Commit Graph

134 Commits

Author SHA1 Message Date
Developer ba01ad7c0c perf(qbittorrent): rid incremental sync + shared cache + backoff (stop hanging qBittorrent)
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.
2026-07-12 12:20:05 +00:00
Developer 7e4222ef00 fix(widgets): expose unit/scale options in the frontend widget registry
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.
2026-07-12 12:00:11 +00:00
Developer b7019b33ac feat(widgets): scale chart axes/tooltips with unit + scale options
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.
2026-07-12 11:46:07 +00:00
Developer 50c0c9b548 fix(gauge): render single value arc with correct Tailwind v4 colors
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.
2026-07-11 13:05:42 +00:00
Developer dad2202756 fix: reset service editor on switch + add service-page settings shortcut
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.
2026-07-11 11:54:18 +00:00
Developer ecabc65dd4 fix: widget edit crash (#185) + resizable textarea for complex fields
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.
2026-07-11 10:31:57 +00:00
Developer 9bc8fab971 chore: fold pre-existing ServicesPage.tsx formatter stray
Whitespace-only JSX reflow (prettier) from earlier #1 validation-surfacing fix;
folding so the working tree goes pristine before the final push.
2026-07-10 00:17:51 +00:00
Developer 29650ca512 chore(per-instance-hook-scoping): archive verified+synced change
Move to openspec/changes/archive/2026-07-09-per-instance-hook-scoping/
(R100 renames preserved). 9 artifacts. Canonical openspec/specs/
service-instance-scoping/ remains. Resolves multi-instance wrong-data bug
(hooks now scope by instance.id; instance switcher re-scopes).
Carry-overs: fetchBackupDashboard untouched (design decision 5); subquery
scoping for runs/alerts (schema asymmetry).
2026-07-10 00:17:51 +00:00
Developer 3bc7ce5269 feat(per-instance-hook-scoping): scope observability + backup hooks by instance 2026-07-09 23:54:31 +00:00
Developer f6c67bd3ff feat(service-credential-tester): slice 2 — Test button + gating (shared ServiceTestPanel)
Presentational ServiceTestPanel (props-driven, no internal hooks) wired into
both CreateServiceDialog (ServicesPage.tsx) and ServiceConfigEditor
(Settings.tsx). Parent owns testResult + saveAnyway state; store-previous
pattern resets on input change (avoids setState-in-effect). Create/Save
button gated on testPassed || saveAnyway. 7 panel tests (button states,
success/failure pills, checkbox toggle). All gates: 158 vitest, build exit 0,
lint 0 errors, 362 backend pytest (regression).
2026-07-09 22:57:02 +00:00
Developer c886fcdf09 spec(grafana-metric-gateway): verify + close GM-115 + reconcile tracking
Add 3 test cases (startup old-config validation warning; status auth_failed
for 401/403) closing the GM-115 PARTIAL. Write apply-progress.md, tick all 33
tasks, add verify-report.md (15/16 PASS, 1 PARTIAL->PASS). Gates green: 331+
pytest, ruff clean, npm build+lint 0 errors, 151 vitest.
2026-07-09 21:53:18 +00:00
Developer 7e91e7f931 feat(grafana-metric-gateway): slice 2 — rename widgets to Metric*
git mv PrometheusChartWidget→MetricChartWidget, PrometheusGaugeWidget→
MetricGaugeWidget, PrometheusMeanWidget→MetricMeanWidget (+ 3 test files,
R100 history preserved). Update registry imports/refs + barrel exports.
Adapt PrometheusMetricWidget for §3.4 Option A: read normalized {result:
[{label,points}]} series shape (last-point extraction) instead of old Prom
{resultType,result} vector. PrometheusMetricWidget NOT renamed (design §3.1).
GM-111/112/116 satisfied. All gates: 151 vitest, build exit 0, lint 0 errors.
2026-07-09 21:34:26 +00:00
Developer cadb6d0991 fix(nav): add qBittorrent to per-service-type navbar entries
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.
2026-07-09 20:11:12 +00:00
Developer 493c0e1aeb fix(services): surface validation errors in add-service dialog
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.
2026-07-09 20:10:46 +00:00
Developer ec7ebd7013 chore: regenerate stale .pi-map.md / .pi-map.index.md project maps
Project map had drifted: claimed recharts unused (was used), ObservabilityPage.tsx
exists (refactored to service-tabs/), missed JellyfinNowPlayingWidget +
authentik/backups service types. Regenerate to reflect post-change reality:
new Qbittorrent/LineSeriesChart/Prometheus{Gauge,Mean}Widget files,
service_data.py/qbittorrent_store.py, canonical prometheus-charting +
service-storage specs, archived changes.
2026-07-09 14:05:56 +00:00
Developer 98f17f6e5d chore: apply pre-existing formatter reformat to MediaTab.tsx
Whitespace-only line reflow (formatter-on-save artifact from a prior session,
not logic). Committing to pristine the working tree.
2026-07-09 13:50:36 +00:00
Developer 1fb12b8a0a feat(service-storage-harness): slice 2 — qbit widgets + LineSeriesChart extract 2026-07-09 08:49:20 +00:00
Developer 7440603cdb spec(prometheus-direct-charting): verify + close SC-125 + reconcile tracking
Add loading-state tests to the three Prometheus widget test files
(closes SC-125 PARTIAL). Write apply-progress.md, tick all 39 tasks,
add verify-report.md (26/27 PASS, 1 PARTIAL->PASS). All gates green:
293 pytest, ruff clean, npm build+lint 0 errors. No blocking findings.
2026-07-08 22:50:30 +00:00
Developer 67ca0fc3bc feat(prometheus-direct-charting): slice 3 — remove grafana + config rewrite + changelog
Remove the entire Grafana surface: integrations/grafana.py, GrafanaWidgetSource
(+ _fetch_chart, now redundant since prometheus chart exists), GrafanaLinkWidget,
LinksTab, get_grafana_status endpoint, useGrafanaStatus hook, GrafanaStatus type,
fetchGrafanaStatus client fn, registry/nav/tab entries (FE+BE). Rewrite
config.yaml thin-dashboard rule to match reality (recharts is sanctioned for
Prometheus-backed series). CHANGELOG migration note added.

Backend: 293 pytest pass, ruff clean. Frontend: build+lint green (0 errors).
SC-115/116 grep-clean (only prometheus_range.py migration comments + Dashboard.test
shortcut fixture remain — both spec-allowed).
2026-07-08 22:35:06 +00:00
Developer 65bae95e3c feat(prometheus-direct-charting): slice 2 — gauge + mean widgets
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.
2026-07-08 22:10:11 +00:00
Developer 5dad98231f feat(prometheus-direct-charting): slice 1 — prom range query + chart rebrand
Add PrometheusWidgetSource._fetch_chart hitting /api/v1/query_range directly
(SC-101..104). New shared helpers in widgets/prometheus_range.py:
step_for_window (window preset -> step, ~200pts) and normalize_prometheus_matrix
(extracted label/dedup rule, retargeted at Prom matrix, robust to malformed
data). Chart widget kind moved grafana->prometheus in both registries;
GrafanaChartWidget renamed -> PrometheusChartWidget (git mv, recharts body
preserved). Grafana binding/service untouched (removed in slice 3).

Backend: 298 pytest pass, ruff clean. Frontend: 128 vitest pass, build+lint green.
2026-07-08 21:55:02 +00:00
Developer 787f46700f Fix: media index build never starts (blocking Jellyfin dependency)
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.
2026-07-06 15:25:48 +00:00
Developer 57fe04ae7b Fix: edit button on referenced widgets opened list view instead of edit
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.
2026-07-06 14:59:24 +00:00
Developer 5a43894875 Fix: sheet scroll, direct-edit close, mobile copy btn, service badge
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.
2026-07-06 14:51:14 +00:00
Developer 04871bd7d4 Fix: dialog scroll, isDirty false positive, mobile edit btn, widget copy
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.
2026-07-06 14:26:58 +00:00
Developer a63467e163 Fix: main dashboard edit showed all widgets (missing scope filter)
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.
2026-07-06 14:09:22 +00:00
Developer d8c0a37210 Fix: settings master/detail, widget kind filter, reorder, media worker
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.
2026-07-06 14:03:05 +00:00
Developer eeb0cccbce Add hover-reveal edit button to widget cards + auto-open edit mode
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.
2026-07-06 12:53:14 +00:00
Developer 1e636fdbe2 Follow-ups: reference reorder, detach service_id, named-dashboard widgets
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.
2026-07-06 12:11:47 +00:00
Developer c36262d7b6 Reusable widgets: reference widgets across dashboards + detach to clone
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.
2026-07-06 11:34:48 +00:00
Developer 94bf830955 Fix invisible chart lines + extract Prometheus labels for multi-series
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.
2026-07-06 10:59:16 +00:00
Developer f355d04278 Use multi-line textarea for complex widget config fields
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.
2026-07-06 10:43:12 +00:00
Developer bfe7ce7367 Fix: WidgetConfigDialog scope + reorder race condition
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.
2026-07-06 10:29:24 +00:00
Developer 447775048c Replace Grafana iframe panel with server-side chart widget
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.
2026-07-06 10:19:57 +00:00
Developer b877a32ad8 Add Jellyfin Now Playing + Grafana Panel embed widgets
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.
2026-06-28 15:26:43 +00:00
Developer 8d2e4c9bfd Service IA refinement: nav naming, instance tabs, config to Settings, configurable Overview
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.
2026-06-26 22:25:46 +00:00
Developer fef0ded76f Fix: tabs.tsx data-orientation variants were dead (side-by-side layout)
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.
2026-06-26 21:33:19 +00:00
Developer f7f590fa47 Fix: ServicePage content tabs wrapped in SheetForm on mobile
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.
2026-06-26 21:21:59 +00:00
Developer 01527ae4f0 Rebase services-as-hub-ia onto mobile-responsive-parity
Combine both branches into a single coherent branch:
- Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm,
  .mobile-touch-target, mobile cards, SheetForm forms, 44px targets,
  dirty-state confirm, TablePagination, refetchIntervalInBackground).
