The requests table showed all names as "—" because Jellyseerr's /api/v1/request
list does NOT embed titles — they live on the Movie/Series records. Added
JellyseerrClient._resolve_title(media_type, tmdb_id) that fetches
/api/v1/movie/{tmdbId} (→ title) or /api/v1/tv/{tmdbId} (→ name), cached on
the client instance so subsequent polls are instant.
Also scoped the table fetch to open requests only (pending + approved) via
Jellyseerr's filter param, instead of fetching all 800+ historical requests.
open_requests() fetches pending+approved (paginated), resolves their titles
(small set → fast), and returns them sorted by date added desc.
Updated the frontend table's status filter to Open/Pending/Approved (the data
only contains open requests now).
Tests: title resolution end-to-end (movie tmdbId → title), caching across
polls, filter param used. 404/404 backend + 184/184 frontend + build green.
Replace the static "recent requests" list with a proper table of all Jellyseerr
requests, sorted by date added (newest first by default) with standard sorting
and filtering.
Backend:
- JellyseerrClient.requests(max_count=500): paginated GET /api/v1/request
(sort=added), mapped with type (movie/tv), status, media_status, and
created_at labels. Returns up to 500 so the table can sort/filter client-side.
- fetch_jellyseer_requests(service) reuses the per-service cached client
(shared with the stats widgets).
- new GET /api/jellyseerr/requests endpoint.
Frontend:
- JellyseerRequestsTable: TanStack Table (sorting via getSortedRowModel,
pagination via getPaginationRowModel) reusing the Table primitives +
TablePagination. Columns: Name / Type / Status / Media / Requested, all
sortable; default sort Requested desc. A search box filters by name and a
status dropdown defaults to "Open" (pending+approved+processing) with
All/Pending/Approved/Declined options. (The shared DataTable is deliberately
visibility-only, so this is a dedicated sortable table.)
- RequestsTab renders the stats grid + the new table (the compact recent list
stays on the Requests overview widget).
- useJellyseerRequests hook + fetchJellyseerRequests API client.
Tests: client requests() mapping + single-page stop; fetch helper not-configured;
RequestsTab test mocks both hooks. 404/404 backend + 184/184 frontend pass;
build (tsc -b && vite build) + ESLint clean.
The frontend production build (tsc -b) was failing, which blocked deployment:
- api/jellyseerr.ts exported `JellyseerrStatsResponse` (double-r) while every
import used `JellyseerStatsResponse` (single-r) — a mismatch TS reported as
"no exported member" (with a misleading identical-name suggestion). The
sibling types (JellyseerStat, JellyseerRecentRequest) are single-r, so
align the export to single-r. (tsc --noEmit missed it because the root
tsconfig is solution-style; tsc -b builds the app project and catches it.)
- RequestsTab.test.tsx's useJellyseerrStats mock returned a partial object
that didn't satisfy UseQueryResult's full shape; cast via a typed helper.
`npm run build` (tsc -b && vite build) now succeeds; 184/184 tests + ESLint clean.
Frontend for Jellyseerr request stats, reusing the generic stat abstraction.
- api/jellyseerr.ts + hooks/useJellyseer.ts: fetchJellyseerrStats +
useJellyseerrStats (polls /api/jellyseerr/stats, no-retry; shares the backend
cache with the widgets).
- Two reusable widgets backed by the stat/stats_overview kinds:
- RequestStatWidget: a single selected stat (big value + label).
- RequestsOverviewWidget: a MetricCard grid of all stats + a recent-requests
list with status/media-status badges.
- registry.ts: Jellyfin gains `stat` (a dropdown over
total/pending/approved/declined/processing/available — the "extract one stat
into a widget" affordance, rendered as a Select via the existing enum UI) and
`stats_overview` widget kinds, wired to the new components.
- RequestsTab rewritten: live stats grid (6 counts) + recent-requests list +
a hint to pin individual stats via the Request stat widget. Reads
jellyseerr_url from config and the now-secret jellyseerr_api_key from
secrets_set.
Tests: RequestStatWidget + RequestsOverviewWidget rendering/error; RequestsTab
not-configured CTA, configured stats grid, and error states. 184/184 frontend
tests pass; tsc + ESLint clean.
The app was saturating qBittorrent's single-threaded web server and causing
its own Web UI (and the reverse proxy) to hang/504: each of the 3 qBittorrent
widgets fetched /sync/maindata independently, every call was a FULL snapshot
(no rid), and polling was aggressive (5s for speed). For large torrent lists
each snapshot is heavy, so the server queued and Traefik timed out.
QbittorrentClient.maindata now:
- Uses the incremental rid protocol: the first call is a full_update;
subsequent calls send the last rid and get a small diff that is merged into
a cached snapshot (full_update replaces; partial_update merges server_state,
torrents {added/None-removed/..._removed}, categories, tags, trackers).
Payloads shrink dramatically for large libraries.
- Serves a short-TTL (3s) cached snapshot under a lock, so concurrent widget
polls collapse onto a single HTTP fetch instead of N.
- Backs off exponentially (capped 30s) on repeated failure, serving the last
good snapshot when available, so a struggling qBittorrent isn't hammered
further. Returns a shallow race-safe copy of the snapshot per call.
Also slow the speed widget poll from 5s -> 15s (backend widget-kind +
frontend registry) for ~3x fewer calls.
Tests: rid full+partial merge, cache collapses within-TTL calls, backoff
skips the network after failure and serves stale. 393/393 backend + 180/180
frontend tests pass; ruff + tsc + ESLint clean.
The config dialog reads each widget kind's schema from the static frontend
SERVICE_REGISTRY (registry.ts), not the backend pydantic schema. The previous
commit added unit/scale to the backend configs but not to the frontend mirror,
so the options never appeared in the dialog — the Prometheus "chart" binding
still listed only promql/window and the qBittorrent "speed" binding had an
empty configSchema.
Add a shared AXIS_FORMAT_PROPERTIES fragment (unit + scale enums) and spread
it into the prometheus chart and qbittorrent speed bindings, with matching
defaultConfig (chart: none/auto; speed: bytes_per_sec/auto). Combined with the
enum <Select> rendering already added to WidgetConfigDialog, the options now
show up as dropdowns when editing those widgets.
Test: registry exposes unit/scale enums on chart + speed; speed defaults to
bytes_per_sec. 180/180 frontend tests pass; tsc + ESLint clean.
Consistent graph scaling across every line-chart widget. A new shared
frontend/src/lib/metricFormat.ts picks a decimal prefix (kB/MB/GB, kbps/Mbps,
Gbps, …) from the series magnitude and formats values; LineSeriesChart accepts
unit + scale and formats both the Y-axis ticks and the tooltip with the SAME
prefix (one consistent unit per axis). MetricChartWidget (Prometheus) and
QbittorrentSpeedWidget pass the widget config through; qBit speed defaults to
bytes/sec → MB/s.
WidgetConfigDialog now renders `enum` schema fields as a <Select> dropdown, so
the backend's unit/scale Literal enums become consistent pickers in every graph
widget's config (and any future enum option).
Decimal (x1000) prefixes by default (matches Mbps/MB/s/Grafana).
Tests: 13 new metricFormat tests (auto/fixed scaling, percent, seconds,
nulls, trailing-zero trimming). 179/179 frontend tests pass; tsc + ESLint clean.
The gauge rendered as multiple black rings. Two causes:
1. recharts RadialBarChart draws each data entry as a CONCENTRIC RING, not an
arc segment, so the 3 "track band" entries + value produced 4 nested rings.
Render a single value arc over a neutral background track instead, colored
by status, with the readout absolutely centered (replacing the -mt-12 hack).
2. The fills used hsl(var(--primary)) / hsl(var(--chart-1)) etc., but this
project's Tailwind v4 theme (index.css) defines colors as --color-* holding
full hex values (--color-primary: #4f8cff). So the references were doubly
invalid (wrong name + hsl() wrapping a hex) -> invalid SVG fill defaults to
black. Use var(--color-*) directly, with the semantically correct chart
colors: ok=--color-chart-2 (green), warn=--color-chart-3 (amber),
crit=--color-chart-4 (red).
Also fix the same hsl(var(--x)) -> var(--color-x) bug in LineSeriesChart's
tooltip contentStyle (popover/border/popover-foreground). The line stroke
palette already used the correct var(--color-chart-N) form.
166/166 frontend tests pass; typecheck + ESLint clean.
Settings.tsx: ServiceConfigEditor derived editable state (name, config,
secrets) from the instance prop via useState, but the parent rendered it
without a key. Switching services in the rail reused the same component, so
name/config stayed pinned to the previously selected service while
instance.id/service_type (read live from props) pointed at the new one —
saving then wrote the stale values onto the wrong row (e.g. saving qBittorrent
renamed it "Jellyfin" with Jellyfin's URL). Add key={selectedService.id} so
the editor remounts and resets on switch.
ServicePage: add a Settings shortcut in the header that deep-links to
/settings?tab=services&service=<id>. Settings now reads tab + service query
params (useSearchParams) to open the Services tab with that service
pre-selected, via a new initialServiceId prop on ServicesAdminCard.
Tests: new Settings.services.test.tsx regression test (fails without the key,
passes with it); wrap existing Settings tests in MemoryRouter since Settings
now uses useSearchParams. 166/166 frontend tests pass; typecheck + ESLint clean.
WidgetConfigDialog crashed on edit with React error #185 (Maximum update
depth exceeded) when the references/instances query returned undefined and
the inline '= []' fallback created a new array ref every render, looping the
auto-edit useEffect. Stabilize via useMemo(data ?? []). Also moved the
referencedWidgetIds Set inside the availableWidgets useMemo (clears the
pre-existing exhaustive-deps warning).
Complex config fields (promql, query, text, command, notes, or opt-in via
format: 'textarea') now render as a taller resizable Textarea (rows=6,
min-h-120px, font-mono, resize) in both WidgetConfigFields and
ServiceConfigFields, instead of a single-line Input.
Build + lint clean (referencedWidgetIds warning gone), 165 vitest pass.
qBittorrent was missing from SERVICE_TYPE_NAV_ENTRIES, so configured qBit
instances never appeared in the left nav (unlike jellyfin/prometheus/etc.).
Add entry with Magnet icon. navEntries.test.ts filters by configured types
(no fixed-count assertion) so it stays green.
CreateServiceDialog.save() awaited mutateAsync without a try/catch, so a
backend 422 (e.g. base_url missing http:// schema) threw uncaught and the
dialog sat silent with no feedback. Wrap in try/catch, hold the error in
local state, render a destructive Alert above the footer. Reset/onClose
only on success; on error the user can fix and retry.
Add gauge widget (recharts RadialBarChart with configurable threshold
bands, scalar-only per SC-111) and mean widget (client-side average over
range-query window, scalar-only per SC-114). Extract shared _instant_query
helper from the metric path; _fetch_gauge and _fetch_mean dispatch in
PrometheusWidgetSource.fetch(). Both new widget kinds declared in
integrations/prometheus.py and frontend registry.
Backend: 305 pytest pass, ruff clean. Frontend: 136 vitest pass, build+lint green.
The build endpoint had Depends(get_jellyfin_client) and Depends(get_user_id)
which executed BEFORE the function body. If Jellyfin was unreachable, these
raised HTTPException(503), the function never ran, and the worker was never
started. The frontend mutation had no onError handler, so the failure was
completely silent — the button briefly showed 'Building...' then reverted
to 'Build index' with zero feedback.
Backend fix: removed the Jellyfin dependencies from post_build_index.
The worker subprocess resolves its own Jellyfin connection via
_resolve_jellyfin(service_id) — the endpoint just needs to start the
worker process. The libraries count starts at 0 and gets updated by
the worker once it connects.
Frontend fix: added onError to useBuildIndex that invalidates the status
query (so the UI reflects the non-building state). MediaTab now displays
the build error inline: 'Build failed: <message>' next to the button.
283 backend tests pass (updated build test for new no-dependency flow);
128 frontend tests pass; ruff/eslint clean.
The WidgetConfigDialog useEffect that auto-enters edit mode when
editWidgetId is set only searched owned widget instances (from
useWidgetInstances). Referenced widgets (from useWidgetReferences)
were never found, so startEdit never fired and the dialog fell through
to the list view.
Now the effect searches both owned instances and referenced widgets,
so clicking edit on any widget — owned or referenced — opens the edit
form directly.
128 tests pass; 0 lint errors; build clean.
Four fixes:
1. Mobile edit fullscreen scroll: the Sheet primitive's
data-[side=bottom]:h-auto was overriding our h-[100dvh] on
SheetForm, preventing scroll. Added data-[side=bottom]:h-[100dvh]
to the SheetForm className to win the specificity battle.
2. Direct-edit close showed list view: when opened via editWidgetId
(the hover edit button), saving or canceling called reset() which
showed the widget list instead of closing the dialog. Now derives
directEdit from editWidgetId — when true, reset() calls onClose()
to close entirely.
3. Copy button missing on mobile: MobileWidgetSections didn't pass
onCopy to its WidgetInstanceCard instances. Now accepts and wires
onCopyWidget, so referenced widgets show the copy/detach button on
mobile too.
4. Service enabled badge stale: ServiceConfigEditor showed
instance.enabled (the initial prop) instead of the local enabled
state. Now reads the local enabled variable so the badge updates
when the user toggles the switch.
128 tests pass; 0 lint errors; build clean.
Five fixes:
1. Dialog mobile scroll: DialogContent now has max-h-[calc(100dvh-2rem)]
overflow-y-auto so dialogs that don't fit on screen can scroll
instead of clipping their footer (and Cancel button) off-screen.
2. isDirty false positive: WidgetConfigDialog's SheetForm used
isDirty={draft !== null} which was true the moment you opened edit
mode, even with no changes. Now stores a draftBaseline at startEdit
time and compares JSON.stringify(draft) !== JSON.stringify(baseline).
The discard-confirmation only appears when something actually changed.
3. Mobile edit button always visible: the widget card's edit button was
opacity-0 group-hover:opacity-100 (hover-only). Changed to
md:opacity-0 md:group-hover:opacity-100 — always visible below md,
hover-reveal at md+.
4. Copy button for referenced widgets: WidgetInstanceCard gains an onCopy
prop. On the Dashboard, referenced widgets get a Copy icon button that
triggers detachRef (creates an independent clone). The edit button on
referenced widgets edits the original (shared config).
5. ConfirmDialog Cancel: fixed by #1 (the Cancel button was off-screen
on mobile dialogs that couldn't scroll).
128 tests pass; lint/build green.
WidgetConfigDialog fetched useWidgetInstances(serviceId) with no scope
filter. On the main dashboard (serviceId undefined, dashboardScope='main'),
this returned ALL widget instances including service-scoped ones from
service overviews — so editing the main dashboard showed widgets that
were never added there.
Fix: pass scope='dashboard' when serviceId is empty and dashboardScope
is set. This fetches only dashboard-scoped widgets (service_id IS NULL).
The 'Add existing' picker (allWidgets) stays unscoped so users can still
reference service widgets onto the dashboard.
128 tests pass; lint/build green.
Four fixes:
1. Settings > Services tab: master/detail layout. Replaced the vertical
stack of ServiceConfigEditor cards with a SelectionRailCard (list on
left) + SectionCard (details on right) — same pattern as Machines
and SSH Keys tabs. Click a service in the rail to edit it.
2. Service Overview widget restriction. When adding widgets on a
service's Overview, the dialog now only shows built-in widgets +
widgets for THAT service type (not all services). Dashboard/named
dashboards (no serviceId) still see all.
3. Reorder buttons fixed. The swap-sort_order approach was a no-op when
both items had sort_order=0 (the default). Now moveInstance
renumbers ALL items by their new index position (i * 10) after the
swap, guaranteeing values change. References use updateRef, owned
widgets use saveWidget, both sequential.
4. Media index worker resolution. The subprocess worker called
get_jellyfin_client/get_user_id (FastAPI request dependencies) which
don't work outside request context. Now accepts a service_id
parameter (passed from post_build_index) and resolves the Jellyfin
instance directly from the settings store via _resolve_jellyfin.
Raises RuntimeError (not HTTPException) on failure.
283 backend tests pass; 128 frontend tests pass; ruff/eslint clean.
Each widget card now shows a settings icon in the top-right corner on
hover (desktop) or always-visible (mobile via mobile-touch-target).
Clicking it opens the WidgetConfigDialog directly in edit mode for that
widget (via a new editWidgetId prop on WidgetConfigDialog that
auto-enters the draft-edit path via useEffect).
Wired across all three widget surfaces:
- Dashboard (both mobile section + desktop grid)
- Service OverviewTab
- NamedDashboardPage
128 tests pass; build/lint green.
Three reusable-widget follow-up fixes:
1. Reference sort_order independently reorderable. Reordering a
referenced widget now updates the widget_references.sort_order (per-
dashboard), not the shared widget instance sort_order. New backend
update_widget_reference method + PUT /api/widgets/references/{id}
endpoint. Frontend moveInstance checks _ref_id to choose the right
mutation (updateRef for references, saveWidget for owned).
2. Detach preserves service_id. detach_widget_reference now copies the
original widget's service_id into the clone, so service-bound widgets
(Grafana chart, Jellyfin activity) continue to render after detach.
3. Named dashboards support widget references. NamedDashboardPage
fetches useWidgetReferences('named:<slug>') and renders them via
WidgetInstanceCard alongside pinned links. 'Edit widgets' button
opens WidgetConfigDialog with dashboardScope='named:<slug>'.
Also: removed useMemo on combinedWidgets in WidgetConfigDialog to fix
a react-hooks/preserve-manual-memoization lint error (the React Compiler
ESLint plugin couldn't verify the spread+sort memoization).
283 backend tests pass (+1 update_reference test); 128 frontend tests
pass; ruff clean; 0 lint errors.
Widgets configured on one dashboard (e.g., a Grafana service's Overview)
can now be live-referenced on other dashboards. Editing the widget config
updates it everywhere it's referenced. References can be detached into
independent clones.
Backend: new widget_references table (dashboard_scope, widget_id,
sort_order) with ON DELETE CASCADE. CRUD methods + 4 endpoints:
GET/POST /api/widgets/references, DELETE /api/widgets/references/{id},
POST /api/widgets/references/{id}/detach (clones the widget into a
standalone instance, then removes the reference).
Frontend: WidgetConfigDialog gains a dashboardScope prop. When set
(the main Dashboard passes 'main'), the dialog shows:
- Owned + referenced widgets in a combined list, with a link badge on
references.
- 'Add existing widget' picker: searchable list of ALL widget instances
not already on this dashboard. Click to create a reference.
- Detach button on references: clones the widget (service_id=NULL) and
removes the reference.
- Delete on a reference removes the REFERENCE (not the original widget).
Dashboard renders referenced widgets alongside owned widgets.
Detaching a service-bound widget clones it with service_id=NULL — the
clone may need re-binding to a service to render correctly. Named
dashboards don't pass dashboardScope yet (pinned-links-only); when they
gain widget support, the backend already handles any scope string.
282 backend tests pass (+2 reference lifecycle); 127 frontend tests
pass; ruff/eslint/tsc/vite all green.
Two fixes for the Grafana chart widget:
1. Invisible lines: the CHART_COLORS used 'hsl(var(--chart-1))' but the
CSS variable is named '--color-chart-1' and already contains a hex
color (#4f8cff). The hsl() wrapper produced invalid CSS, making
every stroke invisible. Fixed to var(--color-chart-1).
2. Multiple series collision: the backend labeled all Prometheus series
with the value field name (often just 'Value'), so multiple time
series collided on the same recharts dataKey and overwrote each
other. Now extracts meaningful labels from the Grafana frame metadata:
prefers displayName, then Prometheus metric labels (e.g.
'instance=server1:9100 mode=iowait'), then falls back to the field
name. Duplicate labels get a numeric suffix for uniqueness.
Multi-series queries now render correctly: each Prometheus time series
gets its own colored line with a unique label in the legend/tooltip.
280 backend tests pass (+1 labels test); 127 frontend tests pass; ruff/
eslint clean.
The Grafana chart widget's 'query' field (PromQL) and any schema field
marked format:'textarea' now render as a resizable 4-row Textarea with
monospace font, instead of a single-line Input. Makes complex queries
much easier to read and edit.
The field-renderer heuristic: key === 'query' or schema format ===
'textarea' → Textarea; everything else stays an Input (numbers stay
number inputs). 127 tests pass; lint/build green.
Two bugs in the widget config dialog:
1. Service Overview edit showed main-dashboard widgets. The dialog
called useWidgetInstances() with no args, fetching ALL widgets. Now
accepts a serviceId prop; OverviewTab passes instance.id so the
dialog lists + creates only service-scoped widgets. New built-in
widgets added from a service Overview inherit the serviceId.
2. Reorder up/down buttons did nothing. moveInstance fired two
saveWidget mutations via Promise.all — the first mutation's
onSuccess cache invalidation triggered a refetch before the second
completed, reverting the swap. Changed to sequential awaits so
both sort_order writes land before the cache refreshes.
127 tests pass; lint/build green.
The iframe-based 'panel' widget didn't work: the browser couldn't
authenticate against the OIDC-protected Grafana (Authentik), and
iframes can't carry Bearer tokens or share cross-origin session
cookies. Result: blank iframe or login redirect.
Replace it with a 'chart' widget that queries Grafana's datasource
API server-side:
Backend (GrafanaWidgetSource): POSTs to /api/ds/query with the stored
api_key (which bypasses OIDC), using the widget's configured PromQL
query, datasource_uid, time range, and resolution. Normalizes Grafana's
frame-based response into a simple {series: [{label, points: [{t, v}]}]}
shape. The api_key is never exposed to the browser.
Frontend (GrafanaChartWidget): renders the series data as a recharts
LineChart with dark-mode-aware colors (Tailwind --chart-* tokens),
responsive container, custom tooltip, and per-series lines. Loading
skeleton, error Alert, and empty state. recharts ^3.9.2 added.
The 'link' widget kind (deep-link URL) is unchanged. The 'panel' kind
and GrafanaPanelWidget are fully removed.
Backend: 279 tests pass (+1 net: -2 panel + 3 chart). Frontend: 127
tests pass (net 0: -3 panel + 3 chart). Lint/build green both sides.
Two new additive widget kinds:
Jellyfin 'now_playing': like the existing 'activity' widget but filters
to only sessions with active playback (NowPlayingItem present + not
paused). Shows who's actually watching right now. The 'activity' kind
is unchanged (shows all sessions including idle).
Grafana 'panel': embeds a single Grafana panel directly in the app via
an iframe, using Grafana's /d-solo/ endpoint (renders one panel without
dashboard chrome, kiosk=tv). Configurable dashboard_uid, panel_id, and
time range (from/to, defaults now-1h/now). Includes a fallback 'Open in
Grafana' link for when embedding is blocked by X-Frame-Options/CSP. The
'link' kind is unchanged (still builds a deep-link URL).
Backend: new widget configs + definitions on jellyfin/grafana; source
adapter logic (session filter for now_playing; d-solo embed URL for
panel); 6 new tests.
Frontend: JellyfinNowPlayingWidget + GrafanaPanelWidget components;
registry bindings; 6 new tests.
278 backend tests pass (+6); 127 frontend tests pass (+6); lint/build
green both sides.
Four coupled changes to the services-as-hub IA:
1. Nav entries use service TYPE names (Jellyfin, SSH Tasks, Alertmanager,
Grafana, Prometheus, Backups, Authentik) instead of conceptual names
(Media, Files, Actions, Alerts, Users). ssh_tasks collapses to one
entry ('SSH Tasks') instead of two. The content tabs inside each
service page surface the concepts (Files, Actions).
2. Service page gains a two-level tab structure when multiple enabled
instances of the same type exist: instance tabs on top ([Main Jellyfin]
[Backup Jellyfin]), content tabs below ([Overview] [Media] [Requests]
[Widgets]). Clicking an instance tab navigates to the sibling's route.
Single instance: no instance tabs. Replaces the dropdown switcher.
3. Config tab (connection fields, secrets, enable/disable, delete) moves
from the service page to Settings > Services tab. The service page
becomes a PURE operational view (Overview + content tabs + Widgets) --
no save/delete/config state. Settings gains a 4th tab 'Services' with
ServiceConfigEditor per instance (schema-driven config fields, secrets
with leave-blank-to-keep semantics, ConfirmDialog on delete).
4. Overview tab is now a configurable widget grid per service instance.
Each instance manages its own set of widgets on its Overview. Backend
widget list endpoints gain ?service_id= and ?scope= (dashboard|service)
filter params; the main Dashboard uses scope=dashboard to exclude
service-scoped widgets. The OverviewTab reuses WidgetInstanceCard +
WidgetConfigDialog. Empty state CTA for instances with no widgets.
All service-tab stubs are replaced; stubs.tsx deleted.
272 backend tests pass (+1 widget filter); 121 frontend tests pass (+3
instance-tabs + OverviewTab); lint/build green both sides.
The shared Tabs primitive used data-horizontal:* / data-vertical:* Tailwind
variants, but the component sets data-orientation='horizontal' (not
data-horizontal). Tailwind v4 data-* variants match attribute names, so
data-horizontal:flex-col on the Tabs root never applied -- the TabsList
and TabsContent laid out side-by-side instead of stacking.
Other consumers (TabbedCard, Settings) wrap their tab children in <div>s,
so the broken flex direction was masked. ServicePage puts TabsList and
TabsContent as direct children of <Tabs>, exposing the bug.
Fix: switch every dead variant to data-[orientation=horizontal]:* /
data-[orientation=vertical]:* (the root flex-col, the list h-8/h-fit/
flex-col, the trigger w-full/justify-start, and the active-indicator
after-element positioning). The full orientation system now works as
intended for both horizontal and vertical tabs.
117 tests pass; lint/build green.
The reconciliation with mobile-responsive-parity applied the SheetForm
wrapper (designed when ServicePage was config-only) to the ENTIRE
service page, including content tabs. Clicking a nav item like 'Media'
on mobile opened a form sheet with Save/Cancel instead of the tabbed
content browser.
Fix: ServicePage now renders the Tabs skeleton on ALL breakpoints.
Content tabs (Media, Files, Actions, etc.) are operational views, not
forms -- they have their own mobile handling (MobileCardRow, etc.) and
should not be wrapped in a Save/Cancel sheet. The Config tab renders
inline like every other tab.
Removes the isMobile branch + SheetForm wrapper + dead imports
(useIsMobile, SheetForm) + sheetOpen state. 117 tests pass; lint/build
green.
Pull the duplicated pagination footer into a single shared component at
frontend/src/components/ui/table-pagination.tsx. Both the desktop
DataTable (which had an internal DataTablePagination driven by a TanStack
table instance) and the Media mobile card list (which had a standalone
MediaMobilePagination driven by raw PaginationState) now consume it.
The shared component takes the raw primitives (pageIndex, pageSize,
pageCount, totalRows, pageSizeOptions, onPaginationChange, optional
className) so it backs both an adapter view (DataTable extracts state
from its table instance and passes table.setPagination) and a direct
state view (Media passes its pagination state directly). Includes the
44px mobile-touch-target on prev/next buttons (previously only on the
Media mobile variant).
Removes ~90 lines of duplication across data-table.tsx and Media.tsx;
adds the focused 122-line shared component. The DataTable Select imports
are dropped (now unused). 122 tests pass; lint/build green.
Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #5.
Set refetchIntervalInBackground: false as a QueryClient default so all
interval-based polls (widgets ~30s, message-queue 5s, media build progress
1s) pause when document.visibilityState === 'hidden'. Battery-friendly on
mobile -- the dashboard is the page most likely to be left open on a phone.
The media build-progress poll previously forced refetchIntervalInBackground:
true; that override is removed so it inherits the default. The build keeps
running server-side; the poll resumes and catches up when the user returns
to the tab.
122 tests pass; lint/build green.
Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #3 (D8 battery follow-up).
Applies .mobile-touch-target to 32 default-size <Button> elements (32px
tall, below the mobile minimum) across 9 files for strict WCAG 2.5.5
compliance: Save, Cancel, Delete, Validate SSH, Run job, Build index,
Update connection, Add service, etc. Plus the shared DialogFooter Cancel
+ Confirm buttons (used by every ConfirmDialog).
The class applies min-height/min-width: 44px only below md
(max-width: 767px); no-op at md+, so desktop sizing is unchanged.
Completes the touch-target audit started in Slice 9 (which covered icon
buttons, size=sm buttons, checkboxes, switches). 122 tests pass; lint/
build green. No new tests (@media queries aren't honored by jsdom).
Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #2.
SheetForm gains an isDirty prop. When true, any close attempt (Cancel
button, header X, Radix overlay click, Escape) opens a 'Discard changes?'
ConfirmDialog instead of discarding unsaved edits. Radix dismiss callbacks
(onEscapeKeyDown, onPointerDownOutside) are intercepted when dirty so the
guard applies uniformly.
All four form consumers now compute and pass isDirty:
- ServicePage: name/enabled/config differ from the persisted instance.
- Settings machine editor: field-by-field draft vs editingMachine
(create mode is always dirty; secret write-only fields excluded).
- Message compose: subject non-empty, body differs from default, or
attachments present.
- WidgetConfigDialog: draft !== null (only draft mode is guarded; list
mode has nothing to discard).
Tests: 3 new SheetForm dirty-guard cases (prompt on cancel, abort discard,
clean close when not dirty) + one focused dirty-guard test per consumer.
122 tests pass; lint/build green.
Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #1.
Below md, both the message-compose Dialog and the WidgetConfigDialog
render inside a SheetForm instead of a centered Dialog.
Message compose (UsersPage.impl.tsx): the form body (subject, formatting
toolbar, HTML textarea, preview, attachments) is extracted into a shared
composeBody const consumed by both SheetForm (mobile) and Dialog
(desktop). SheetForm wired with title, onSave=handleSend (which already
closes on success per R4.5), onCancel=closeCompose, isPending,
saveDisabled, saveLabel='Send message'.
WidgetConfigDialog: the draftBody const is shared between branches. The
two-mode flow (list vs draft) maps to dynamic SheetForm props -- list
mode ('Dashboard widgets' / Done / Cancel both close), draft mode
('Add/Edit widget' / Save widget / Cancel=reset back to list). The
inline Back/Save buttons are hidden on mobile (!isMobile) since the
SheetForm footer provides them.
Desktop (md+) is token-identical for both components -- the
isComposeMobile (900px) fullscreen styling on compose is preserved for
the 768-900px band. The large diff (~860 lines) is dominated by
extraction/re-indentation of shared form bodies into consts; the
behavioral delta is ~80 lines.
Tests: 3 new (compose mobile send/subject, WidgetConfigDialog desktop +
mobile titles/Done). 116 tests pass; lint/build green.
Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 8).
Below md, the machine editor Dialog renders as a SheetForm (triggered by
the same Edit/Add buttons via machineDialogOpen state). The shared
MachineEditor body (fields + SSH validate button) renders inside the
sheet; the ConfirmDialog is a sibling outside. Desktop Dialog is
byte-for-byte identical.
No navigation needed on close -- the Settings page content (tabbed cards,
machine list) is always visible behind the sheet, so there is no stranding
risk (unlike ServicePage where the sheet was the whole page).
Added saveDisabled prop to SheetForm (additive, default false) so the
machine editor can gate Save on required fields (name + host for SSH
mode), matching the desktop DialogFooter confirmDisabled semantics.
Scope note: SSHKeyManager is an inline two-panel layout (SelectionRailCard
+ SectionCard), not a dialog, and already stacks responsively via
grid-cols-1 md:grid-cols-[...]. Wrapping it in SheetForm would break its
always-visible selection rail. Left as-is.
Tests: 3 new mobile cases (SheetForm render, save payload, cancel closes)
+ desktop unchanged. 113 tests pass; lint/build green.
Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 7).