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).
11 KiB
SDD Tasks: Per-Instance Hook Scoping
Change: per-instance-hook-scoping
Phase: tasks
Date: 2026-07-09
Review Workload Forecast
| Field | Value |
|---|---|
| Estimated changed lines | ~257 (frontend-dominant wiring + backend filter) |
| 400-line budget risk | Low |
| Chained PRs recommended | No |
| Suggested split | Single PR |
| Delivery strategy | single-pr |
| Chain strategy | pending |
Decision needed before apply: No
Chained PRs recommended: No
Chain strategy: pending
400-line budget risk: Low
Slice ordering rationale
This change is small (~257 lines) and cohesive — all tasks serve one goal (scope observability + backup hooks to instance.id). A single slice is well within the 400-line budget. Splitting would create artificial boundaries (e.g. backend filter before frontend wiring) where a half-applied state has no user-visible benefit.
Slice 1: Per-instance hook scoping (full change)
Exit gate: PYTHONPATH=src python3 -m pytest -q + ruff check green (backend/); npm run build + npm run lint + npx vitest run green (frontend/). All existing tests stay green (non-regression: PI-117, PI-118). No usePrometheusTargets / useMonitoringMachines / fetchBackupDashboard changes.
Backend: backup store filter (PI-111)
-
1.1 Add
service_idfilter tolist_backup_jobs- Files:
backend/src/media_library_viewer_api/services/settings_store.py - Details: Add
service_id: str | None = Noneparameter tolist_backup_jobs. When truthy (non-None, non-empty), appendWHERE service_id = ?clause + param. When falsy, return all rows (backward-compat). Thebackup_jobstable HAS theservice_idcolumn (direct WHERE, no subquery needed). (design §4.2 decision 4)
- Files:
-
1.2 Add
service_idfilter tolist_backup_runs(subquery)- Files:
backend/src/media_library_viewer_api/services/settings_store.py - Details: Add
service_id: str | None = Noneparameter. When truthy, appendjob_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)to theclauseslist + param. Thebackup_runstable has NOservice_idcolumn (schema asymmetry — design §0 source finding 2), so a subquery throughbackup_jobsis required. When falsy, no filter. (design §4.2 decision 4)
- Files:
-
1.3 Add
service_idfilter tolist_backup_alerts(subquery)- Files:
backend/src/media_library_viewer_api/services/settings_store.py - Details: Same subquery pattern as 1.2:
job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)appended toclauseswhen truthy. Thebackup_alertstable has NOservice_idcolumn. When falsy, no filter.
- Files:
-
1.4 Add backend tests for backup service scoping
- Files:
backend/tests/test_backups.py - Details: Add
TestBackupServiceScopingclass with:test_list_backup_jobs_filtered_by_service— seed jobs for svc-a + svc-b; assertservice_id="svc-a"returns only svc-a's jobs.test_list_backup_jobs_unfiltered_returns_all— assertservice_id=Nonereturns all ANDservice_id=""returns all (empty-string treated as no filter).test_list_backup_runs_filtered_by_service— seed jobs (svc-a + svc-b) each with a run; assertservice_id="svc-a"returns only svc-a's runs (subquery works).test_list_backup_alerts_filtered_by_service— seed jobs + alerts; assertservice_id="svc-a"returns only svc-a's alerts (subquery works).test_list_backup_runs_unfiltered_returns_all— assertservice_id=Nonereturns all runs. (PI-111, PI-119)
- Files:
Backend: backup endpoint threading (PI-110)
-
1.5 Add
service_idquery param to backup endpoints- Files:
backend/src/media_library_viewer_api/routers/backups.py - Details: Add
service_id: str | None = Noneto the signatures ofget_backup_jobs,get_backup_runs,get_backup_alerts. Thread it into the correspondingstore.list_backup_*calls asservice_id=service_id. Do NOT add it toget_backup_dashboard(design §5 — excluded, widget path). (PI-110)
- Files:
-
1.6 Add backend test for endpoint threading
- Files:
backend/tests/test_backups.py - Details: Test that
GET /api/backups/jobs?service_id=svc-afilters correctly via the test client (asserts the param reaches the store). Keep it lightweight — the store-level tests (1.4) are the thorough ones. (PI-110, PI-119)
- Files:
Backend: verify Alertmanager/Prometheus (zero change — PI-112)
- 1.7 Confirm Alertmanager/Prometheus endpoints need no change
- Files:
backend/src/media_library_viewer_api/routers/monitoring.py(read-only check) - Details: Verify
get_alertmanager_alerts,get_alertmanager_status,get_prometheus_statusalready acceptservice_id: str | None = Noneand resolve viaresolve_service_record. No edit. If source confirms, mark done. (PI-112 — documented asymmetry, zero backend work)
- Files:
Frontend: API client functions (PI-108, PI-109)
-
1.8 Add
serviceIdparam to observability fetch functions- Files:
frontend/src/api/client.ts - Details:
fetchAlertmanagerAlerts,fetchAlertmanagerStatus,fetchPrometheusStatuseach gain(serviceId?: string). Use conditional spread with the existingget<T>(path, params?)helper:serviceId ? { service_id: serviceId } : undefined. No new URL helper. (PI-108, design §3.1 decision 2)
- Files:
-
1.9 Add
serviceIdparam to backup fetch functions (NOT Dashboard)- Files:
frontend/src/api/backups.ts - Details:
fetchBackupJobs,fetchBackupRuns(3rd arg),fetchBackupAlerts(4th arg) each gainserviceId?: string. Appendservice_idto the existing params object via conditional spread.fetchBackupRunsandfetchBackupAlertsalready build a params object — append to it. Do NOT modifyfetchBackupDashboard(design decision 5 — excluded, widget path). (PI-109, design §3.2)
- Files:
Frontend: hooks (PI-101..PI-107)
-
1.10 Add
serviceIdtouseObservabilityhooks- Files:
frontend/src/hooks/useObservability.ts - Details:
useAlertmanagerAlerts,useAlertmanagerStatus,usePrometheusStatuseach gain(serviceId?: string). IncludeserviceId ?? ""as the last element of thequeryKeytuple (design decision 1 — empty-string default for a single stable undefined key, separate key per instance). ChangequeryFnfrom a direct reference to() => fetchXxx(serviceId)(capture serviceId in closure). Do NOT modifyusePrometheusTargetsoruseMonitoringMachines(PI-107 — global by design). (PI-101, PI-102, PI-103, PI-107)
- Files:
-
1.11 Add
serviceIdtouseBackupshooks- Files:
frontend/src/hooks/useBackups.ts - Details:
useBackupJobsgains(serviceId?: string).useBackupRunsgains(jobId?, status?, serviceId?).useBackupAlertsgains(jobId?, acknowledged?, severity?, serviceId?). Each includesserviceId ?? ""inqueryKeyas the last element. ChangequeryFnto arrow function capturingserviceId. Do NOT modifyuseBackupDashboardoruseBackupJob(design §2.3 — dashboard path, single-ID fetch). (PI-104, PI-105, PI-106)
- Files:
Frontend: tabs pass instance.id (PI-113..PI-116)
-
1.12 Wire
instance.idinto AlertsTab- Files:
frontend/src/pages/service-tabs/AlertsTab.tsx - Details: Replace
void instance;+useAlertmanagerAlerts()withuseAlertmanagerAlerts(instance.id). Same foruseAlertmanagerStatus(instance.id). Remove the TODO comment / file-docstring note about hooks being global/first-configured. (PI-113)
- Files:
-
1.13 Wire
instance.idinto MetricsTab- Files:
frontend/src/pages/service-tabs/MetricsTab.tsx - Details: Replace
void instance;+usePrometheusStatus()withusePrometheusStatus(instance.id).usePrometheusTargets()stays unchanged (global — PI-107). Remove the TODO comment / file-docstring note. (PI-114, PI-107)
- Files:
-
1.14 Wire
instance.idinto JobsTab- Files:
frontend/src/pages/service-tabs/JobsTab.tsx - Details: Replace
void instance;+useBackupJobs()withuseBackupJobs(instance.id).useBackupRuns()→useBackupRuns(undefined, undefined, instance.id).useBackupAlerts(undefined, false)→useBackupAlerts(undefined, false, undefined, instance.id). Remove the TODO comment / file-docstring note. (PI-115)
- Files:
Frontend: tests (PI-121)
-
1.15 Add hook test for per-instance queryKey
- Files:
frontend/src/hooks/__tests__/useBackups.test.ts(new or existing) - Details: Assert that calling a representative hook (e.g.
useBackupJobs) with differentserviceIdvalues produces differentqueryKeys. Mock the query client to inspect keys, or usequeryClient.getQueryDatato verify isolation. Also assertserviceId=undefinedproduces a stable key. (PI-121, design §7.2)
- Files:
-
1.16 Add tab test for
instance.idwiring- Files:
frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx(or equivalent existing tab test) - Details: Assert that at least one tab (e.g. AlertsTab) passes its received
instance.idto the scoped hook. Cleanest approach:vi.spyOn(useObservability, "useAlertmanagerAlerts"), render the tab with a known instance, assert the spy was called withinstance.id. (PI-121, design §7.2)
- Files:
Integration verification
- 1.17 Verify Slice 1 (build + lint + test green)
- Commands:
cd backend && PYTHONPATH=src python3 -m pytest -q— all pass incl. newTestBackupServiceScoping(PI-119)cd backend && PYTHONPATH=src python3 -m ruff check src tests— cleancd frontend && npm run build— exit 0 (PI-120)cd frontend && npm run lint— 0 errors (PI-120)cd frontend && npx vitest run— all pass incl. new hook + tab tests (PI-121)
- Details: Confirm all existing tests stay green (PI-117 non-regression, PI-118 backward-compat). No widget test modified.
usePrometheusTargets/useMonitoringMachines/fetchBackupDashboardunchanged.
- Commands:
Risk flags
- (a) Subquery correctness for runs/alerts.
backup_runsandbackup_alertsdo NOT have aservice_idcolumn (schema asymmetry — design §0 source finding 2). The filter usesjob_id IN (SELECT id FROM backup_jobs WHERE service_id = ?). Get the SQL right — test it explicitly (1.4). A wrong JOIN/subquery shape would silently filter incorrectly or throw. - (b) Don't accidentally scope the dashboard widget path.
fetchBackupDashboard/useBackupDashboard/build_backup_dashboard_summarystay global (design decision 5). They feedBackupDashboardWidget(a dashboard component), NOTJobsTab. Scoping them would be a PI-117 regression risk for zero tab benefit. - (c) queryKey consistency. Every modified hook MUST include
serviceId ?? ""in itsqueryKey— a forgotten one causes silent cross-instance cache hits. Covered by task 1.15. - (d)
void instance;removal. Each tab currently hasvoid instance;to suppress the unused-var lint. Removing it + passinginstance.idremoves the need. Ensure no lint regression.