diff --git a/docs/openspec/specs/web-ui/design.md b/docs/openspec/specs/web-ui/design.md
index 92ab1ad..1f7e520 100644
--- a/docs/openspec/specs/web-ui/design.md
+++ b/docs/openspec/specs/web-ui/design.md
@@ -15,7 +15,8 @@ internal/cli/serve.go ──▶ web.NewServer(store, cfg, log)
GET /c/{id} handleConversation │ transcript + infinite scroll
GET /c/{id}/messages handleMessages │ keyset next-page partial
GET /c/{id}/at/{mid} handleConversationAt │ jump-to-context (ownership check)
- GET /status handleStatus │ freshness + ingest + snapshots
+ GET /status handleStatus │ freshness + ingest
+ GET /backups handleBackups │ encrypted DB snapshot inventory
GET /media/{id}/... handleMedia ─┘ source-aware, traversal-safe
GET /static/... embedded assets (htmx, theme.js, app.css)
```
diff --git a/internal/web/backups.go b/internal/web/backups.go
new file mode 100644
index 0000000..4238c7f
--- /dev/null
+++ b/internal/web/backups.go
@@ -0,0 +1,64 @@
+// The Backups tab (issue #2): the encrypted-DB-snapshots inventory graduated
+// out of /status into its own Settings-shell section at /backups. It renders
+// ONLY the snapshot story — total footprint, snapshot count, and the
+// per-snapshot table (name, taken-at, size, retention tier) — reusing
+// store.ListSnapshots and the footprint sum that used to live in handleStatus.
+//
+// HasSnapshotPipeline is preserved verbatim from #164: a machine with no
+// snapshot pipeline (the desktop-onboarded shape — no recorded snapshots, no
+// .snapshots dir in the signal archive) shows one neutral line instead of a
+// "0 B across 0 snapshots" card that read like a failure.
+package web
+
+import (
+ "net/http"
+
+ "github.com/joestump/msgbrowse/internal/store"
+)
+
+// backupsData drives the Backups tab. It carries the snapshot inventory that
+// used to hang off statusData; the surface has no stat strip, so it needs
+// neither the global counts nor the ingest run.
+type backupsData struct {
+ baseData
+ Snapshots []store.Snapshot
+ SnapshotFootprint int64
+ // HasSnapshotPipeline gates the Encrypted-DB-snapshots card (issue #164):
+ // true when snapshots are recorded or the signal archive carries a
+ // .snapshots directory; false renders one neutral line instead of the card.
+ HasSnapshotPipeline bool
+}
+
+// handleBackups renders the Backups tab — the snapshot inventory only. Like
+// the other Settings-shell sections it is a safe GET with no privileged work.
+// The boosted-partial path (REQ-0008-006) skips the sidebar listing via
+// partialBase; the snapshot query is cheap enough to run on both paths.
+func (s *Server) handleBackups(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ var base baseData
+ if isPartialRequest(r) {
+ base = partialBase("Backups · msgbrowse", 0)
+ } else {
+ var err error
+ base, err = s.baseData(ctx, "Backups · msgbrowse", 0)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ }
+ snaps, err := s.store.ListSnapshots(ctx)
+ if err != nil {
+ s.serverError(w, err)
+ return
+ }
+ var footprint int64
+ for _, sn := range snaps {
+ footprint += sn.SizeBytes
+ }
+ s.render(w, r, "backups", backupsData{
+ baseData: base,
+ Snapshots: snaps,
+ SnapshotFootprint: footprint,
+ HasSnapshotPipeline: len(snaps) > 0 || s.signalSnapshotsDirExists(),
+ })
+}
diff --git a/internal/web/backups_test.go b/internal/web/backups_test.go
new file mode 100644
index 0000000..966ff81
--- /dev/null
+++ b/internal/web/backups_test.go
@@ -0,0 +1,102 @@
+package web
+
+import (
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+// TestBackupsPage (issue #2): the Backups tab renders the snapshot inventory
+// that used to live on /status — total footprint, count, and the per-snapshot
+// table (name, taken-at, size, retention tier). The fixture archive carries a
+// .snapshots dir with three tarballs (daily/monthly/yearly tiers), so the card
+// and its rows must render.
+func TestBackupsPage(t *testing.T) {
+ srv, _, _ := newTestServer(t)
+ rec := get(t, srv, "/backups")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("backups page = %d", rec.Code)
+ }
+ body := rec.Body.String()
+
+ // The snapshot card, its footprint copy, and the per-tier rows.
+ for _, want := range []string{
+ "Encrypted DB snapshots",
+ "Total footprint",
+ "never opens or decrypts",
+ "status-table",
+ "tier-pill",
+ "daily", "monthly", "yearly",
+ } {
+ if !contains(body, want) {
+ t.Errorf("backups page missing %q", want)
+ }
+ }
+ // It renders inside the Settings shell with the Backups tab active.
+ if !contains(body, `
Settings
`) {
+ t.Error("backups page missing the shared Settings shell h1")
+ }
+ if !contains(body, `href="/backups" class="settings-tab settings-tab-active"`) {
+ t.Error("backups page missing its active sub-nav tab")
+ }
+ // The inventory-only scope: no ingest grid, no archive-freshness strip.
+ for _, absent := range []string{"Last ingest", "Archive freshness"} {
+ if contains(body, absent) {
+ t.Errorf("backups page leaked the Status-only surface %q", absent)
+ }
+ }
+}
+
+// TestBackupsPageNoPipeline is the issue-#164 behavior, preserved on the new
+// tab: with no snapshots recorded and no .snapshots dir in the signal archive
+// (the desktop-onboarded shape — newManagedRootServer's temp managed root),
+// the Encrypted-DB-snapshots card is replaced by one neutral line; growing a
+// .snapshots dir brings the card back even before any rows are ingested.
+func TestBackupsPageNoPipeline(t *testing.T) {
+ srv, _, managed := newManagedRootServer(t)
+
+ body := get(t, srv, "/backups").Body.String()
+ if contains(body, "Encrypted DB snapshots") {
+ t.Error("/backups rendered the snapshots card with no snapshot pipeline")
+ }
+ if !contains(body, "No snapshot pipeline on this machine.") {
+ t.Error("/backups missing the neutral no-pipeline line")
+ }
+
+ if err := os.MkdirAll(filepath.Join(managed, ".snapshots"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ body = get(t, srv, "/backups").Body.String()
+ if !contains(body, "Encrypted DB snapshots") {
+ t.Error("/backups hid the snapshots card despite a .snapshots dir in the archive")
+ }
+ if contains(body, "No snapshot pipeline on this machine.") {
+ t.Error("/backups kept the no-pipeline line beside the snapshots card")
+ }
+}
+
+// TestBackupsBoostedPartial: the boosted (#main-content) swap of /backups
+// carries the snapshot inventory and its owning , but none of the
+// document shell — the SPEC-0008 REQ-0008-006 *_content contract.
+func TestBackupsBoostedPartial(t *testing.T) {
+ srv, _, _ := newTestServer(t)
+ rec := getPartial(t, srv, "/backups")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("partial status = %d", rec.Code)
+ }
+ body := rec.Body.String()
+ if !contains(body, "Backups · msgbrowse") {
+ t.Errorf("boosted partial missing its owning title; body starts %q", body[:min(120, len(body))])
+ }
+ if !contains(body, `id="main-content"`) || !contains(body, "Encrypted DB snapshots") {
+ t.Error("boosted partial missing the #main-content snapshot inventory")
+ }
+ // No shell in the boosted swap.
+ for _, forbidden := range []string{" 0 || s.signalSnapshotsDirExists(),
- DeviceSyncEnabled: s.deviceSyncEnabled,
- DeviceSyncFeature: s.deviceSyncFeature,
- Sync: s.syncStatusSnapshot(ctx),
+ baseData: base,
+ ConversationCount: convCount,
+ Run: run,
+ NewestTS: newest,
+ DeviceSyncEnabled: s.deviceSyncEnabled,
+ DeviceSyncFeature: s.deviceSyncFeature,
+ Sync: s.syncStatusSnapshot(ctx),
})
}
diff --git a/internal/web/handlers_test.go b/internal/web/handlers_test.go
index ab7b66f..4f02f13 100644
--- a/internal/web/handlers_test.go
+++ b/internal/web/handlers_test.go
@@ -424,16 +424,18 @@ func TestStatusPage(t *testing.T) {
t.Fatalf("status page = %d", rec.Code)
}
body := rec.Body.String()
- for _, want := range []string{"daily", "monthly", "yearly", "never opens or decrypts"} {
+ // Slate re-skin (REQ-0006-011): slate surfaces, the freshness stat strip,
+ // and the ingest-run metric grid. The snapshot inventory moved to the
+ // Backups tab (#2), so its table/pills no longer render here.
+ for _, want := range []string{"status-card", "stat-strip", "status-grid"} {
if !contains(body, want) {
- t.Errorf("status page missing %q", want)
+ t.Errorf("status page missing slate marker %q", want)
}
}
- // Slate re-skin (REQ-0006-011): slate surfaces, the freshness stat strip, the
- // ingest-run metric grid, the snapshot table, and tier pills.
- for _, want := range []string{"status-card", "stat-strip", "status-grid", "status-table", "tier-pill"} {
- if !contains(body, want) {
- t.Errorf("status page missing slate marker %q", want)
+ // The snapshot surface is gone from Status — it lives on /backups now.
+ for _, absent := range []string{"Encrypted DB snapshots", "tier-pill", "never opens or decrypts"} {
+ if contains(body, absent) {
+ t.Errorf("status page still carries the snapshot marker %q (moved to /backups in #2)", absent)
}
}
}
diff --git a/internal/web/overview.go b/internal/web/overview.go
index 5d830bf..259eaab 100644
--- a/internal/web/overview.go
+++ b/internal/web/overview.go
@@ -9,8 +9,8 @@
// Deliberate non-changes: /status and /settings stay the canonical URLs (the
// settings_subnav contract from #163 — "nothing redirects" — is preserved;
// the Overview duplicates, it does not replace), device pairing stays on
-// Settings, and snapshots stay on /status until they graduate to their own
-// Backups tab (tracked separately).
+// Settings, and snapshots live on their own /backups tab (issue #2) — the
+// Overview surfaces neither.
package web
import (
diff --git a/internal/web/partial_test.go b/internal/web/partial_test.go
index ed40d20..a8a9ec1 100644
--- a/internal/web/partial_test.go
+++ b/internal/web/partial_test.go
@@ -83,6 +83,7 @@ func pageRoutes(t *testing.T, st *store.Store) []string {
"/search",
"/gallery",
"/status",
+ "/backups",
"/providers",
"/logs",
"/settings",
diff --git a/internal/web/server.go b/internal/web/server.go
index 8dac85b..ba80b66 100644
--- a/internal/web/server.go
+++ b/internal/web/server.go
@@ -363,6 +363,9 @@ func (s *Server) routes() http.Handler {
mux.HandleFunc("GET /c/{id}/messages", s.handleMessages)
mux.HandleFunc("GET /c/{id}/at/{mid}", s.handleConversationAt)
mux.HandleFunc("GET /status", s.handleStatus)
+ // The Backups tab (issue #2): the encrypted-DB-snapshot inventory, moved out
+ // of /status into its own Settings-shell section. A safe GET, no mutation.
+ mux.HandleFunc("GET /backups", s.handleBackups)
// The Setup surface is presented to the user as "Providers" (its route is
// /providers); /setup 301-redirects for compatibility with any existing links
// or bookmarks. The privileged POSTs keep the /setup/* prefix — they are
diff --git a/internal/web/settings_shell_test.go b/internal/web/settings_shell_test.go
index 1c583cb..bc0b4b6 100644
--- a/internal/web/settings_shell_test.go
+++ b/internal/web/settings_shell_test.go
@@ -1,17 +1,15 @@
package web
import (
- "os"
- "path/filepath"
"strings"
"testing"
)
-// The issue-#163 acceptance, extended by #175 with the Providers tab and by
-// #191 with the LLM tab: Settings, Providers, Logs, Status & backups, and LLM
-// render as one shell with sub-navigation — each page carries the shared h1 +
-// the boosted sub-nav with its own tab active — while the old routes stay the
-// canonical, working URLs.
+// The issue-#163 acceptance, extended by #175 with the Providers tab, by #191
+// with the LLM tab, and by #2 with the Backups tab: Settings, Providers, Logs,
+// Status, Backups, and LLM render as one shell with sub-navigation — each page
+// carries the shared h1 + the boosted sub-nav with its own tab active — while
+// the old routes stay the canonical, working URLs.
func TestSettingsShellSubNav(t *testing.T) {
srv, _, _ := newTestServer(t)
@@ -23,6 +21,7 @@ func TestSettingsShellSubNav(t *testing.T) {
{"/providers", `href="/providers" class="settings-tab settings-tab-active"`},
{"/logs", `href="/logs" class="settings-tab settings-tab-active"`},
{"/status", `href="/status" class="settings-tab settings-tab-active"`},
+ {"/backups", `href="/backups" class="settings-tab settings-tab-active"`},
{"/settings/llm", `href="/settings/llm" class="settings-tab settings-tab-active"`},
}
for _, c := range cases {
@@ -45,18 +44,23 @@ func TestSettingsShellSubNav(t *testing.T) {
if !contains(body, `Settings
`) {
t.Errorf("page missing the shared Settings shell h1")
}
- // All five sections stay reachable from every tab.
- for _, href := range []string{`href="/settings"`, `href="/providers"`, `href="/logs"`, `href="/status"`, `href="/settings/llm"`} {
+ // All six sections stay reachable from every tab.
+ for _, href := range []string{`href="/settings"`, `href="/providers"`, `href="/logs"`, `href="/status"`, `href="/backups"`, `href="/settings/llm"`} {
if !contains(body, href) {
t.Errorf("sub-nav missing %s", href)
}
}
- // Exactly the five tabs, providers second (#175), LLM last (#191).
- if n := strings.Count(body, `class="settings-tab`); n != 5 {
- t.Errorf("sub-nav has %d tabs, want 5", n)
+ // Exactly the six tabs, providers second (#175), Backups after
+ // Status (#2), LLM last (#191).
+ if n := strings.Count(body, `class="settings-tab`); n != 6 {
+ t.Errorf("sub-nav has %d tabs, want 6", n)
}
- if llmAt, statusAt := strings.Index(body, `href="/settings/llm"`), strings.Index(body, `href="/status"`); llmAt < statusAt {
- t.Error("LLM tab should be the LAST sub-nav tab (after Status & backups)")
+ backupsAt, statusAt := strings.Index(body, `href="/backups"`), strings.Index(body, `href="/status"`)
+ if backupsAt < statusAt {
+ t.Error("Backups tab should follow the Status tab (#2)")
+ }
+ if llmAt := strings.Index(body, `href="/settings/llm"`); llmAt < backupsAt {
+ t.Error("LLM tab should be the LAST sub-nav tab (after Backups)")
}
// Exactly one h1 per page (accessibility: single h1).
if n := strings.Count(body, "
+ keeps history entries titled; the define owns so
+ partial responses carry no shell.
+
+ The Backups tab (issue #2): the encrypted-DB-snapshots inventory, moved out
+ of /status into its own Settings-shell section. Renders ONLY the snapshot
+ story — total footprint, count, and the per-snapshot table — inside the
+ shared Settings shell (h1 + sub-nav), with /backups its canonical URL. */}}
+{{define "backups_content"}}{{.Title}}
+
+
+
Settings
+ {{template "settings_subnav" "backups"}}
+
+ {{/* The snapshots card renders only where a snapshot pipeline exists
+ (issue #164): recorded snapshots, or a .snapshots dir in the signal
+ archive. A desktop-onboarded machine has neither — "0 B across 0
+ snapshots … No snapshots found" read like a failure there, so it gets
+ one neutral line instead of the card. */}}
+ {{if .HasSnapshotPipeline}}
+
+
Encrypted DB snapshots
+
Total footprint: {{humanSize .SnapshotFootprint}} across {{num (len .Snapshots)}} snapshots.
+ These are SQLCipher-encrypted raw database backups for disaster recovery —
+ msgbrowse lists them but never opens or decrypts them.
+ {{if .Snapshots}}
+
+
+ | Snapshot | Taken | Size | Retention tier |
+
+ {{- range .Snapshots}}
+
+ | {{.Filename}} |
+ {{.TakenAt.Format "2006-01-02 15:04"}} |
+ {{humanSize .SizeBytes}} |
+ {{.Tier}} |
+
+ {{- end}}
+
+
+
+ {{else}}
+
No snapshots found under .snapshots/.
+ {{end}}
+
+ {{else}}
+
No snapshot pipeline on this machine.
+ {{end}}
+
+
+{{end}}
diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html
index c6beab6..0c73114 100644
--- a/internal/web/templates/index.html
+++ b/internal/web/templates/index.html
@@ -127,8 +127,8 @@ msgbrowse
{{template "icon-server" .}}
- Status & backups
- Ingest details & snapshots
+ Status
+ Ingest & sync health
diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html
index 1f40c76..b15de0f 100644
--- a/internal/web/templates/settings.html
+++ b/internal/web/templates/settings.html
@@ -1,12 +1,14 @@
{{define "settings"}}{{template "page_start" .}}{{template "settings_content" .}}{{template "page_end" .}}{{end}}
{{/* settings_subnav is the Settings shell's sub-navigation (issue #163):
- Settings · Providers · Logs · Status & backups · LLM, rendered at the top
+ Settings · Providers · Logs · Status · Backups · LLM, rendered at the top
of each section's page so they read as one surface instead of link-card
jumps to standalone destinations. Providers joined the shell in #175 (it
left the sidebar — a configuration surface, not a primary destination);
- LLM joined last in #191 (the AI endpoint tab). The dot is the active
- tab's token ("settings" / "providers" / "logs" / "status" / "llm"); the
+ LLM joined in #191 (the AI endpoint tab); Backups split off Status in #2
+ (the encrypted-DB-snapshot inventory graduated to its own tab, so Status
+ is once again just ingest health). The dot is the active tab's token
+ ("settings" / "providers" / "logs" / "status" / "backups" / "llm"); the
active tab carries aria-current so the state is conveyed as more than
color (SPEC-0013 §Accessibility spirit). Boosted like every in-app nav
(SPEC-0008 REQ-0008-006), so switching tabs swaps only #main-content; the
@@ -17,7 +19,8 @@
Settings
Providers
Logs
- Status & backups
+ Status
+ Backups
LLM
{{end}}
diff --git a/internal/web/templates/status.html b/internal/web/templates/status.html
index 03494c0..55a7cd3 100644
--- a/internal/web/templates/status.html
+++ b/internal/web/templates/status.html
@@ -5,13 +5,14 @@
partial responses carry no shell. */}}
{{define "status_content"}}{{.Title}}
- {{/* Status & backups (REQ-0006-011): re-skinned to the slate system — slate
+ {{/* Status (REQ-0006-011): re-skinned to the slate system — slate
surfaces/borders, mono tabular values — with no behavioral change. Keeps
- the archive-freshness stat strip, the ingest-run metric grid, and the
- snapshot table. The counts come from dedicated fields (not
- len .Conversations) so partial renders can skip the sidebar listing.
- Renders inside the Settings shell (issue #163): shared h1 + sub-nav,
- with /status staying the canonical URL. */}}
+ the archive-freshness stat strip and the ingest-run metric grid; the
+ encrypted-DB-snapshot inventory graduated to its own Backups tab
+ (issue #2). The counts come from dedicated fields (not len .Conversations)
+ so partial renders can skip the sidebar listing. Renders inside the
+ Settings shell (issue #163): shared h1 + sub-nav, with /status staying
+ the canonical URL. */}}
Settings
{{template "settings_subnav" "status"}}
@@ -123,41 +124,6 @@ Settings
{{end}}
{{- end}}
-
- {{/* The snapshots card renders only where a snapshot pipeline exists
- (issue #164): recorded snapshots, or a .snapshots dir in the signal
- archive. A desktop-onboarded machine has neither — "0 B across 0
- snapshots … No snapshots found" read like a failure there, so it gets
- one neutral line instead. */}}
- {{if .HasSnapshotPipeline}}
-
-
Encrypted DB snapshots
-
Total footprint: {{humanSize .SnapshotFootprint}} across {{num (len .Snapshots)}} snapshots.
- These are SQLCipher-encrypted raw database backups for disaster recovery —
- msgbrowse lists them but never opens or decrypts them.
- {{if .Snapshots}}
-
-
- | Snapshot | Taken | Size | Retention tier |
-
- {{- range .Snapshots}}
-
- | {{.Filename}} |
- {{.TakenAt.Format "2006-01-02 15:04"}} |
- {{humanSize .SizeBytes}} |
- {{.Tier}} |
-
- {{- end}}
-
-
-
- {{else}}
-
No snapshots found under .snapshots/.
- {{end}}
-
- {{else}}
- No snapshot pipeline on this machine.
- {{end}}
{{end}}