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).
9.0 KiB
Slice 2 Review — Dashboard mobile layout (mobile-responsive-parity)
Scope: unstaged diff on frontend/src/pages/Dashboard.tsx (+134/-7) and
frontend/src/pages/__tests__/Dashboard.test.tsx (+176/-7). Slice 1
(primitives: useIsMobile, mobile-touch-target CSS) is already committed.
Verdict: commit
No blockers. One non-blocking deviation from the task wording (JS-gated
md:hidden instead of the Tailwind class), which is functionally equivalent
and tested. All seven requested verification points pass.
1. Desktop non-regression (R7.4 / R10.1) — ✅ CONFIRMED, most important check
Dashboard.tsx:541-547 — the desktop branch is literally the original code:
{isMobile && mobileSections.length > 0 ? (
<MobileWidgetSections sections={mobileSections} />
) : (
visibleWidgets.map((widget) => (
<WidgetInstanceCard key={widget.id} widget={widget} />
))
)}
When isMobile === false, the renderer emits the exact same
visibleWidgets.map(...) → WidgetInstanceCard sequence, with the same
visibleWidgets memo (filter(enabled).sort(sort_order asc), unchanged at
Dashboard.tsx:456-461). No wrapper element is introduced on desktop, sort
order is identical, and no new query runs on the desktop path beyond the
cache-shared useServiceInstances() (see §6). The only desktop-visible
addition is the useIsMobile() hook and the mobileSections memo, both of
which are pure and render nothing extra when isMobile is false.
Test evidence: Dashboard.test.tsx "does NOT render the anchor bar at desktop
width" asserts the widget still renders (getByText("Grafana Link")) AND no
section heading/pill appears (queryByText("Observability") is null).
2. Section grouping logic (widgetSection / groupWidgetsBySection) — ✅ CORRECT
Dashboard.tsx:62-78:
function widgetSection(widget, services): SectionId {
if (!widget.service_id) {
return widget.widget_kind === "backups" ? "backups" : "custom";
}
const service = services.find((s) => s.id === widget.service_id);
const serviceType = service?.service_type ?? "";
if (OBSERVABILITY_TYPES.has(serviceType)) return "observability"; // alertmanager/prometheus/grafana
if (serviceType === "jellyfin") return "media";
return "custom";
}
Mapping verified against the closed service registry
(backend/.../integrations/registry.py: alertmanager, grafana, jellyfin,
jellyseerr, nextcloud, prometheus, ssh_tasks) and builtin widget kinds
(widgets/builtin.py: static, backups):
| Widget | Result |
|---|---|
builtin backups (no service_id) |
backups ✓ |
builtin static (no service_id) |
custom ✓ |
grafana link, prometheus metric, alertmanager alerts |
observability ✓ |
jellyfin activity |
media ✓ |
ssh_tasks task_output |
custom ✓ |
| nextcloud / jellyseerr / unknown service_type | custom ✓ |
orphan widget (service_id points at deleted service → service undefined, serviceType "") |
custom (safe fallback) ✓ |
No widget kind falls through wrong. The closed-over SECTION_ORDER
(observability, media, backups, custom) guarantees deterministic section
render order independent of widget arrival order.
3. Anchor bar — ✅ CORRECT (one wording deviation, non-blocking)
- Horizontal scroll:
-mx-1 flex gap-2 overflow-x-auto px-1 pb-1✓ scrollIntoView({ behavior: "smooth", block: "start" })on click ✓ (Dashboard.tsx:107-113)scroll-mt-16on each<section>(Dashboard.tsx:124) so the sticky TopBar (64px ≈mt-16) does not cover the heading ✓md:hidden: implemented via JS gating (isMobile && mobileSections.length > 0), NOT via a Tailwindmd:hiddenclass. Task 2.2 literally says "Anchor barmd:hidden". Functionally equivalent — at md+useIsMobile()returns false soMobileWidgetSectionsis never mounted, which is cleaner than rendering hidden DOM. Tested at both breakpoints. Non-blocking note only.
4. Empty sections — ✅ CONFIRMED
groupWidgetsBySection filters with s.widgets.length > 0
(Dashboard.tsx:94). The same filtered sections array feeds BOTH the anchor
bar pill list and the section list inside MobileWidgetSections, so an empty
section appears in neither. Test evidence: with observability/media/backups
widgets present and no custom widget, queryByText("Custom") is null
(Dashboard.test.tsx "renders widgets in a single column…").
5. Test quality — ✅ GOOD
Three new tests, all asserting behavior (not snapshots):
- "renders widgets in a single column with an anchor bar below md" — checks
each populated section label is present, the empty
Customsection is absent, and every widget title renders. - "does NOT render the anchor bar at desktop width" — asserts widget renders AND no section heading appears (anchor-bar-absent + widgets-present). ✓
- "anchor bar pills jump to their section via scrollIntoView" — spies on
Element.prototype.scrollIntoView, clicks the Media pill viagetByRole("button", { name: "Media" }), asserts the spy fired. ✓
matchMedia mock (Dashboard.test.tsx:79-92) is correct and complete: it
returns { matches, media, onchange, addEventListener, removeEventListener, addListener, removeListener, dispatchEvent }. matches is keyed on the exact
query string "(max-width: 768px)" that useIsMobile uses, so the boolean
flips correctly. useIsMobile only needs addEventListener/removeEventListener
- the initial
matchesread, all of which are stubbed. The mock is reset inbeforeEachviasetMatchMedia(false).
Minor note: the widget-stub was upgraded to render widget.title
(Dashboard.test.tsx:6-9) so tests can distinguish widgets — good improvement,
doesn't affect the existing shortcut-CRUD tests.
6. useServiceInstances() addition — ✅ CACHE-SHARED, no duplicate request
useServiceInstances(serviceType?) builds queryKey
["services", "instances", serviceType ?? "all"] (useServices.ts:21). The
Dashboard calls it with no arg → key ["services", "instances", "all"].
Critically, WidgetInstanceCard already calls useServiceInstances() with
no arg (WidgetInstance.tsx:12) for every rendered widget, as does
WidgetConfigDialog (WidgetConfigDialog.tsx:167). So the Dashboard's new
call hits the exact same TanStack cache entry that is already being subscribed
to by the widget cards it renders. TanStack Query deduplicates by key → zero
additional network requests introduced by this change on either desktop or
mobile. The 60s refetchInterval is shared.
7. Sort order within sections (R7.3) — ✅ PRESERVED
visibleWidgets is sorted by sort_order ascending (Dashboard.tsx:456-461,
unchanged). groupWidgetsBySection iterates visibleWidgets in order and
.push()es into per-section arrays, preserving insertion order. Therefore
within each section the user's configured sort order is intact, and sections
themselves render in fixed SECTION_ORDER. R7.3 satisfied.
Build / lint / test evidence
| Command | Result |
|---|---|
npm run lint |
✅ 0 errors (2 pre-existing warnings in UsersPage.impl.tsx, unrelated) |
npm run build (tsc -b && vite build) |
✅ built, typecheck clean |
npm run test (vitest run) |
✅ 25 files / 89 tests passed |
vitest run Dashboard.test.tsx |
✅ 6 tests passed (3 original + 3 new) |
Other observations (non-blocking)
- The mobile single-column container is
grid grid-cols-1 gap-4(Dashboard.tsx:120). The pre-change desktop widgets were already a flat vertical stack (no grid wrapper), so mobile parity is effectively the same column plus grouping — consistent with R7.1. mobileSectionsis recomputed viauseMemokeyed on[visibleWidgets, services]; correct deps, no stale-closure risk.OBSERVABILITY_TYPES,SECTION_ORDER,SECTION_METAare module-level constants — no per-render allocation. Good.- Diff is +303/-7 across 2 files, well under the 400-line review budget and exactly the two files Slice 2 scoped.
Blockers
None.
Suggestions (non-blocking, do not gate commit)
- If strict adherence to the task wording "Anchor bar
md:hidden" is preferred, add themd:hiddenTailwind class to the anchor bar div and renderMobileWidgetSectionsunconditionally inside the mobile branch. The current JS-gated approach is equally correct and avoids mounting hidden nodes; leaving as-is is fine. - Consider an explicit test that an orphan widget (service_id set but not in
services) lands incustom— the code handles it but no test pins the fallback. Low value; optional.
Acceptance
All seven requested verification points pass. Desktop non-regression is
verified by code identity on the isMobile === false branch and by the
desktop test. No scope widening (only the two Slice-2 files changed, no
backend, no other pages). No staged files. Ready to commit.