From a48dc83784743134f45dac5b5ce6e9231b29b102 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 13:21:48 +0000 Subject: [PATCH 1/2] feat(web): move encrypted DB snapshots into a dedicated Backups tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The encrypted-DB-snapshot inventory graduated out of /status into its own Settings-shell section at /backups (issue #2). The new tab 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. - New GET /backups route + handleBackups (internal/web/backups.go), following the SPEC-0008 *_content boosted-partial pattern (backups_content owns the + #main-content). - Snapshot card markup moved from status.html into backups.html; statusData drops Snapshots/SnapshotFootprint/HasSnapshotPipeline and handleStatus no longer computes them. Status is once again just ingest + sync health. - HasSnapshotPipeline behavior preserved verbatim (#164): a machine with no snapshot pipeline shows the single neutral "No snapshot pipeline on this machine" line, not a "0 B across 0 snapshots" card. - settings_subnav gains a Backups tab after Status (Status renamed from "Status & backups"); the Overview's stale Status quick-link copy updated. - Tests: TestBackupsPage (with pipeline), TestBackupsPageNoPipeline (moved from the old /status conditional test), TestBackupsBoostedPartial; /backups added to the shared pageRoutes so the REQ-0008-006 partial/full contract covers it; TestStatusPage asserts the snapshot surface is gone from /status. No new CSS classes (backups reuses status-* classes; the tab reuses settings-tab), so app.css is unchanged and CI-fresh. Part of #2 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- internal/web/backups.go | 64 +++++++++++++++++ internal/web/backups_test.go | 102 +++++++++++++++++++++++++++ internal/web/handlers.go | 35 ++------- internal/web/handlers_test.go | 16 +++-- internal/web/overview.go | 4 +- internal/web/partial_test.go | 1 + internal/web/server.go | 3 + internal/web/settings_shell_test.go | 62 +++++----------- internal/web/templates/backups.html | 53 ++++++++++++++ internal/web/templates/index.html | 4 +- internal/web/templates/settings.html | 11 +-- internal/web/templates/status.html | 48 ++----------- 12 files changed, 275 insertions(+), 128 deletions(-) create mode 100644 internal/web/backups.go create mode 100644 internal/web/backups_test.go create mode 100644 internal/web/templates/backups.html 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, `<h1 class="screen-h1">Settings</h1>`) { + 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 <title>, 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, "<title>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}} +
+ + + + {{- range .Snapshots}} + + + + + + + {{- end}} + +
SnapshotTakenSizeRetention tier
{{.Filename}}{{.TakenAt.Format "2006-01-02 15:04"}}{{humanSize .SizeBytes}}{{.Tier}}
+
+ {{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}} -
- - - - {{- range .Snapshots}} - - - - - - - {{- end}} - -
SnapshotTakenSizeRetention tier
{{.Filename}}{{.TakenAt.Format "2006-01-02 15:04"}}{{humanSize .SizeBytes}}{{.Tier}}
-
- {{else}} -

No snapshots found under .snapshots/.

- {{end}} -
- {{else}} -

No snapshot pipeline on this machine.

- {{end}}
{{end}} From 77076949880ab67f183599fb597c82f841014ec8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Jul 2026 13:26:57 +0000 Subject: [PATCH 2/2] docs(web-ui): update route map for Backups tab Status no longer renders snapshots (moved to /backups in #2); document the new GET /backups route in the design.md route map. Co-Authored-By: Claude Fable 5 --- docs/openspec/specs/web-ui/design.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) ```