Skip to content
This repository was archived by the owner on Jul 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/openspec/specs/web-ui/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Expand Down
64 changes: 64 additions & 0 deletions internal/web/backups.go
Original file line number Diff line number Diff line change
@@ -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(),
})
}
102 changes: 102 additions & 0 deletions internal/web/backups_test.go
Original file line number Diff line number Diff line change
@@ -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</title>") {
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{"<!doctype", "app-sidebar", "app-toolbar"} {
if contains(strings.ToLower(body), forbidden) {
t.Errorf("boosted partial leaked shell marker %q", forbidden)
}
}
}
35 changes: 7 additions & 28 deletions internal/web/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,16 +173,7 @@ type statusData struct {
baseData
ConversationCount int // stat-strip count; independent of the sidebar listing (REQ-0008-006)
Run *store.IngestRun
Snapshots []store.Snapshot
NewestTS string
SnapshotFootprint int64
// HasSnapshotPipeline gates the Encrypted-DB-snapshots card (issue #164):
// on a desktop-onboarded machine there IS no snapshot pipeline (that flow
// is the Cowork/launchd Signal export), so "0 B across 0 snapshots … No
// snapshots found" read like a failure. True when snapshots are recorded or
// the signal archive carries a .snapshots directory; false renders one
// neutral line instead of the card.
HasSnapshotPipeline bool
// DeviceSyncEnabled mirrors config device_sync.enabled for the Device
// sync card's disabled state; Sync is the live snapshot (nil when sync is
// disabled, no monitor is wired, or the registry read failed) — SPEC-0014
Expand Down Expand Up @@ -527,31 +518,19 @@ func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
s.serverError(w, err)
return
}
snaps, err := s.store.ListSnapshots(ctx)
if err != nil {
s.serverError(w, err)
return
}
newest, err := s.store.NewestMessageTS(ctx)
if err != nil {
s.serverError(w, err)
return
}
var footprint int64
for _, sn := range snaps {
footprint += sn.SizeBytes
}
s.render(w, r, "status", statusData{
baseData: base,
ConversationCount: convCount,
Run: run,
Snapshots: snaps,
NewestTS: newest,
SnapshotFootprint: footprint,
HasSnapshotPipeline: len(snaps) > 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),
})
}

Expand Down
16 changes: 9 additions & 7 deletions internal/web/handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions internal/web/overview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
1 change: 1 addition & 0 deletions internal/web/partial_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ func pageRoutes(t *testing.T, st *store.Store) []string {
"/search",
"/gallery",
"/status",
"/backups",
"/providers",
"/logs",
"/settings",
Expand Down
3 changes: 3 additions & 0 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 18 additions & 44 deletions internal/web/settings_shell_test.go
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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 {
Expand All @@ -45,18 +44,23 @@ func TestSettingsShellSubNav(t *testing.T) {
if !contains(body, `<h1 class="screen-h1">Settings</h1>`) {
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, "<h1"); n != 1 {
Expand Down Expand Up @@ -87,33 +91,3 @@ func TestBuiltCSSCarriesSettingsShell(t *testing.T) {
}
}
}

// TestStatusSnapshotsConditional is the issue-#164 acceptance: 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; with a
// .snapshots dir present, the card renders.
func TestStatusSnapshotsConditional(t *testing.T) {
srv, _, managed := newManagedRootServer(t)

body := get(t, srv, "/status").Body.String()
if contains(body, "Encrypted DB snapshots") {
t.Error("/status rendered the snapshots card with no snapshot pipeline")
}
if !contains(body, "No snapshot pipeline on this machine.") {
t.Error("/status missing the neutral no-pipeline line")
}

// Grow a .snapshots dir in the (temp) archive: the pipeline exists, so the
// card renders even before any snapshot rows are ingested.
if err := os.MkdirAll(filepath.Join(managed, ".snapshots"), 0o755); err != nil {
t.Fatal(err)
}
body = get(t, srv, "/status").Body.String()
if !contains(body, "Encrypted DB snapshots") {
t.Error("/status hid the snapshots card despite a .snapshots dir in the archive")
}
if contains(body, "No snapshot pipeline on this machine.") {
t.Error("/status kept the no-pipeline line beside the snapshots card")
}
}
Loading
Loading