From 77c6b62ee2b9545cdf5120eac598927ffbc92e59 Mon Sep 17 00:00:00 2001 From: Developer Date: Wed, 17 Jun 2026 12:53:36 +0000 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20slice=203=20=E2=80=94=20Backu?= =?UTF-8?q?ps=20cluster=20migration=20+=20nav/IA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web UI rework. - Migrate BackupAlertsTable, BackupJobsTable, BackupRunsTable, BackupsPage, BackupDashboardWidget off @mui (shadcn Table + Badge severity variants: success=chart-2, warning=chart-3, destructive) - App.tsx IA: Backups now top-level nav (DatabaseBackup icon); Media surface primary at /media; /applications -> /media redirect in both route trees (mirrors /monitoring -> /observability) Gate: build + lint + test green. --- frontend/src/App.tsx | 24 ++- frontend/src/components/BackupAlertsTable.tsx | 109 ++++++----- .../src/components/BackupDashboardWidget.tsx | 93 +++++---- frontend/src/components/BackupJobsTable.tsx | 146 +++++++------- frontend/src/components/BackupRunsTable.tsx | 179 +++++++++--------- frontend/src/components/BackupsPage.tsx | 123 ++++++------ .../__tests__/BackupAlertsTable.test.tsx | 63 ++++++ .../__tests__/BackupDashboardWidget.test.tsx | 63 ++++++ .../__tests__/BackupRunsTable.test.tsx | 59 ++++++ .../changes/web-ui-rework/apply-progress.md | 168 ++++++++++++++++ openspec/changes/web-ui-rework/tasks.md | 16 +- 11 files changed, 720 insertions(+), 323 deletions(-) create mode 100644 frontend/src/components/__tests__/BackupAlertsTable.test.tsx create mode 100644 frontend/src/components/__tests__/BackupDashboardWidget.test.tsx create mode 100644 frontend/src/components/__tests__/BackupRunsTable.test.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3fdb226..e77c6b4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,6 +43,7 @@ import { import { LayoutDashboard, Activity, + DatabaseBackup, Monitor, Users, Zap, @@ -82,8 +83,9 @@ function useDarkMode() { const navItems = [ { path: "/", label: "Dashboard", icon: LayoutDashboard }, { path: "/observability", label: "Observability", icon: Activity }, - { path: "/applications", label: "Media", icon: Monitor }, + { path: "/media", label: "Media", icon: Monitor }, { path: "/files", label: "Files", icon: FolderOpen }, + { path: "/backups", label: "Backups", icon: DatabaseBackup }, { path: "/users", label: "Users", icon: Users }, { path: "/actions", label: "Actions", icon: Zap }, { path: "/settings", label: "Settings", icon: SettingsIcon }, @@ -432,9 +434,15 @@ function AppInner() { }> } /> - } /> - } /> + } + /> } /> + } + /> } /> } /> } /> @@ -457,9 +465,15 @@ function AppInner() { } > } /> - } /> - } /> + } + /> } /> + } + /> } /> } /> } /> diff --git a/frontend/src/components/BackupAlertsTable.tsx b/frontend/src/components/BackupAlertsTable.tsx index f971ff3..73bad79 100644 --- a/frontend/src/components/BackupAlertsTable.tsx +++ b/frontend/src/components/BackupAlertsTable.tsx @@ -1,56 +1,73 @@ -import { Button, Chip, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import type { BackupAlert } from "../types/backups"; interface Props { - alerts: BackupAlert[]; - onAcknowledge: (alertId: string) => void; + alerts: BackupAlert[]; + onAcknowledge: (alertId: string) => void; } function formatTimestamp(ts: number): string { - return new Date(ts * 1000).toLocaleString(); + return new Date(ts * 1000).toLocaleString(); +} + +type SeverityVariant = "destructive" | "warning"; + +/** + * Map an alert severity onto a Badge variant per design §2.3. + * `critical` → destructive (chart-4); `warning` → warning (chart-3). + */ +function severityVariant(severity: string): SeverityVariant { + return severity === "critical" ? "destructive" : "warning"; } export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) { - return ( - - - - - Severity - Type - Message - Created - Actions - - - - {alerts.map((alert) => ( - - - - - {alert.alert_type} - {alert.message} - {formatTimestamp(alert.created_at)} - - {!alert.acknowledged && ( - - )} - - - ))} - -
-
- ); + return ( +
+ + + + Severity + Type + Message + Created + Actions + + + + {alerts.map((alert) => ( + + + + {alert.severity} + + + {alert.alert_type} + {alert.message} + {formatTimestamp(alert.created_at)} + + {!alert.acknowledged && ( + + )} + + + ))} + +
+
+ ); } diff --git a/frontend/src/components/BackupDashboardWidget.tsx b/frontend/src/components/BackupDashboardWidget.tsx index 5364b39..98864f1 100644 --- a/frontend/src/components/BackupDashboardWidget.tsx +++ b/frontend/src/components/BackupDashboardWidget.tsx @@ -1,52 +1,49 @@ -import { Card, CardContent, Typography, Box, Chip } from "@mui/material"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { useBackupDashboard } from "../hooks/useBackups"; export default function BackupDashboardWidget() { - const { data, isLoading } = useBackupDashboard(); - - if (isLoading || !data) { - return ( - - - Backups - Loading... - - - ); - } - - return ( - - - Backups - - - {data.total_jobs} - Jobs - - - {data.success_rate_24h}% - 24h Success - - - - {data.active_alerts > 0 ? ( - - ) : ( - 0 - )} - - Alerts - - {data.last_failed_at && ( - - - Last failed: {new Date(data.last_failed_at * 1000).toLocaleString()} - - - )} - - - - ); + const { data, isLoading } = useBackupDashboard(); + + return ( + + + Backups + + + {isLoading || !data ? ( +

Loading…

+ ) : ( +
+
+
{data.total_jobs}
+
Jobs
+
+
+
+ {data.success_rate_24h}% +
+
24h Success
+
+
+
+ {data.active_alerts > 0 ? ( + {data.active_alerts} + ) : ( + 0 + )} +
+
Alerts
+
+ {data.last_failed_at && ( +
+ Last failed:{" "} + {new Date(data.last_failed_at * 1000).toLocaleString()} +
+ )} +
+ )} +
+
+ ); } diff --git a/frontend/src/components/BackupJobsTable.tsx b/frontend/src/components/BackupJobsTable.tsx index fe5b3e8..400f016 100644 --- a/frontend/src/components/BackupJobsTable.tsx +++ b/frontend/src/components/BackupJobsTable.tsx @@ -1,84 +1,90 @@ +import { Badge } from "@/components/ui/badge"; import { - Chip, - Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, -} from "@mui/material"; + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import type { BackupJob, BackupRun } from "../types/backups"; interface Props { - jobs: BackupJob[]; - latestRuns: Map; + jobs: BackupJob[]; + latestRuns: Map; } function formatInterval(seconds: number | null): string { - if (!seconds) return "N/A"; - if (seconds < 60) return `${seconds}s`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; - if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; - return `${Math.floor(seconds / 86400)}d`; + if (!seconds) return "N/A"; + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; + return `${Math.floor(seconds / 86400)}d`; } function formatTimestamp(ts: number | null): string { - if (!ts) return "Never"; - return new Date(ts * 1000).toLocaleString(); + if (!ts) return "Never"; + return new Date(ts * 1000).toLocaleString(); +} + +type StatusVariant = "success" | "destructive" | "warning" | "secondary"; + +/** + * Map a job/run status onto a Badge variant per design §2.3: + * `success` → success (chart-2); `failure` → destructive (chart-4); + * `in_progress` → warning (chart-3); unknown → secondary (neutral accent). + */ +function statusVariant(status: string): StatusVariant { + if (status === "success") return "success"; + if (status === "failure") return "destructive"; + if (status === "in_progress") return "warning"; + return "secondary"; } export default function BackupJobsTable({ jobs, latestRuns }: Props) { - return ( - - - - - Name - Source - Target - Schedule - Last Status - Last Run - Next Expected - - - - {jobs.map((job) => { - const run = latestRuns.get(job.id); - const status = run?.status ?? "unknown"; - const nextExpected = run && job.schedule_interval_seconds - ? run.started_at + job.schedule_interval_seconds - : null; - - return ( - - {job.name} - {job.source ?? "—"} - {job.target ?? "—"} - {formatInterval(job.schedule_interval_seconds)} - - - - {formatTimestamp(run?.started_at ?? null)} - {formatTimestamp(nextExpected)} - - ); - })} - -
-
- ); + return ( +
+ + + + Name + Source + Target + Schedule + Last Status + Last Run + Next Expected + + + + {jobs.map((job) => { + const run = latestRuns.get(job.id); + const status = run?.status ?? "unknown"; + const nextExpected = + run && job.schedule_interval_seconds + ? run.started_at + job.schedule_interval_seconds + : null; + + return ( + + {job.name} + {job.source ?? "—"} + {job.target ?? "—"} + + {formatInterval(job.schedule_interval_seconds)} + + + {status} + + + {formatTimestamp(run?.started_at ?? null)} + + {formatTimestamp(nextExpected)} + + ); + })} + +
+
+ ); } diff --git a/frontend/src/components/BackupRunsTable.tsx b/frontend/src/components/BackupRunsTable.tsx index 34b4b18..2e2a82d 100644 --- a/frontend/src/components/BackupRunsTable.tsx +++ b/frontend/src/components/BackupRunsTable.tsx @@ -1,103 +1,110 @@ -import { - Chip, - FormControl, - InputLabel, - MenuItem, - Paper, - Select, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, -} from "@mui/material"; import { useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; import type { BackupRun } from "../types/backups"; interface Props { - runs: BackupRun[]; + runs: BackupRun[]; } function formatBytes(bytes: number | null): string { - if (bytes === null || bytes === undefined) return "—"; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; - return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; + if (bytes === null || bytes === undefined) return "—"; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + if (bytes < 1024 * 1024 * 1024) + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } function formatDuration(ms: number | null): string { - if (ms === null || ms === undefined) return "—"; - if (ms < 1000) return `${ms}ms`; - if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; - if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`; - return `${(ms / 3600_000).toFixed(1)}h`; + if (ms === null || ms === undefined) return "—"; + if (ms < 1000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; + if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`; + return `${(ms / 3600_000).toFixed(1)}h`; } function formatTimestamp(ts: number): string { - return new Date(ts * 1000).toLocaleString(); + return new Date(ts * 1000).toLocaleString(); +} + +type StatusVariant = "success" | "destructive" | "warning"; + +/** + * Map a run status onto a Badge variant per design §2.3: + * `success` → success (chart-2); `failure` → destructive (chart-4); + * `in_progress` → warning (chart-3). + */ +function statusVariant(status: string): StatusVariant { + if (status === "success") return "success"; + if (status === "failure") return "destructive"; + return "warning"; } export default function BackupRunsTable({ runs }: Props) { - const [statusFilter, setStatusFilter] = useState("all"); - - const filteredRuns = statusFilter === "all" - ? runs - : runs.filter((r) => r.status === statusFilter); - - return ( - <> - - Status - - - - - - - - Job - Status - Duration - Size - Started - - - - {filteredRuns.map((run) => ( - - {run.job_id} - - - - {formatDuration(run.duration_ms)} - {formatBytes(run.bytes_transferred)} - {formatTimestamp(run.started_at)} - - ))} - -
-
- - ); + const [statusFilter, setStatusFilter] = useState("all"); + + const filteredRuns = + statusFilter === "all" + ? runs + : runs.filter((r) => r.status === statusFilter); + + return ( +
+ + +
+ + + + Job + Status + Duration + Size + Started + + + + {filteredRuns.map((run) => ( + + {run.job_id} + + + {run.status} + + + {formatDuration(run.duration_ms)} + {formatBytes(run.bytes_transferred)} + {formatTimestamp(run.started_at)} + + ))} + +
+
+
+ ); } diff --git a/frontend/src/components/BackupsPage.tsx b/frontend/src/components/BackupsPage.tsx index 5c19abb..cee63c0 100644 --- a/frontend/src/components/BackupsPage.tsx +++ b/frontend/src/components/BackupsPage.tsx @@ -1,69 +1,72 @@ -import { Box, Tab, Tabs, Typography } from "@mui/material"; import { useState } from "react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { - useAcknowledgeAlert, - useBackupAlerts, - useBackupJobs, - useBackupRuns, + useAcknowledgeAlert, + useBackupAlerts, + useBackupJobs, + useBackupRuns, } from "../hooks/useBackups"; import BackupAlertsTable from "./BackupAlertsTable"; import BackupJobsTable from "./BackupJobsTable"; import BackupRunsTable from "./BackupRunsTable"; export default function BackupsPage() { - const [tab, setTab] = useState(0); - const { data: jobsData, isLoading: jobsLoading } = useBackupJobs(); - const { data: runsData, isLoading: runsLoading } = useBackupRuns(); - const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(undefined, false); - const acknowledgeMutation = useAcknowledgeAlert(); - - // Build a map of latest runs per job - const latestRuns = new Map(); - if (runsData) { - for (const run of runsData) { - const existing = latestRuns.get(run.job_id); - if (!existing || run.started_at > existing.started_at) { - latestRuns.set(run.job_id, run); - } - } - } - - return ( - - Backups - - setTab(v)} sx={{ mb: 2 }}> - - - - - - {tab === 0 && ( - jobsLoading ? ( - Loading jobs... - ) : ( - - ) - )} - - {tab === 1 && ( - runsLoading ? ( - Loading runs... - ) : ( - - ) - )} - - {tab === 2 && ( - alertsLoading ? ( - Loading alerts... - ) : ( - acknowledgeMutation.mutate(id)} - /> - ) - )} - - ); + const [tab, setTab] = useState("jobs"); + const { data: jobsData, isLoading: jobsLoading } = useBackupJobs(); + const { data: runsData, isLoading: runsLoading } = useBackupRuns(); + const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts( + undefined, + false, + ); + const acknowledgeMutation = useAcknowledgeAlert(); + + // Build a map of latest runs per job + const latestRuns = new Map(); + if (runsData) { + for (const run of runsData) { + const existing = latestRuns.get(run.job_id); + if (!existing || run.started_at > existing.started_at) { + latestRuns.set(run.job_id, run); + } + } + } + + const alertsLabel = alertsData ? `Alerts (${alertsData.length})` : "Alerts"; + + return ( +
+

Backups

+ + + Jobs + Runs + {alertsLabel} + + + {jobsLoading ? ( +

Loading jobs…

+ ) : ( + + )} +
+ + {runsLoading ? ( +

Loading runs…

+ ) : ( + + )} +
+ + {alertsLoading ? ( +

Loading alerts…

+ ) : ( + acknowledgeMutation.mutate(id)} + /> + )} +
+
+
+ ); } diff --git a/frontend/src/components/__tests__/BackupAlertsTable.test.tsx b/frontend/src/components/__tests__/BackupAlertsTable.test.tsx new file mode 100644 index 0000000..38daefc --- /dev/null +++ b/frontend/src/components/__tests__/BackupAlertsTable.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import BackupAlertsTable from "../BackupAlertsTable"; +import type { BackupAlert } from "../../types/backups"; + +function alert(overrides: Partial = {}): BackupAlert { + return { + id: "a1", + job_id: "job-1", + run_id: null, + alert_type: "failed_status", + severity: "warning", + message: "Run failed", + acknowledged: false, + resolved_at: null, + created_at: 1_700_000_000, + ...overrides, + }; +} + +describe("BackupAlertsTable", () => { + it("maps alert severity onto Badge variants per design §2.3", () => { + render( + , + ); + expect(screen.getByText("critical").getAttribute("data-variant")).toBe( + "destructive", + ); + expect(screen.getByText("warning").getAttribute("data-variant")).toBe( + "warning", + ); + }); + + it("calls onAcknowledge with the alert id when the button is clicked", async () => { + const onAcknowledge = vi.fn(); + render( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Acknowledge" })); + expect(onAcknowledge).toHaveBeenCalledTimes(1); + expect(onAcknowledge).toHaveBeenCalledWith("ack-me"); + }); + + it("hides the acknowledge button for already-acknowledged alerts", () => { + render( + , + ); + expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull(); + }); +}); diff --git a/frontend/src/components/__tests__/BackupDashboardWidget.test.tsx b/frontend/src/components/__tests__/BackupDashboardWidget.test.tsx new file mode 100644 index 0000000..9bc60e0 --- /dev/null +++ b/frontend/src/components/__tests__/BackupDashboardWidget.test.tsx @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import BackupDashboardWidget from "../BackupDashboardWidget"; +import { useBackupDashboard } from "../../hooks/useBackups"; + +// The widget reads from the react-query hook; mocking `useBackupDashboard` lets +// us exercise the render paths without a QueryClientProvider or network. +vi.mock("../../hooks/useBackups", () => ({ + useBackupDashboard: vi.fn(), +})); + +const mockUseBackupDashboard = vi.mocked(useBackupDashboard); +type DashboardResult = ReturnType; + +function mockResult( + data: DashboardResult["data"], + isLoading = false, +): DashboardResult { + return { data, isLoading } as DashboardResult; +} + +beforeEach(() => { + mockUseBackupDashboard.mockReset(); +}); + +describe("BackupDashboardWidget", () => { + it("renders the loading state while data is pending", () => { + mockUseBackupDashboard.mockReturnValue(mockResult(undefined, true)); + render(); + expect(screen.getByText("Loading…")).toBeInTheDocument(); + }); + + it("renders the backup dashboard stats (jobs / 24h success)", () => { + mockUseBackupDashboard.mockReturnValue( + mockResult({ + total_jobs: 4, + success_rate_24h: 96, + active_alerts: 0, + last_failed_at: null, + }), + ); + render(); + expect(screen.getByText("4")).toBeInTheDocument(); + expect(screen.getByText("96%")).toBeInTheDocument(); + expect(screen.getByText("Jobs")).toBeInTheDocument(); + expect(screen.getByText("24h Success")).toBeInTheDocument(); + }); + + it("renders a destructive Badge for active alerts and shows last-failed time", () => { + mockUseBackupDashboard.mockReturnValue( + mockResult({ + total_jobs: 2, + success_rate_24h: 50, + active_alerts: 3, + last_failed_at: 1_700_000_000, + }), + ); + render(); + const badge = screen.getByText("3"); + expect(badge.getAttribute("data-variant")).toBe("destructive"); + expect(screen.getByText(/Last failed:/)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/__tests__/BackupRunsTable.test.tsx b/frontend/src/components/__tests__/BackupRunsTable.test.tsx new file mode 100644 index 0000000..cadd440 --- /dev/null +++ b/frontend/src/components/__tests__/BackupRunsTable.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import BackupRunsTable from "../BackupRunsTable"; +import type { BackupRun } from "../../types/backups"; + +function run(overrides: Partial = {}): BackupRun { + return { + id: "r1", + job_id: "job-1", + started_at: 1_700_000_000, + ended_at: null, + status: "success", + bytes_transferred: 2048, + duration_ms: 1500, + error_message: null, + details_json: null, + created_at: 1_700_000_000, + ...overrides, + }; +} + +describe("BackupRunsTable", () => { + it("maps run status onto Badge variants per design §2.3", () => { + render( + , + ); + expect(screen.getByText("success").getAttribute("data-variant")).toBe( + "success", + ); + expect(screen.getByText("failure").getAttribute("data-variant")).toBe( + "destructive", + ); + expect(screen.getByText("in_progress").getAttribute("data-variant")).toBe( + "warning", + ); + }); + + it("renders the formatted duration and transferred size", () => { + render( + , + ); + expect(screen.getByText("1.5s")).toBeInTheDocument(); + expect(screen.getByText("2.0 KB")).toBeInTheDocument(); + }); +}); diff --git a/openspec/changes/web-ui-rework/apply-progress.md b/openspec/changes/web-ui-rework/apply-progress.md index f39ebe1..c8ac1cf 100644 --- a/openspec/changes/web-ui-rework/apply-progress.md +++ b/openspec/changes/web-ui-rework/apply-progress.md @@ -330,3 +330,171 @@ Slice 2 is complete (25/71 tasks). Next in dependency order: **Slice 3 — Backu cluster + nav/IA** (uses slice-2 `Table`/`Badge`/`Tabs`/cards and lands the `/backups` nav item + `/applications`→`/media` redirect; `DatabaseBackup` icon confirmed available). See `tasks.md` Slices 3–8 for the verbatim unchecked list. + +## Slice 3 — Backups cluster + navigation/IA — COMPLETE + +All 8 Slice-3 task lines in `tasks.md` are now `- [x]`. The 5 Backups components +are MUI-free and the reconciled IA (`/backups` top-level nav item, Media at +`/media`, `/applications` → redirect) is live. Cumulative change task progress: +25 → **33/71** complete. + +### Status context consumed + +- `applyState` reported by the status engine: **blocked** (`blockedReasons`: + domain specs missing/partial; legacy flat `spec.md` present without domain + specs). Same planning-completeness gap as slices 1–2 — **not** a safety or + `actionContext` blocker. +- `actionContext`: `mode: repo-local`, `workspaceRoot: /home/user/Manage_01`, + `allowedEditRoots: ["/home/user/Manage_01"]`, `warnings: []` — safe. +- This run executed the explicitly delegated **Slice 3 (Backups cluster + + nav/IA)** scope per the parent acceptance contract. Slice-3 work is fully + specified in `tasks.md` (Slice 3) + `design.md` (§1 mapping table, §2.3 + status→Badge variant map, §4 IA) and does not depend on the missing domain + specs. `instructions.apply`: "Implement only unchecked tasks from the tasks + artifact." → proceeded under the parent's explicit slice delegation. +- `artifactStore: openspec`; persisted task checkboxes updated in `tasks.md` + (Slice 3: 0 → 8 `[x]`). + +### Completed tasks (persisted checkboxes updated) + +- [x] Migrated `BackupAlertsTable.tsx` → shadcn `Table` family on a bordered + rounded surface + `Badge` (`severityVariant`: `critical`→`destructive`, + `warning`→`warning`) + `Button variant="outline" size="sm"` acknowledge + (hidden when `acknowledged`). Preserve `formatTimestamp`, props, callback. +- [x] Migrated `BackupJobsTable.tsx` → shadcn `Table` + `Badge` + (`statusVariant`: `success`→`success`, `failure`→`destructive`, + `in_progress`→`warning`, unknown→`secondary`). Latest-run status, `formatInterval`, + last-run + next-expected timing preserved. +- [x] Migrated `BackupRunsTable.tsx` → shadcn `Select` (status filter, + `onValueChange`) + `Badge` (`success`/`destructive`/`warning`) + `Table`. + `formatDuration`/`formatBytes`/`formatTimestamp` + filter logic preserved. +- [x] Migrated `BackupDashboardWidget.tsx` → shadcn `Card`/`CardHeader`/ + `CardTitle`/`CardContent` + `Badge` (active_alerts>0 → `destructive`) + + `text-destructive` last-failed line. total_jobs / 24h success rate / active + alerts / last-failed-time + loading state preserved. +- [x] Migrated `BackupsPage.tsx` → shadcn `Tabs` (controlled `value`/ + `onValueChange`, string values `jobs|runs|alerts`) with `TabsContent` per tab; + jobs/runs/alerts behavior, latest-runs map, and the acknowledge mutation + preserved. In-page `

` kept to match the already-migrated `ObservabilityPage`. +- [x] Applied the §4 IA nav + route edits in `App.tsx`: imported `DatabaseBackup` + from lucide-react; nav `Media` retargeted `/applications` → `/media`; added + top-level `{ path: "/backups", label: "Backups", icon: DatabaseBackup }` (after + Files, before Users); in **both** route blocks `/media` is the canonical + `` element and `/applications` is `` (mirrors the existing `/monitoring` → `/observability` redirect). + `/backups` already rendered ``. +- [x] Added component tests: `BackupRunsTable` (status→Badge variant mapping + + formatted duration/size), `BackupAlertsTable` (severity→variant, acknowledge + callback fires with the id, acknowledged hides the button), + `BackupDashboardWidget` (loading state, stats render, active_alerts>0 → + destructive Badge + last-failed line). 8 new tests across 3 files. +- [x] **Exit gate green:** `/backups` reachable from the sidebar; `/applications` + redirects to `/media`; 5 Backups components MUI-free; `npm run build` + + `npm run lint` + `npm test` + `npm run test:node` all pass. + +### Files changed (tracked) + +Modified (6): + +- `frontend/src/components/BackupAlertsTable.tsx` +- `frontend/src/components/BackupJobsTable.tsx` +- `frontend/src/components/BackupRunsTable.tsx` +- `frontend/src/components/BackupDashboardWidget.tsx` +- `frontend/src/components/BackupsPage.tsx` +- `frontend/src/App.tsx` (icon import, `navItems`, both route blocks) + +Added (new, 3 test files): + +- `frontend/src/components/__tests__/BackupRunsTable.test.tsx` +- `frontend/src/components/__tests__/BackupAlertsTable.test.tsx` +- `frontend/src/components/__tests__/BackupDashboardWidget.test.tsx` + +Persisted artifact updated: + +- `openspec/changes/web-ui-rework/tasks.md` (Slice 3 checkboxes 0 → 8 `[x]`) + +Untouched (no-unintended-edits respected): **no `frontend/src/pages/*` file was +edited this slice** — `git status --porcelain frontend/src/pages` is empty. +Applications.tsx itself was **not** edited (slice 4 owns it); only its route + +nav entry changed. No `components/ui/*` primitive was modified. The pre-existing +`/monitoring` → `/observability` redirect is intact. + +### Commands run (validation) — all green + +- `grep -rlE '@mui/(material|icons-material)' <5 backups files>` → **ALL 5 + BACKUPS MUI-FREE**. +- `cd frontend && npm run build` → **PASS** (`tsc -b` + `vite build`; the + >500 kB chunk-size warning is pre-existing and unrelated). +- `cd frontend && npm run lint` → **PASS** (0 errors; the only 2 items are the + pre-existing `react-hooks/exhaustive-deps` **warnings** in + `UsersPage.impl.tsx`, out of Slice-3 scope). +- `cd frontend && npm test` → **PASS** (Vitest: **15 files, 30 tests**; +3 files + and +8 tests vs slice-2 baseline of 12 files / 22 tests + the badge smoke). +- `cd frontend && npm run test:node` → **PASS** (legacy node:test: 4 tests, + 0 fail). +- IA verification (`grep` of `App.tsx`): nav has `/media` Media + `/backups` + Backups; both route blocks have `/media` canonical + `/applications` Navigate + redirect + `/backups` BackupsPage; `/monitoring`→`/observability` intact. + +### Design decisions / deviations + +1. **Backups nav icon: `DatabaseBackup`.** Verified at the pinned + `lucide-react@^1.14.0` (slice-1 confirmation re-checked this run via + `node -e "…require('lucide-react').DatabaseBackup"` → object). Semantic fit, + no fallback needed. Placed after Files, before Users per design §4.1. +2. **`/applications` is a replace-redirect to `/media`** in both route blocks + (OIDC-configured branch + unauthenticated branch), mirroring the existing + `/monitoring` → `/observability` redirect. `/media` is the canonical + `` route. React Router v6 ranks routes by specificity, so + order is cosmetic; rendered `/media` before the `/applications` redirect to + match design §4.2's example snippet. The `Applications.tsx` component file + is **not** renamed (out of scope — design §4 non-goal). +3. **`BackupsPage` keeps an in-page `

`** ("Backups") to match the + already-migrated `ObservabilityPage` (`

`). The shell's `
` already provides `p-4 md:p-6`, so + the old `Box sx={{ p: 3 }}` double-padding was dropped per design §2.2. +4. **Status / severity → Badge variant** uses the `chart-N` cue map per design + §2.3 (success=chart-2/success, failure/critical=chart-4/destructive, + in_progress=chart-3/warning, unknown=secondary). Verified via the + `data-variant` attribute assertions in the new tests (mirrors the slice-2 + `SessionActivityPanel` test pattern). +5. **`BackupRunsTable` status filter** uses the shadcn `Select` (`onValueChange` + with string values) instead of MUI `Select`/`FormControl`/`InputLabel`/ + `MenuItem`; an `aria-label="Status filter"` is on the trigger for a11y. The + `all|success|failure|in_progress` filter set is unchanged. +6. **`latestRuns` map left un-memoized** in `BackupsPage` (verbatim from the + pre-migration source) to preserve behavior exactly and avoid an extra + `useMemo`/exhaustive-deps surface. + +### Slice boundary / PR + +- Slice 3 is forecast "likely OK" at ≤400 lines as a single PR (tasks §per-slice + table). Actual review churn: ~6 modified components/App (~250 ins / ~270 del) + - 3 new test files (~150 lines) ≈ **~400 changed lines**, right at the budget. + The parent delegated the whole slice as one unit and owns the commit/PR; if + the parent prefers, the nav/route edit in `App.tsx` is a clean split point. + All gates are green. + +### Remaining tasks (Slices 4–8, 38 unchecked) + +Slice 3 is complete (33/71 tasks). Next in dependency order: **Slice 4 — +Dashboard + Applications surface** (depends on slices 2 + 3; reuses +`BackupDashboardWidget` from this slice and the reconciled `/media` route; the +`Applications.tsx` component itself is migrated here). The first unchecked items: + +- [ ] Migrate `frontend/src/pages/Applications.tsx` … +- [ ] Migrate `frontend/src/pages/Dashboard.tsx` … +- … (see `tasks.md` Slices 4–8 for the verbatim unchecked list) + +### Top risk for slice 4 + +- **`Dashboard.tsx` is the heaviest single page** (20 distinct MUI components: + Dialog/FormControl/FormControlLabel/Grid/Select/Switch/TextField/Stack/Grid…) + and composes the slice-3 `BackupDashboardWidget` plus `NowPlaying`. It is + forecast "medium" (~300–450 lines) and may need a 4a (Applications, smaller) + → 4b (Dashboard) sub-split on overrun. The reconciled `/media` route + the + shortcut deep-links must be re-pointed to `/media` (any Dashboard shortcut + still linking `/applications` will rely on the new redirect until re-pointed). + Overall change `applyState` remains **blocked** on missing domain specs (legacy + flat `spec.md`); does not block Slice 3 (done) but should be resolved before + `sdd-verify`/archive. diff --git a/openspec/changes/web-ui-rework/tasks.md b/openspec/changes/web-ui-rework/tasks.md index 441e020..8d30c9c 100644 --- a/openspec/changes/web-ui-rework/tasks.md +++ b/openspec/changes/web-ui-rework/tasks.md @@ -146,14 +146,14 @@ Each slice section restates this gate as its final task. > This is where Backups becomes a top-level nav item and the Media/Applications route > is reconciled. -- [ ] Migrate `frontend/src/components/BackupAlertsTable.tsx` (Chip/Paper/Table family/FormControl/InputLabel/MenuItem/Select/Button → `Badge` (status cues), bordered surface, shadcn `Table` family, shadcn `Select`; acknowledge button preserved; severity → Badge variant). -- [ ] Migrate `frontend/src/components/BackupJobsTable.tsx` (Chip/Paper/Table family → `Badge` + shadcn `Table`; latest-run status + next-expected timing preserved). -- [ ] Migrate `frontend/src/components/BackupRunsTable.tsx` (Chip/FormControl/InputLabel/MenuItem/Paper/Select/Table family → shadcn `Select` + `Badge` + `Table`; status filter + formatted duration/size/timestamp preserved). -- [ ] Migrate `frontend/src/components/BackupDashboardWidget.tsx` (Box/Card/CardContent/Chip/Typography → shadcn `Card` + `Badge`; total jobs / 24h success rate / active alerts / last-failed-time preserved). -- [ ] Migrate `frontend/src/components/BackupsPage.tsx` (Box/Tab/Tabs/Typography → shadcn `Tabs`; tabs Jobs/Runs/Alerts behavior + acknowledge mutation preserved). -- [ ] Apply the IA nav + route edits in `frontend/src/App.tsx` per design §4: import a Backups icon (`DatabaseBackup`, or the slice-1-chosen fallback) from lucide-react; add a top-level `{ path: "/backups", label: "Backups", icon: … }` nav item (after Files, before Users); retarget the Media nav item from `/applications` to `/media`; in **both** route blocks add `} />` and convert `` to `} />`, mirroring the existing `/monitoring` → `/observability` redirect. -- [ ] Add/extend component tests for the migrated Backups tables (status Badge variant mapping; alert acknowledge callback). -- [ ] **Exit gate:** `/backups` reachable from the sidebar; `/applications` redirects to `/media`; Backups cluster MUI-free; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green. +- [x] Migrate `frontend/src/components/BackupAlertsTable.tsx` (Chip/Paper/Table family/FormControl/InputLabel/MenuItem/Select/Button → `Badge` (status cues), bordered surface, shadcn `Table` family, shadcn `Select`; acknowledge button preserved; severity → Badge variant). +- [x] Migrate `frontend/src/components/BackupJobsTable.tsx` (Chip/Paper/Table family → `Badge` + shadcn `Table`; latest-run status + next-expected timing preserved). +- [x] Migrate `frontend/src/components/BackupRunsTable.tsx` (Chip/FormControl/InputLabel/MenuItem/Paper/Select/Table family → shadcn `Select` + `Badge` + `Table`; status filter + formatted duration/size/timestamp preserved). +- [x] Migrate `frontend/src/components/BackupDashboardWidget.tsx` (Box/Card/CardContent/Chip/Typography → shadcn `Card` + `Badge`; total jobs / 24h success rate / active alerts / last-failed-time preserved). +- [x] Migrate `frontend/src/components/BackupsPage.tsx` (Box/Tab/Tabs/Typography → shadcn `Tabs`; tabs Jobs/Runs/Alerts behavior + acknowledge mutation preserved). +- [x] Apply the IA nav + route edits in `frontend/src/App.tsx` per design §4: import a Backups icon (`DatabaseBackup`, or the slice-1-chosen fallback) from lucide-react; add a top-level `{ path: "/backups", label: "Backups", icon: … }` nav item (after Files, before Users); retarget the Media nav item from `/applications` to `/media`; in **both** route blocks add `} />` and convert `` to `} />`, mirroring the existing `/monitoring` → `/observability` redirect. +- [x] Add/extend component tests for the migrated Backups tables (status Badge variant mapping; alert acknowledge callback). +- [x] **Exit gate:** `/backups` reachable from the sidebar; `/applications` redirects to `/media`; Backups cluster MUI-free; `npm run build` + `npm run lint` + `npm test` + `node --test tests` green. ---