Cleanup: delete dead top-level pages + update docs (Slice 11)

Delete the old top-level page files whose content was migrated into
service-page tabs in slices 5-9:
- pages/Media.tsx, Applications.tsx (-> MediaTab)
- pages/FileBrowser.tsx, FileBrowser.impl.tsx (-> FilesTab)
- pages/Actions.tsx (-> ActionsTab)
- pages/Users.tsx, UsersPage.impl.tsx (replaced by Authentik tabs)
- components/BackupsPage.tsx (-> JobsTab)
- components/ObservabilityPage.tsx (split into Alerts/Links/Metrics tabs)
- hooks/useUsers.ts (orphaned after Users page deletion)
- the corresponding page test files (Media, FileBrowser, Applications,
  Actions, UsersPage) that tested the deleted pages directly.

The service-tab components are the live implementations; ServicePage
renders them. No live code references the deleted files.

Docs: append an Information Architecture section to REQUIREMENTS.md
documenting the services-as-hub model (nav shape, service-page tabs,
service type registry, Users->Authentik, Observability split, legacy
route 404s, empty state). Add a CHANGELOG entry under [Unreleased].

92 frontend tests pass (was 112; -20 deleted page tests); 271 backend
tests pass; lint/build green.

Refs openspec/changes/services-as-hub-ia/ (tasks slice 11).
This commit is contained in:
Developer
2026-06-26 20:11:02 +00:00
parent ec57eff59a
commit a8dfbd5dc6
45 changed files with 3441 additions and 4711 deletions
+120
View File
@@ -0,0 +1,120 @@
# Slice 6 Review — ServicePage mobile form (mobile-responsive-parity)
**Reviewer:** fresh adversarial review
**Scope:** unstaged `frontend/src/pages/ServicePage.tsx` + new `frontend/src/pages/__tests__/ServicePage.test.tsx`
**Commands run:** `npm run lint` (0 errors, 2 pre-existing warnings in UsersPage.impl.tsx), `npm run build` (green), `npm run test` (110 passed; ServicePage suite 5/5).
## Correct (verified with evidence)
- **Desktop non-regression — token-identical.** Compared `git show HEAD:ServicePage.tsx` against the new desktop branch. The heading (`<h2>` + binding.description + Badge), the `SectionCard title="General"` (Name + Enabled + Save/Delete), `configFields` (=`<ServiceConnectionFields isMobile={false}>` → renders `<SectionCard title="Connection" description="…">{fields}</SectionCard>` with byte-identical field JSX), `widgetsCard` (identical conditional SectionCard), and `confirmDelete` (identical ConfirmDialog) all render the same tree. The refactor only extracted inline JSX into `configFields`/`widgetsCard`/`confirmDelete` consts and renamed `ServiceConnectionCard``ServiceConnectionFields`; desktop output is unchanged. ✓
- **Mobile SheetForm wiring.** `sheetOpen` init `true` (open-on-mount, ServicePage.tsx:79); title = `name || instance.name` (draft-aware, :166); `onSave={save}` (:167); `onCancel={() => setSheetOpen(false)}` (:168); `isPending={saveService.isPending}` disables Save in SheetForm footer. ✓
- **Connection fields render without SectionCard on mobile.** `ServiceConnectionFields` `isMobile` branch returns `<div className="flex flex-col gap-3">{fields}</div>` (no card) — the SheetForm is the container. Desktop branch still wraps in `SectionCard title="Connection"`. ✓
- **Save semantics preserved.** `buildInput()` (:111-121) returns `{ id, service_type, name, config: draftConfig, secrets: {}, enabled }`; `save()` calls `saveService.mutateAsync(buildInput())`. ✓
- **Secrets "leave blank to keep" preserved.** `handleUpdateConnection()` filters `draftSecrets` to non-blank only (`filter(([,v]) => v !== "")`); General Save still sends `secrets: {}`. Same dual-save model as desktop. ✓
- **Delete flow on both branches.** Mobile branch renders `{confirmDelete}` as a **sibling** of `<SheetForm>` (ServicePage.tsx:188), so the ConfirmDialog overlays correctly outside the sheet. Desktop unchanged. ✓
- **Rules of Hooks — clean.** In `ServicePage`: `useParams`, `useServiceInstances`, `useServiceTypes`, `useSaveServiceInstance`, `useDeleteServiceInstance`, both `useMemo`, all five `useState`, `useIsMobile`, and `useState(sheetOpen)` are all called unconditionally **before** the `!binding`/`!instance` early returns. In `ServiceConnectionFields`: `useSaveServiceInstance()` + `useState(draftSecrets)` at top, unconditionally. No conditional hooks. The earlier "useIsMobile inside a conditional" risk was correctly avoided. ✓
- **Test quality — solid.** Desktop test #2 asserts `queryByRole("dialog")` is null (no SheetForm at ≥768px). Mobile test #2 edits the name, clicks Save, and asserts `mutateAsync` called once with `input.name === "Renamed Grafana"` and `input.id === "svc-1"`. Mobile test #3 asserts the `base_url` config field is editable. All 5 pass. ✓
## Confirmed issues (must-fix before commit)
### Blocker-1 — R4.5 violation: Sheet does not close on successful save
**Location:** `frontend/src/pages/ServicePage.tsx:117-119` (`save()`) and `:165-170` (SheetForm onSave wiring).
`save()` is:
```ts
async function save() {
await saveService.mutateAsync(buildInput());
}
```
It never calls `setSheetOpen(false)`. Spec **R4.5** explicitly requires: *"The Sheet closes on successful save and on explicit cancel."* Cancel closes (onCancel → `setSheetOpen(false)`), but after a successful Save on mobile the sheet stays open. `useSaveServiceInstance` only invalidates queries; it does not close the sheet. This is a direct, testable deviation from the requirement that AC8/verify will flag.
**Fix:** close the sheet on successful resolve, e.g.
```ts
async function save() {
await saveService.mutateAsync(buildInput());
setSheetOpen(false);
}
```
(Then also address Blocker-2, since closing the sheet surfaces the empty-page problem.)
## Notes / risks (non-blocking but important)
### Risk-1 — "Cancel leaves empty page" is a REAL UX bug (not acceptable as-is)
The mobile branch (`ServicePage.tsx:161-191`) renders only `<SheetForm>` + `{confirmDelete}`. There is no list, no back button, no `useNavigate`. When the sheet closes — via Cancel today, or via Save once Blocker-1 is fixed — the user is stranded on a blank `<div className="flex flex-col gap-4">` with no way back except browser history. This is a genuine UX defect, not an acceptable artifact of the sheet pattern: this page is reached via `/services/:serviceType/:serviceId` (deep link / row tap from ServicesPage), so closing the editor must return the user somewhere.
**Recommendation:** on sheet close (both save-success and cancel), navigate back to the services list — e.g. add `const navigate = useNavigate();` and `onOpenChange={(o) => { setSheetOpen(o); if (!o) navigate("/services"); }}`, or render a fallback "Back to services" affordance when `!sheetOpen`. This should be resolved in this slice, not deferred, because Blocker-1's fix makes it user-visible.
### Risk-2 — R4.5 dirty-state outside-click confirm not implemented
R4.5 also says the sheet *"does not close on outside-click while the form is dirty (confirm prompt)."* `SheetForm` passes `onOpenChange` straight through to Radix `Sheet` with no dirty guard, and ServicePage wires `onOpenChange={setSheetOpen}` directly. This is likely a cross-slice concern owned by the Slice-1 `SheetForm` deliverable, but it is currently unmet for this form. Flag for the verify pass / Slice 1 retro.
### Suggestion-1 — Strengthen the mobile Save payload assertion
Mobile test #2 (`ServicePage.test.tsx`) only asserts `input.name` and `input.id`. To lock the save semantics claimed by the slice, also assert `input.config` (equals draftConfig), `input.enabled`, and `input.secrets === {}`. Cheap and prevents regressions.
### Suggestion-2 — `save()` async-onClick typing
`SheetForm.onSave` is typed `() => void` but receives an async function; the promise is fire-and-forget. `isPending` correctly gates the button so this is functionally fine, but worth a comment or a `.catch` if error toast UX is added later.
## Verdict
**fix-then-commit.**
The desktop non-regression, Rules-of-Hooks, secrets/delete semantics, and test scaffolding are all correct and verified. However, **Blocker-1** (sheet does not close on save) is a clear, spec-cited (R4.5) deviation, and **Risk-1** (empty page after close) is a real UX bug that becomes user-visible the moment Blocker-1 is fixed. Both should be addressed in this slice before commit. Risk-2 and the two suggestions are non-blocking follow-ups.
## Acceptance
```acceptance-report
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "partially-satisfied",
"evidence": "Slice 6 implements ServicePage mobile SheetForm without widening scope (only ServicePage.tsx + new test). Desktop output verified token-identical to HEAD; Rules-of-Hooks clean; secrets/delete semantics preserved; lint/build/test green. BUT R4.5 'sheet closes on successful save' is not implemented (save() never calls setSheetOpen(false)) and closing the sheet strands the user on an empty page — must-fix before commit."
}
],
"changedFiles": [
"frontend/src/pages/ServicePage.tsx"
],
"testsAddedOrUpdated": [
"frontend/src/pages/__tests__/ServicePage.test.tsx"
],
"commandsRun": [
{ "command": "cd frontend && npm run lint", "result": "passed", "summary": "0 errors; 2 pre-existing warnings in UsersPage.impl.tsx (unrelated)" },
{ "command": "cd frontend && npm run build", "result": "passed", "summary": "vite build green (chunk-size advisory only)" },
{ "command": "cd frontend && npm run test", "result": "passed", "summary": "110/110 tests pass; ServicePage suite 5/5" },
{ "command": "git show HEAD:frontend/src/pages/ServicePage.tsx", "result": "passed", "summary": "Used to verify desktop branch token-identical to pre-change page" }
],
"validationOutput": [
"Desktop non-regression: CONFIRMED token-identical (heading, General, Connection, Widgets, ConfirmDialog).",
"Mobile SheetForm wiring (open-on-mount, title=draft name, onSave=save, onCancel closes, isPending disables Save): CONFIRMED.",
"Connection fields render without SectionCard inside sheet on mobile: CONFIRMED.",
"buildInput() + save()→mutateAsync: CONFIRMED.",
"Secrets leave-blank-to-keep (onlyChanged filter; General secrets:{}): CONFIRMED.",
"ConfirmDialog rendered OUTSIDE SheetForm on mobile (sibling): CONFIRMED.",
"Rules of Hooks (all hooks unconditional, before early returns): CONFIRMED clean.",
"R4.5 'closes on successful save': NOT MET — save() does not call setSheetOpen(false).",
"Empty page after sheet close (cancel/save): real UX bug, no back navigation."
],
"residualRisks": [
"Blocker-1: Sheet does not close on successful save (R4.5 violation) — ServicePage.tsx:117-119.",
"Risk-1: Closing the sheet (cancel, or save once fixed) leaves an empty page with no path back to /services — ServicePage.tsx mobile branch.",
"Risk-2: R4.5 dirty-state outside-click confirm not implemented at ServicePage/SheetForm level (likely Slice-1 cross-cutting concern)."
],
"noStagedFiles": true,
"diffSummary": "Adds a mobile (isMobile) branch to ServicePage that renders the edit form inside a SheetForm (open-on-mount, draft-name title, onSave=save, onCancel=close) with Connection fields unwrapped and ConfirmDialog as a sibling; extracts desktop JSX into configFields/widgetsCard/confirmDelete consts and renames ServiceConnectionCard→ServiceConnectionFields (isMobile prop) so the desktop output stays token-identical. Adds 5 Vitest cases (2 desktop, 3 mobile).",
"reviewFindings": [
"blocker: ServicePage.tsx:117-119 — save() does not close the sheet on success; violates R4.5.",
"blocker: ServicePage.tsx:161-191 — mobile branch has no back navigation; closing the sheet strands the user on an empty page (becomes visible once blocker-1 is fixed).",
"note: R4.5 dirty-state outside-click confirm not implemented (SheetForm passes onOpenChange through).",
"suggestion: ServicePage.test.tsx mobile Save test should also assert config/enabled/secrets payload, not just name+id."
],
"manualNotes": "Verdict: fix-then-commit. Desktop non-regression, hooks, and core save/delete/secrets semantics are correct and verified. The two blockers are tightly coupled (fixing save-close surfaces the empty-page gap) and should be resolved together in this slice: close sheet on save AND navigate back to /services (or render a fallback) on close."
}
```