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).
13 KiB
Review — Slice 4: services-as-hub-ia (frontend shell)
Scope: unstaged frontend changes — top-nav generation, service-page tab skeleton + instance switcher, ServiceTypePage resolver, empty-state CTAs, dashboards API/hook, stubs.
Base: main (NOT mobile-responsive-parity); absence of SheetForm/useIsMobile is expected and not flagged.
Verdict: fix-then-commit
One blocker (secret-editing behavior loss) must be fixed before commit. One confirmed issue (missing legacy-404 test promised by the slice) should be added. Everything else is sound.
Verification results (commands run)
| Command | Result |
|---|---|
cd frontend && npm run lint |
PASS — 0 errors (2 pre-existing warnings in UsersPage.impl.tsx, deleted in slice 8) |
cd frontend && npm run build |
PASS — built in 1.19s (tsc + vite) |
cd frontend && npm run test |
PASS — 25 files / 83 tests |
git diff --cached --stat |
empty — no staged files |
Blocker
B1 — Secret editing is broken (behavior loss) — frontend/src/pages/ServicePage.tsx
The Config-body lift orphaned the secret-draft state. The old ServiceConnectionCard saved secrets by filtering its local draftSecrets to non-empty values and sending them on its own "Update connection" button. The new ConfigBody still owns draftSecrets (line ~const [draftSecrets, setDraftSecrets] = useState<...>({})), but the merged Save button calls the parent's onSave → save() → buildInput(), which hard-codes secrets: {}:
function buildInput(): ServiceInstanceInput {
return {
id: instance!.id,
service_type: instance!.service_type,
name,
config: draftConfig,
secrets: {}, // <-- typed secret values are never collected
enabled,
};
}
So typing a value into any secret field and clicking Save sends an empty secrets object — the secret is discarded. This violates R2.3 ("Config tabs unchanged … secrets editors") and R10.1 ("ServicePage config/secrets editing continue to work"), and directly contradicts review verification point #2 ("preserve … config/secrets editing verbatim, no behavior loss").
Fix: lift draftSecrets to the parent (alongside name/enabled/draftConfig), or have ConfigBody expose its draft secrets to the save path. Cleanest: move draftSecrets into ServicePage state and build secrets in buildInput():
const onlyChanged = Object.fromEntries(
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
);
// ... secrets: onlyChanged ...
and reset draftSecrets after a successful save. Add a ServicePage test that types a secret and asserts the mutate payload includes it (the current test never exercises secret save).
Confirmed issues (should-fix before commit)
C1 — Missing legacy-route 404 test (slice deliverable gap)
Slice 4.5 / AC5 / R4.7 explicitly call for "404-on-legacy-routes tests." The implementation is correct — all legacy routes (/media, /files, /actions, /users, /observability, /backups, /monitoring, /applications) were removed and <Route path="*" element={<NotFoundPage />} /> catches them (App.tsx). But there is no test asserting any of these resolve to the NotFound catch-all. No App.test.tsx exists; grep for notfound/404/legacy across *.test.* finds nothing relevant.
Fix: add a small App-level (or router-level) test rendering <AppInner> (or the route subtree) with MemoryRouter initialEntries=["/media"] etc. and asserting the "Not found" text renders for each legacy path. The behavior is right; only the test is missing.
Suggestions (non-blocking)
S1 — Instance switcher trigger counts all siblings, not enabled-only (R3.1)
frontend/src/pages/ServicePage.tsx:
const siblings = services.filter((s) => s.service_type === serviceType);
const showSwitcher = siblings.length > 1;
R3.1 specifies the switcher appears when "more than one enabled instance" exists. Today two instances where one is disabled still show the switcher, and the dropdown lists disabled instances too. Minor edge case (the common path — two enabled — works and is tested). Suggest services.filter((s) => s.service_type === serviceType && s.enabled) for the trigger condition. Whether to also navigate to disabled instances in the dropdown is a product call, but the trigger should key off enabled count per spec.
S2 — No nav loading skeleton (design deviation, graceful but not as specified)
Design §"Top nav generation" / risk list: "Show a skeleton nav until settled; do not block the route render." useNavItems defaults both queries to [] while loading, so during load the nav renders only the core entries (Dashboard / Services / Settings) and conditional + dashboard entries pop in once data arrives. This is graceful (no crash, core always visible) but is not a skeleton and allows a nav "flash." Acceptable for the shell slice; consider an isLoading-gated skeleton later. R1.4 is satisfied in spirit.
S3 — /d/:slug route is absent (staging, not a defect)
useNavItems emits /d/:slug entries for named dashboards, but App.tsx has no /d/:slug route, so clicking one would currently hit the catch-all NotFound. This is fine for slice 4 because no named dashboards exist yet (Main Dashboard lives at /; named-dashboard CRUD/landing is slice 10), so the entries are empty in practice. Flagging only so the parent knows slice 10 must add the route — not a slice-4 blocker.
S4 — Composed nav order is unit-tested only partially
navEntries.test.ts thoroughly covers configuredNavEntries (filtering, ssh_tasks double-entry, nextcloud-none, declaration order). The composed useNavItems order (Dashboard first, then dashboards, then service entries, then Services, then Settings) is not asserted by a test. Behavior is correct by inspection; a tiny composed-order assertion would lock AC1. Optional.
Confirmed correct (with evidence)
- Nav order (R1.1/AC1):
useNavItems(App.tsx) returns[Dashboard, ...dashboardEntries, ...serviceEntries, Services, Settings]. ✓ - Conditional filtering (R1.2):
configuredTypesis built fromservices.filter((s) => s.enabled);configuredNavEntriesfilters the static map. ssh_tasks correctly contributes Files+Actions (two entries); nextcloud has no entries in the static map (asserted by test). ✓ - Tab skeleton (R2.1/R2.4):
serviceContentTabs(service-tabs/index.ts) switch returns exactly: jellyfin→Media+Requests, ssh_tasks→Files+Actions, backups→Jobs, authentik→Users+Messaging, alertmanager→Alerts, grafana→Links, prometheus→Metrics, default(nextcloud)→[]. ServicePage renders[Overview, ...content, Widgets, Config]. ✓ - Stubs are stubs:
service-tabs/stubs.tsx— every tab is a "coming soon"<Alert>; no half-implemented content. ✓ - Widgets tab preserved: widget-list rendering lifted verbatim into
widgetsContent(kind/name/description/badge + "add from dashboard edit dialog"). ✓ - Instance switcher (R3): renders a Radix
Selectonly whensiblings.length > 1; absent for single instance; selecting navigates to/services/:type/:id. Tested (show/hide). ✓ (modulo S1 enabled-count nuance) - Routing (R4): legacy routes removed;
*catch-all →NotFoundPage;/services/:serviceType→ServiceTypePage(resolves first-enabled →<Navigate>redirect, empty-state if none);/services/:serviceType/:serviceId→ServicePage;/,/settings,/servicesunchanged. Two route blocks (desktop + mobile drawer) kept in sync. ✓ - Empty state (R9): Dashboard renders "Welcome to Manage / Add a service" CTA when
services.length === 0(Dashboard.tsx);Dashboard.test.tsxmocks the newuseServiceInstances. ServicesPage strong empty state already pre-exists (ServicesPage.tsx:298). ✓ - Rules of Hooks:
useNavItems,ServicePage,ServiceTypePageall call hooks unconditionally at top level — no conditional hooks.useServiceInstances/useDashboardsaccept optional/undefined args cleanly. ✓ - Diff size ~530 lines: structural, not scope creep. Bulk is
ServicePage.tsx(260 changed — ConfigBody lift + tab skeleton + switcher) and the newservice-tabs/+navEntries+dashboardsAPI/hook, all in scope for slice 4.useDashboards/api/dashboards.tsbelong here because the design wiresuseDashboards()into nav generation. No real content migrated. ✓ ./sharedimport inapi/dashboards.ts: resolves to the existingapi/shared.ts(get/post/put/del with auth headers). ✓- Test quality:
navEntries.test.tsasserts real filtering/order behavior;ServicePage.test.tsxasserts per-type tab presence (jellyfin vs ssh_tasks) and switcher conditional. Good — aside from the missing legacy-404 and secret-save cases above. ✓
acceptance-report
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "partial",
"evidence": "Scope is bounded to slice 4 (nav generation, service-page skeleton, stubs, resolver, empty states, dashboards API/hook). No content migration leaked. However one in-scope behavior (secret editing, R2.3/R10.1) regressed and must be fixed; one promised test (legacy-404) is missing."
},
{
"id": "criterion-2",
"status": "satisfied",
"evidence": "Cited file:line evidence for each finding; ran lint/build/test; verified git staging state."
}
],
"changedFiles": [
"frontend/src/App.tsx",
"frontend/src/pages/Dashboard.tsx",
"frontend/src/pages/ServicePage.tsx",
"frontend/src/pages/__tests__/Dashboard.test.tsx",
"frontend/src/integrations/navEntries.ts",
"frontend/src/integrations/__tests__/navEntries.test.ts",
"frontend/src/api/dashboards.ts",
"frontend/src/hooks/useDashboards.ts",
"frontend/src/pages/service-tabs/stubs.tsx",
"frontend/src/pages/service-tabs/index.ts",
"frontend/src/pages/ServiceTypePage.tsx",
"frontend/src/pages/__tests__/ServicePage.test.tsx"
],
"testsAddedOrUpdated": [
"frontend/src/integrations/__tests__/navEntries.test.ts",
"frontend/src/pages/__tests__/ServicePage.test.tsx",
"frontend/src/pages/__tests__/Dashboard.test.tsx"
],
"commandsRun": [
{
"command": "cd frontend && npm run lint",
"result": "passed",
"summary": "0 errors; 2 pre-existing warnings in UsersPage.impl.tsx (deleted in slice 8)"
},
{
"command": "cd frontend && npm run build",
"result": "passed",
"summary": "tsc + vite build succeeded in 1.19s"
},
{
"command": "cd frontend && npm run test",
"result": "passed",
"summary": "25 files / 83 tests passed"
},
{
"command": "git diff --cached --stat",
"result": "passed",
"summary": "empty — no staged files"
}
],
"validationOutput": [
"lint: 0 errors",
"build: success",
"test: 83/83 passed",
"no staged files"
],
"residualRisks": [
"B1 (blocker): secret editing sends secrets:{} — fix before commit",
"C1: no legacy-route 404 test though behavior is implemented",
"S1: switcher trigger keys off total siblings not enabled-only (R3.1 nuance)",
"S3: /d/:slug route absent — fine now (no named dashboards exist), must land in slice 10"
],
"noStagedFiles": true,
"diffSummary": "~530 lines: App.tsx data-driven nav (useNavItems from services+dashboards) + legacy-route removal + NotFound catch-all; ServicePage refactored to tab skeleton [Overview,...content,Widgets,Config] with instance switcher and ConfigBody lift; new navEntries map/filter, service-tabs stubs, ServiceTypePage resolver, Dashboard empty-state CTA, dashboards API+hook. Structural overrun, not scope creep.",
"reviewFindings": [
"blocker: frontend/src/pages/ServicePage.tsx buildInput() returns secrets:{} — typed secret drafts in ConfigBody are never sent; secret editing regressed (R2.3/R10.1). Fix by lifting draftSecrets and sending onlyChanged.",
"confirmed-issue: no test asserts legacy routes (/media,/files,/actions,/users,/observability,/backups) hit the NotFound catch-all — slice 4.5/AC5 promised it; behavior implemented but untested.",
"suggestion: ServicePage.tsx switcher trigger counts all siblings, not enabled-only (R3.1).",
"suggestion: no nav loading skeleton (design called for one); partial-nav-during-load is graceful but flashes.",
"suggestion: /d/:slug route absent; acceptable staging, lands in slice 10."
],
"manualNotes": "Verdict: fix-then-commit. Fix B1 (secret save) and add C1 (legacy-404 test), then commit slice 4. S1–S4 are non-blocking follow-ups. Confirmed the base is main (no SheetForm/useIsMobile) per instructions; mobile reconciliation is deferred."
}