- Full services-as-hub IA (data-driven nav, service-page tab skeleton,
  new service types, Authentik directory + messaging, named dashboards,
  legacy routes 404, Observability split, Jellyseerr absorbed).

Enhancement: service tabs now use mobile-parity primitives:
- MediaTab: MobileCardRow below md (title/size/HDR/library/year) +
  TablePagination; DataTable at md+ (desktop branch preserved).
- FilesTab: MobileCardRow below md (name/type/size/modified) +
  handleRowClick; DataTable at md+.
- ServicePage: SheetForm branch below md (open-on-mount, sticky header
  + save bar, cancel navigates back to /services, dirty-state guard).
- Dashboard: single-column + section anchors below md (from mobile-parity)
  + empty-state CTA (from services-hub).
- App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity)
  + data-driven useNavItems (from services-hub).
- Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from
  mobile-parity; JobsTab inherits mobile behavior through its sub-components.

Conflict resolutions:
- Backend: entirely from services-hub (mobile didn't touch it).
- Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/
  ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted
  (services-hub deleted them; content moved into service tabs).
- New service-tabs/*: from services-hub, enhanced with mobile patterns.
- App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile.
- Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors).
- ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm.
- Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity.

117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests +
services-hub's new tab/dashboard tests); 271 backend tests pass; lint/
build green both sides.
2026-06-26 21:08:51 +00:00
Developer 32fa01cc12 Extract shared TablePagination (dedupe DataTable + Media mobile)
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.
2026-06-26 15:58:12 +00:00
Developer ac703eecd2 Pause TanStack interval refetches when the tab is hidden (D8)
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).
2026-06-26 15:48:43 +00:00
Developer d05de0aacd Touch-target pass: 44px min on default-size buttons (WCAG 2.5.5)
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.
2026-06-26 15:45:34 +00:00
Developer 09b9c45665 SheetForm dirty-state confirm + wire isDirty into all form consumers (R4.5)
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.
2026-06-26 15:31:30 +00:00
Developer 30f1b6e6db Touch-target audit: 44px minimum on mobile interactive elements (Slice 9)
Apply the mobile-touch-target CSS class to 40 interactive elements across
12 files. The class applies min-height/min-width:44px only below md
(max-width:767px), satisfying WCAG 2.5.5 / Apple HIG on touch devices.
Desktop behavior is unchanged.

Audit log (before -> after hit-area):
- App.tsx: hamburger/dark-mode/sign-out (32/32/28 -> 44)
- Dashboard.tsx: shortcut open/edit/delete (28 -> 44), enabled switch (18 -> 44)
- Media.tsx: mobile pagination prev/next (28 -> 44)
- FileBrowser.impl.tsx: 'Open Settings' alert button (28 -> 44)
- UsersPage.impl.tsx: compose toolbar bold/italic/link/list (32 -> 44),
  attachment remove button (16 -> 44)
- Settings.tsx: machine switch (18 -> 44), clear/add-machine buttons (28 -> 44),
  reset-db checkboxes x3 (16 -> 44)
- Actions.tsx: 'Add action' button (28 -> 44)
- ServicePage.tsx: service enabled switch (18 -> 44)
- ServicesPage.tsx: service switch/open-link/delete-icon (18/28/32 -> 44)
- ObservabilityPage.tsx: retry + 4 asChild link buttons (28 -> 44)
- WidgetConfigDialog.tsx: 4 icon buttons (32 -> 44), 2 switches (18 -> 44),
  2 add-widget buttons (28 -> 44)
- SessionActivityPanel.tsx: 'Open in Users' button (28 -> 44)

Deliberately skipped: default-size text buttons (32px, borderline), desktop-only
sidebar toggle, DataTable internals (desktop-only below md), Select triggers.
Dashboard anchor pills and HoverEditButton already had the class from Slices 1/2.

No new tests (the class applies via @media which jsdom doesn't honor).
116 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R6, tasks slice 9).
2026-06-26 14:37:40 +00:00
Developer 7808822a55 Mobile message compose + WidgetConfigDialog SheetForms (Slice 8)
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).
2026-06-26 14:17:30 +00:00
Developer e805c624b2 Mobile Settings: machine editor SheetForm (Slice 7)
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).
2026-06-26 13:56:27 +00:00
Developer f7b63fead5 Mobile ServicePage: SheetForm edit + close-on-save navigation (Slice 6)
Below md, ServicePage renders the edit form inside a SheetForm (open on
mount -- this page always edits an existing instance reached via
/services/:type/:id). The sheet body holds Name + Enabled + Connection
fields (no SectionCard wrapper, the sheet is the container) + Delete +
Widgets. At md+ the existing full-page layout renders token-identical.

Refactor: extracted the desktop inline JSX into configFields/widgetsCard/
confirmDelete consts and renamed ServiceConnectionCard ->
ServiceConnectionFields (isMobile prop drops the SectionCard wrapper on
mobile). Desktop output unchanged.

Fixes from Slice 6 review:
- R4.5: save() now closes the sheet on successful save (was staying open).
- Closing the sheet (save or cancel) navigates back to /services -- on
  mobile the sheet IS the page, so closing it would strand the user on a
  blank div. Added useNavigate.
- Strengthened the mobile save test to assert the full payload
  (name, id, enabled, secrets:{}, config), not just name+id.

Out of scope (flagged for verify pass): R4.5 dirty-state outside-click
confirm is a broader SheetForm concern not yet implemented.

Tests: 5 new (2 desktop non-regression + no-dialog, 3 mobile sheet render +
save payload + editable config). useNavigate added to the router mock.
110 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 6).
2026-06-26 13:44:50 +00:00
Developer 2eb649eceb Mobile Users + Backups tables: stacked cards + selection (Slice 5)
Below md, the Users directory and the three Backups tables render as
MobileCardRow cards:

- UsersPage: display name primary; username/activity/email fields. Each
  card carries a selection checkbox (44px via mobile-touch-target) in the
  actions slot with stopPropagation so toggling selection does not open
  the drawer; card-body tap still opens the drawer.
- BackupAlertsTable: alert message primary; severity/type/created fields;
  Acknowledge action preserved in actions slot.
- BackupJobsTable: job name primary; source/schedule/last-status fields
  (joins latestRuns into a JobCardRow).
- BackupRunsTable: run job_id primary; status/duration/size/started fields;
  status-filter Select renders above both layouts (preserved on mobile).

Desktop (md+) is byte-for-byte identical for all four components -- the
UsersPage diff is dominated by re-indenting the existing Table into the
isMobile ternary else branch.

Fix from Slice 5 review: MobileCardRow now renders the clickable card as
<div role=button tabIndex=0> with Enter/Space keyboard handling instead
of <button>, so nesting a Radix Checkbox (which renders a <button>) in
the actions slot produces valid HTML. The desktop-parity argument for
<button>-in-<button> did not hold (desktop rows are <tr>, not buttons).

Cross-cutting: useIsMobile hardened with typeof window.matchMedia guard
(safe in real browsers; only changes jsdom crash -> false). The file-local
900px compose hook was renamed useComposeViewport to avoid collision with
the shared 768px useIsMobile.

Tests: BackupJobsTable test file added (was untested), UsersPage mobile
selection round-trip + stopPropagation, mobile card render across all
four components. 105 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 5).
2026-06-26 13:24:19 +00:00
Developer 2076ab76fa Mobile FileBrowser: stacked cards for file list (Slice 4)
Below md, the file table renders as MobileCardRow cards: name as primary,
plus type/size/modified. Whole-card tap triggers handleRowClick (dir rows
navigate into the directory; file rows select for ffprobe preview). No
pagination needed (FileBrowser does not paginate).

The ext column is omitted from the card -- the extension is already visible
in the filename itself, so it's redundant on mobile and would waste card
space.

Path bar / breadcrumbs / Open / Refresh live outside the table and already
stack on mobile via existing md:flex-row. ffprobe and Jobs sections are
unaffected.

Desktop (md+) is byte-for-byte identical: the isMobile===false branch
renders the same DataTable with the same props.

Tests: 4 new covering mobile card render + dir-tap navigation + path
controls present + desktop DataTable. matchMedia mocked per-breakpoint.
98 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 4).
2026-06-26 12:53:25 +00:00
Developer 2e3e7b3850 Mobile Media table: stacked cards + mobile pagination (Slice 3)
Below md, the Media DataTable renders as MobileCardRow cards: title as
primary, plus size/HDR/library/year (3-5 fields, null-safe). Card tap
navigates to /files?path=... (same handleRowClick as desktop). The TanStack
column-visibility toggle is absent below md (the card picks the fields).

Pagination is preserved via a standalone MediaMobilePagination component
that mirrors DataTablePagination semantics (rows count, page-size select,
page indicator, prev/next with correct disabled states) off the raw
PaginationState. The duplication is flagged tech debt -- extracting a shared
TablePagination is a follow-up, out of scope for this slice.

Desktop (md+) is byte-for-byte identical: the isMobile===false branch
renders the same DataTable with the same props. enableRowSelection state is
vestigial (no batch consumer on either path); navigation is the correct
primary mobile interaction.

Tests: 5 new covering mobile cards + hidden column toggle + pagination +
card-tap navigation, and desktop DataTable + column toggle. matchMedia
mocked per-breakpoint. 94 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 3).
2026-06-26 12:43:09 +00:00