Replace the MediaTab and RequestsTab stubs with real implementations on
the Jellyfin service page.
MediaTab (pages/service-tabs/MediaTab.tsx): lifts the operational content
from the top-level Media page into an instance-scoped tab. Build controls,
status display, library counts, media DataTable, and pagination all read
the Jellyfin service id directly from the instance prop (replacing the old
URL-search-param service selector + dropdown). Row-click navigation to
the file browser is preserved (note: target /files is a cross-slice
dependency on slice 6's FilesTab).
RequestsTab (pages/service-tabs/RequestsTab.tsx): reads the absorbed
jellyseerr_url + jellyseerr_api_key from the Jellyfin instance config.
When unconfigured, renders a CTA to add the fields via the Config tab.
When configured, shows the Jellyseerr URL + an honest placeholder (no
requests backend endpoint exists yet -- out of scope for this slice).
service-tabs/index.ts updated to wire the new components; the
MediaTabStub/RequestsTabStub removed from stubs.tsx.
Note: this branch is based on main, not on mobile-responsive-parity, so
MediaTab lifts main's DataTable + TanStack column-visibility mobile
hiding (no MobileCardRow -- that lands when the branches reconcile).
Tests: MediaTab (instance-scoped hooks + build controls + table render)
+ RequestsTab (configured URL vs empty-state CTA). 90 tests pass (+6);
lint/build green.
Cross-slice flag: MediaTab row-click -> /files will 404 until slice 6
re-routes it to the ssh_tasks FilesTab.
Refs openspec/changes/services-as-hub-ia/ (spec R2.4, tasks slice 5).
The IA shell lands. The static navItems array is replaced by useNavItems(),
which combines useServiceInstances (enabled instances) + useDashboards to
build the nav in spec order: Main Dashboard, named dashboards, conditional
service-type entries (one per configured type; ssh_tasks contributes Files
+ Actions, nextcloud contributes none), Services, Settings.
Legacy top-level routes (/media, /files, /actions, /users, /observability,
/backups, /monitoring, /applications) are removed; a NotFoundPage catch-all
returns 404 (R4.7).
ServicePage is refactored to a tab skeleton: Overview | type-specific
content tabs | Widgets | Config. serviceContentTabs(type) returns the
per-type set (jellyfin=Media+Requests, ssh_tasks=Files+Actions, backups=Jobs,
authentik=Users+Messaging, alertmanager=Alerts, grafana=Links,
prometheus=Metrics, nextcloud=none). Content tabs are stubs ('coming soon');
real content migrates in slices 5-9. Widgets + Config tabs preserve the
existing widget-list and config/secrets editing verbatim.
ServiceTypePage resolves /services/:type (no id) by redirecting to the
first enabled instance; empty state when none.
Instance switcher (Select) appears when >1 ENABLED sibling of the same
type exists (R3.1).
Empty states: Dashboard shows an 'Add a service' CTA when no instances
exist; ServicesPage already had a strong empty state.
Fixes from Slice 4 review:
- B1 (blocker): secret editing regressed because buildInput() hardcoded
secrets:{} after the ConfigBody lift orphaned draftSecrets. Lifted
draftSecrets to the parent ServicePage; buildInput now sends only the
non-blank typed drafts ('leave blank to keep' semantics restored).
- S1: switcher trigger keys off enabled siblings, not total.
New: navEntries.ts + test, dashboards api/hook, service-tabs/ stubs +
index, ServiceTypePage, ServicePage tab skeleton + ConfigBody lift,
Dashboard empty-state CTA, ServicePage tab/switcher/secret-save tests.
Note: this branch is based on main (mobile-responsive-parity is unmerged);
the mobile SheetForm on ServicePage will be re-added when content tabs
get real content (slices 5-9). 84 tests pass (+1 secret-save guard);
lint/build green.
Refs openspec/changes/services-as-hub-ia/ (spec R1-R4/R9, tasks slice 4).
New service types:
- backups: BackupsConfig(ingestion_label), no secrets, summary widget kind.
Modeled as a service so it can be named/multi-instanced like others.
- authentik: AuthentikConfig(base_url, timeout_seconds), api_token secret
(required). Directory source for the upcoming Users tab.
Jellyseerr absorption:
- JellyfinConfig gains optional jellyseerr_url + jellyseerr_api_key fields.
- integrations/jellyseerr.py deleted; registry entry removed.
- clients/jellyseerr.py stays (JellyseerrClient still used by enrichment).
- One-time idempotent migration in settings_store.ensure_defaults():
jellyseerr service rows merge into a paired Jellyfin (exactly-one merges;
multiple picks first unpaired; none/all-paired drops with a logged
warning). The api_key is decrypted from secrets before moving to config.
Registry is now 8 types: alertmanager, authentik, backups, grafana,
jellyfin, nextcloud, prometheus, ssh_tasks.
Tests: registry count updated to 8, jellyseerr-absent assertion, new-type
definition assertions, and migration tests (single-jellyfin merge, no-
jellyfin drop, idempotency). 256 backend tests pass; ruff clean.
Refs openspec/changes/services-as-hub-ia/ (spec R6, tasks slice 1).
Reorganize the app around services as the hub. Operational content
(Media, Files, Actions, Users, Backups) moves into type-specific tabs on
the service page. Top nav shrinks to Main Dashboard + named dashboards +
conditional per-type entries (appear when configured) + Services + Settings.
Decisions (D1-D17): conditional type entries; instance switcher for
multi-instance; Files/Actions into ssh_tasks tabs; Backups = new service
type; Users -> Authentik (in scope); Observability split per type (no
aggregate); main dashboard special at /; named dashboards = widgets +
pinned service links; each named dashboard = top entry; Authentik =
directory source (OIDC unchanged); Messaging -> Authentik users via SMTP;
Jellyseerr absorbed into Jellyfin config; standard tab skeleton
(Overview | content | Widgets | Config); Overview = health + metrics;
routing /services/:type/:id + /d/:slug; legacy routes 404; empty-state
CTAs.
Authentik directory client + endpoint included. 12 chained PRs forecast
(backend types -> frontend shell -> content tabs -> dashboards ->
cleanup -> verify).
The service detail page showed non-secret connection config (base_url,
user_id, username, timeout_seconds) as read-only. Render schema-driven
editable inputs (reusing the create-dialog pattern) with a draftConfig
state hydrated from the instance, and unify the save button to persist
both config and secrets. Number fields render as type=number; the base_url
schema description surfaces as helper text.
Add a shared ServiceBaseUrl type (BeforeValidator + Field description) in
integrations/base.py and apply it to base_url across all six service configs
(grafana, prometheus, alertmanager, jellyfin, jellyseerr, nextcloud). Missing
http:// or https:// schema now fails fast with a clear 422 instead of breaking
HTTP clients silently. Tests cover reject/accept cases; REQUIREMENTS updated.
Regenerate project map artifacts across backend, docs, openspec, and
root to refresh role descriptions and architectural notes after recent
service-registry and observability changes.
Co-authored-by: el Gentleman <gentleman@pi.local>
On a fresh deploy with no Jellyfin service configured yet,
get_jellyfin_client (and get_user_id / get_ssh_client) raised a plain
RuntimeError, which bubbled up as a 500 traceback on every
Jellyfin-dependent route (dashboard counts/libraries/activity, media,
users). Convert those RuntimeErrors to HTTPException(503) with a clear
detail message so FastAPI returns a clean 503 JSON response instead of
a 500, and the frontend can render a not-configured state.
- dependencies.py: get_jellyfin_client (no service / missing creds),
get_user_id (no users discovered), and get_ssh_client (no SSH machine
+ no legacy key path) now raise HTTPException(503, detail=...).
- tests/test_api.py: added
TestDashboard.test_jellyfin_endpoints_return_503_when_not_configured
covering /api/dashboard/counts and /activity.
ruff clean; 240 backend tests pass.
Under AUTH_ENABLED=true, api/services.ts, api/widgets.ts, and
api/backups.ts called fetch() directly without attaching the OIDC
access token, so every services/widgets/backups request 401'd while
api/client.ts requests succeeded. The token was only attached in
client.ts.
Extract the auth-attaching fetch helpers (buildUrl/buildHeaders/
readErrorDetail + get/post/put/del/postForm) into a new api/shared.ts
that consults getAccessToken(), rewrite services.ts/widgets.ts/
backups.ts to use them, and consolidate client.ts to import from
shared.ts (removing its duplicated copies). Now every backend request
goes through one auth-attaching path.
As a side benefit, error messages surface the HTTP status + backend
detail instead of a generic "Failed to ..." string.
Bug masked in dev because dev runs AUTH_ENABLED=false. npm run build
clean; 0 lint errors; 72 frontend tests pass.
Refreshes the docs that were actively misleading about the current
FastAPI + React + service-registry app, and deletes one obsolete design.
- CONTRIBUTING.md: full rewrite — Streamlit-era guidance replaced with
the current backend (ruff/pytest, src/ layout) + frontend (npm
lint/build/test) workflow, service-registry model, and shadcn/Tailwind
stack. Mirrors AGENTS.md.
- README.md: removed the non-existent /addons/:addonId route (Services
page is current); fixed the per-machine Jellyfin wording; replaced the
py_compile dev snippet with ruff + pytest / npm lint+build+test.
- backend/README.md: updated the structure tree (removed deleted
clients/resources.py; added routers backups/services/tasks/widgets,
integrations/, models/, widgets/, workers/); dropped the "starts the
collector" sentence (MonitoringPoller is decommissioned).
- frontend/README.md: corrected the uvicorn module path
(main:app -> media_library_viewer_api.main:app).
- Deleted docs/superpowers/specs/2026-05-08-obsidian-documentation-design.md
(Obsidian vault never built; stack refs MUI/D3/AG Grid all removed).
Historical docs (MIGRATION_PLAN, superpowers backup-monitoring, the
bannered design/runbook/context files) deferred to a later banner pass.
Slice 3 (final) of jellyfin-service-registry. Documents the completed
migration and archives the SDD change.
- docs/REQUIREMENTS.md: marked the machine-level Jellyfin follow-up
resolved; added a decision-log entry (Jellyfin no longer a machine
service, dead media_root/path_prefix removed; global config +
path_utils retained for Jellyfin->SSH path resolution).
- CHANGELOG.md: struck through the old follow-up note; added a
Follow-up #2 section describing the machine field + service removal.
- Archived openspec/changes/jellyfin-service-registry (no active SDD
changes remain).
Backend ruff clean / 239 tests pass; frontend 0 lint errors / build
clean / 72 tests pass.
Slice 2 of jellyfin-service-registry. Removes the machine-level
media_root/path_prefix fields from the frontend now that the backend no
longer stores them.
- types/index.ts: dropped media_root/path_prefix from MonitoringMachine
and MonitoringMachineInput.
- pages/Settings.tsx: removed the media_root form input, the read-only
"Media root" detail (replaced with a local-hint field mirroring the
editor), and media_root/path_prefix from emptyMachine() and both
edit-handler reset mappings; updated the section description.
- tests: removed media_root/path_prefix from Settings/Media/FileBrowser
test fixtures.
npm run build (tsc -b + vite) clean; 0 lint errors; 72 tests pass.
Slice 1 of jellyfin-service-registry. Jellyfin is configured exclusively
via the service registry now; the machine-level media_root/path_prefix
fields were dead duplicates of the global config.
- services/settings_store.py: DEFAULT_SERVICES no longer includes
"jellyfin" (now ["monitoring", "files"]). Removed machine-level
media_root/path_prefix from _default_local_machine, _row_to_machine,
_normalize_machine_payload, _seed_local_machine, get_machine_config,
and upsert_machine. _default_local_machine no longer reads global
config, so the get_settings import is dropped.
- routers/settings.py: removed media_root/path_prefix from
MonitoringMachineInput (dead API input; store already ignored them).
The global config remote_media_root/path_prefix properties + path_utils.py
are unchanged (files.py and media_index still use them for Jellyfin->SSH
path resolution). ruff clean; 239 backend tests pass.
- Archive the completed observability-service-registry SDD change into
openspec/changes/archive/ (delivered across 5 slices; only
jellyfin-service-registry remains active).
- Stop ignoring .pi-map.md / .pi-map.index.md so the navigation maps are
versioned alongside the code, and add the regenerated map pairs repo-wide.
After the observability-service-registry slices removed the observability
env vars and the file-SD writer, several docs still instructed readers to
set vars that no longer exist. Updated the live config instructions;
historical decision-log entries are left intact.
- README.md: removed VITE_GRAFANA_URL/VITE_PROMETHEUS_URL/ALERTMANAGER_URL
from compose examples and the env-var block; added a note that
observability is configured on the Services page; updated Notes.
- frontend/README.md: dropped the stale VITE_* deep-link sentence.
- docs/REQUIREMENTS.md: fixed one stale trailing phrase in the
externalization decision-log entry (VITE_* no longer "remain").
- docs/monitoring-logging-design.md: added a "Superseded mechanisms" note
under Implementation Plan so the Phase 2/3 file-SD + alertmanager_url
details read as historical, not current wiring.
- context.md: strengthened the status banner to cover the env->service-
registry and file-SD->http_sd_configs shift; body marked historical.
.env.example is assistant-edit-blocked; updated replacement text provided
to the user separately.
Slice 5 (final) of observability-service-registry. Completes the move to
service-registry-only observability config: no observability service env
vars remain.
- config.py: removed alertmanager_url + alertmanager_webhook_url fields.
- docker-compose.yml / docker-compose.dev.yml: removed ALERTMANAGER_URL,
ALERTMANAGER_WEBHOOK_URL (backend env), and VITE_GRAFANA_URL,
VITE_PROMETHEUS_URL (frontend build args / dev env).
- frontend/Dockerfile: removed the VITE_GRAFANA_URL / VITE_PROMETHEUS_URL
ARG, build-stage ENV, and dev-stage ENV lines.
- docs: REQUIREMENTS decision-log entry; CHANGELOG Added/Changed/BREAKING
for the observability service registry; backend/README monitoring
section (Observability page, services page config, http_sd_configs,
new health endpoints, log-only webhook).
The only observability env var remaining is PROMETHEUS_ENABLED (Manage's
own /metrics toggle). Grep-gated: no live references to the removed
vars/fields in backend src, frontend src, compose, or Dockerfile.
ruff clean; 239 backend tests pass; frontend 0 lint errors, build clean,
72 tests pass.
.env.example is assistant-edit-blocked; user follow-up noted in the SDD
tasks: drop the removed vars there too.
Slice 4 of observability-service-registry. Removes the shared-file
Prometheus bridge; external Prometheus now consumes node-exporter targets
via http_sd_configs against GET /api/monitoring/prometheus-targets.
- services/targets.py: removed write_prometheus_targets() (the file
writer) and its json/Path/get_settings imports; updated module docstring.
build_node_exporter_targets() is unchanged and still powers the HTTP
endpoint.
- main.py: removed the startup write_prometheus_targets call.
- routers/settings.py: removed the _write_prometheus_targets helper and
its three post machine create/update/delete call sites + the now-unused
targets import.
- config.py: removed the prometheus_file_sd_dir field.
- docker-compose.yml / docker-compose.dev.yml: removed the
PROMETHEUS_FILE_SD_DIR backend env var.
- tests: removed TestWritePrometheusTargets + the write_prometheus_targets
import in test_targets.py; rewrote the two TestSettingsMachines tests to
assert machines appear/disappear from /api/monitoring/prometheus-targets
(the surviving HTTP path) instead of the removed file-writer side effect.
ruff clean; 239 backend tests pass.
Slice 2 of observability-service-registry. The monitoring router resolves
observability components from the service registry instead of env vars.
- routers/monitoring.py: removed _alertmanager_client/_webhook_client env
readers + the get_settings import. Added _resolve_service_record(store,
service_type, service_id?) -> ServiceRecord|None (requested instance with
type+enabled checks, else first enabled instance), plus _base_url/_timeout/
_auth_headers (Bearer from api_key)/_status_response helpers.
- /alerts + /alertmanager-status now take service_id? + Depends(store),
resolve an alertmanager service, return graceful not-configured/
unreachable payloads including service_id/name; status down-branches now
include peers:[] + error (fixes prior type drift).
- NEW /grafana-status (probes /api/health) and /prometheus-status (probes
/-/healthy then /api/v1/status/buildinfo) returning
{up,version,service_id,name,error}.
- Webhook receiver is now log-only (dropped the outbound
ALERTMANAGER_WEBHOOK_URL forward).
- tests: rewrote TestAlertmanager + TestAlertmanagerWebhook to mock
_resolve_service_record/requests.get (not-configured via empty registry);
added TestGrafanaStatus/TestPrometheusStatus and a TestResolveServiceRecord
unit class covering service_id match/type-mismatch/disabled and first-
enabled/none-enabled paths.
Orphaned config fields alertmanager_url/alertmanager_webhook_url and the
env-var removal land in Slice 5. ruff clean; 240 backend tests pass.
Reviewed fresh-context (read-only): no blockers.
Rename grafana-prometheus-polish -> observability-service-registry and
rewrite proposal/design/tasks for the approved vision: all observability
integration (alertmanager, grafana, prometheus) configured as service-
registry instances in the UI, surfaced on a dedicated page, with widgets
per service definition -- nothing in the env.
Key scope decisions captured:
- Add alertmanager as a 6th service type + active_alerts widget.
- Rewire /alerts + /alertmanager-status to resolve from service records
(first-enabled-instance default; no primary flag in v1).
- Add /grafana-status + /prometheus-status health endpoints.
- Observability page discovers services; kill VITE_GRAFANA_URL /
VITE_PROMETHEUS_URL deep-links.
- Webhook receiver stays log-only (drop the outbound forward).
- Remove PROMETHEUS_FILE_SD_DIR + the file-writer; external Prometheus
uses http_sd_configs against GET /api/monitoring/prometheus-targets.
build_node_exporter_targets + that endpoint stay.
- PROMETHEUS_ENABLED stays (Manage's own /metrics toggle).
- End state: zero observability *service* env vars.
Plan = 5 slices, each <=400 changed lines, green tests/lint/build,
commit per slice.
Manage now connects to existing Grafana/Prometheus/Alertmanager instances
and never deploys its own stack.
- docker-compose.yml / docker-compose.dev.yml: removed prometheus, loki,
alloy, grafana, alertmanager, node-exporter services, the monitoring
network, and observability named volumes; they now ship only backend +
frontend. Dev frontend now joins the web network so the Vite dev proxy
can reach the backend.
- backend: alertmanager_url default is now empty; /api/monitoring/alerts
and /alertmanager-status return graceful "not configured" responses
when ALERTMANAGER_URL is unset. Added not-configured tests.
- docker-compose.observability.yml: kept as the optional standalone
example; header clarifies Manage does not deploy it.
- Removed orphaned combined monitoring/prometheus/prometheus.yml
(standalone stack uses prometheus.standalone.yml).
- Docs (README, REQUIREMENTS decision log, monitoring-logging-design,
observability-runbooks, context.md, MIGRATION_PLAN, frontend/README,
CHANGELOG) updated to the connect-to-existing model.
VITE_GRAFANA_URL / VITE_PROMETHEUS_URL remain as optional frontend
deep-link overrides. .env.example still needs a manual update (safety
policy blocks assistant edits): set ALERTMANAGER_URL empty/optional and
move standalone-only vars out of the root file.
- jellyfin-service-registry: proposal, design, and tasks for completing
the Jellyfin migration off machine-level config.
- grafana-prometheus-polish: proposal, design, and tasks for improving
the Grafana/Prometheus observability integration.
Both are planning-only artifacts; implementation not started.
Move finished change directories to openspec/changes/archive/:
- configurable-dashboard-widgets
- decommission-monitoring-poller
- service-registry
- unify-tasks-on-services
All associated implementation has been merged to main.
The legacy SSH-scraping MonitoringPoller and its endpoints were
decommissioned earlier; update the backend README endpoint list and
Monitoring description to match the current Alertmanager + Prometheus
targets + Grafana observability model.
- Add shared task_runner.run_saved_task helper used by routers/tasks.py and
widgets/sources.py SshTaskWidgetSource.
- Saved tasks now target ssh_tasks service instances via default_service_id;
the legacy default_machine_id and saved_task_runs are removed.
- Actions page lists ssh_tasks services for default and run-time selection.
- Update types, API client, hooks, tests, docs, and changelog.
Backend tests: 222 passed. Frontend lint/build/test: clean (71 passed).
Follow-up #1 to the service-registry change. Jellyfin/Jellyseerr now resolve
from the service registry, so the machine-level app fields are dead config.
- dependencies.py: drop dead _jellyseerr_client_for; simplify _resolve_machine
to SSH-only.
- settings_store.py + routers/settings.py: remove jellyfin_*/jellyseerr_* from
machine default config, get_machine_config, normalization, row mappers, and
MachineInput.
- frontend types + Settings.tsx: drop the fields and the Jellyfin/Jellyseerr
form sections + service options.
- Update frontend test fixtures.
Existing DB rows may still carry these keys in config_json; they are inert and
drop on the next machine save. Verification: backend ruff clean, pytest 222;
frontend lint 0 errors, build success, 70 tests.
Slice 4b frontend half. Jellyfin-touching pages now select a Jellyfin service
instance instead of a machine.
- api/client.ts: Jellyfin-backed calls (counts/libraries/activity/users, media
status/build/stop/force-stop, queryMedia) send jellyfin_service_id.
- hooks/useDashboard, useUsers, useMedia: selector param renamed to
jellyfinServiceId.
- pages/Media + Applications: list jellyfin service instances and persist
jellyfin_service_id in the URL.
- Dashboard (widgets) and Users (default instance) need no selector change.
- Update Applications + Media tests for the new hook/param.
Files/SSH transport keeps machine_id. Verification: frontend lint 0 errors,
build success, 70 tests; backend ruff clean, 222 tests.
Slice 4b backend half. Jellyfin and Jellyseerr clients are now resolved from
service instances instead of machine-level app config.
- Add jellyseerr service definition (6 service types total); add user_id to
the Jellyfin service config.
- dependencies.py: jellyfin_service_id query param + _service_record
(decrypt-on-read); get_jellyfin_client / get_jellyseerr_client / get_user_id
resolve against the service registry (first enabled instance as fallback).
- SSH/Files transport (get_ssh_client) unchanged; still uses machine_id.
- Update service-registry tests for 6 types.
Selection model: split params — ?jellyfin_service_id= for Jellyfin/Jellyseerr,
?machine_id= for SSH/Files. Frontend threading follows in the next PR.
Verification: backend ruff clean, pytest 222 passed; frontend green (unchanged).
PR 4a of the runtime service registry change.
- Remove addon pages (/addons/:addonId, AddonPage, addons/*) superseded by
service pages.
- Remove grafana_url/prometheus_url from backend config, compose, .env.example,
and README (URLs now live on service records; VITE_ frontend deep-link vars
retained).
- Add Services page (/services) with create/list/delete + sidebar nav, so
services are configurable in the tool itself and service pages are reachable.
- Update docs/REQUIREMENTS.md service-registry section; add CHANGELOG.md with
the breaking-upgrade note (MANAGE_ENCRYPTION_KEY required; grafana/prometheus
env vars removed; default widget seeding removed).
Verification: backend ruff clean, pytest 222 passed; frontend lint 0 errors,
build success, 70 tests passed.
PR 3 of 4 for the runtime service registry change.
- Add service + new-shape widget TypeScript types; widgets carry service_id
+ widget_kind (service-bound) or null (built-in).
- Add services API client + TanStack Query hooks; reconcile the widget API
client/hooks to the new endpoints (remove sources/types; add builtin kinds).
- Add closed frontend service registry (integrations/registry.ts) mirroring the
backend, with resolveWidget(widget, services) mapping a widget to its
component + refresh interval.
- Add ServicePage at /services/:serviceType/:serviceId with config view,
empty-on-edit secret inputs + 'set' badges, enable toggle, delete, and the
service's widget-kind list.
- Register /services/:serviceType/:serviceId in App.tsx.
- Reconcile the six widget components to refreshIntervalMs + description props;
rewrite WidgetConfigDialog around a service -> widget-kind picker.
- Update Dashboard test; add integrations/registry.test.ts.
Verification: frontend lint 0 errors, build success, 70 tests passed; backend
ruff clean, 222 tests passed.
PR 2 of 4 for the runtime service registry change.
- dashboard_widgets gains service_id + widget_kind columns (legacy
addon_id/widget_type kept but unused).
- Source adapters take (service: ServiceRecord | None, widget_kind, config).
SERVICE_ADAPTERS keyed by service_type; BUILTIN_ADAPTERS for backups/static.
- Backups and static stay as service-less built-ins (service_id nullable),
exposed via GET /api/widgets/builtin.
- SSH task adapter resolves the task + instance, runs over SSH, and appends a
service_task_runs history row on success/failure/timeout/error.
- Retire widgets/registry.py; widget metadata now comes from the integrations
registry + widgets/builtin. Remove /api/widgets/types and /api/widgets/sources.
- Stop default widget seeding (fresh install = empty dashboard).
- Rewrite widget tests around the service-bound + built-in model (26 tests).
Backend-only breaking change; frontend is reconciled in Slice 3. Build/lint
stay green; pytest 222 passed.