Files
manage/openspec/changes/archive/2026-07-09-per-instance-hook-scoping/spec.md
T
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

16 KiB
Raw Blame History

SDD Spec: Per-Instance Hook Scoping

Change: per-instance-hook-scoping Phase: spec Date: 2026-07-09

This is a correctness fix, not a feature. When multiple instances of Alertmanager, Prometheus, or the backups service are configured, the service-type tabs (AlertsTab, MetricsTab, JobsTab) show data for whichever instance resolves first globally — not the instance whose page the operator is viewing. This spec defines the acceptance requirements to scope the observability and backup hooks to the instance the operator selected. Each requirement is testable. Requirements derived from proposal.md §6 (success criteria) and the resolved §8 (no question round needed).

Requirement categories

  1. Frontend hooks (serviceId param + queryKey)
  2. API client functions (serviceId → query param)
  3. Backend backup endpoints (service_id filter)
  4. Tabs pass instance.id (TODO comments removed)
  5. Non-regression (dashboard widgets, global hooks untouched)
  6. Backward compatibility
  7. Test + build greenness

1. Frontend hooks (serviceId param + queryKey)

Requirement: PI-101 — useAlertmanagerAlerts accepts serviceId

The hook useAlertmanagerAlerts MUST accept an optional serviceId?: string argument. When provided, the hook MUST (a) include the serviceId in its TanStack Query queryKey (so caches do not collide across instances), and (b) pass the serviceId to its underlying fetch function. When omitted, the hook MUST behave identically to today (global/first-configured resolution).

Scenario: per-instance cache key

  • GIVEN two configured Alertmanager instances A and B
  • WHEN useAlertmanagerAlerts("A") and useAlertmanagerAlerts("B") are both mounted
  • THEN their queryKeys MUST differ (the serviceId appears in the key)
  • AND a cache hit for A MUST NOT be served for B (no stale cross-instance data)

Scenario: omitted serviceId is backward-compatible

  • GIVEN a caller that does not pass serviceId
  • WHEN the hook runs
  • THEN its behavior MUST match the pre-change hook (queryKey shape equivalent, fetch path identical)

Requirement: PI-102 — useAlertmanagerStatus accepts serviceId

The hook useAlertmanagerStatus MUST accept an optional serviceId?: string, include it in its queryKey, and pass it to its fetch function. Omitting it MUST preserve today's behavior.

Scenario: per-instance cache key

  • GIVEN instances A and B
  • WHEN useAlertmanagerStatus("A") and useAlertmanagerStatus("B") are mounted
  • THEN the queryKeys MUST differ and a cache hit for A MUST NOT serve B

Requirement: PI-103 — usePrometheusStatus accepts serviceId

The hook usePrometheusStatus MUST accept an optional serviceId?: string, include it in its queryKey, and pass it to its fetch function. Omitting it MUST preserve today's behavior.

Scenario: per-instance cache key

  • GIVEN instances A and B
  • WHEN usePrometheusStatus("A") and usePrometheusStatus("B") are mounted
  • THEN the queryKeys MUST differ and a cache hit for A MUST NOT serve B

Requirement: PI-104 — useBackupJobs accepts serviceId

The hook useBackupJobs MUST accept an optional serviceId?: string, include it in its queryKey, and pass it to its fetch function. Omitting it MUST preserve today's behavior.

Scenario: per-instance cache key

  • GIVEN backup services A and B
  • WHEN useBackupJobs("A") and useBackupJobs("B") are mounted
  • THEN the queryKeys MUST differ and a cache hit for A MUST NOT serve B

Requirement: PI-105 — useBackupRuns accepts serviceId

The hook useBackupRuns(jobId, status, serviceId?) MUST accept an optional serviceId?: string in addition to its existing jobId and status arguments, include it in its queryKey, and pass it to its fetch function. Omitting it MUST preserve today's behavior.

Scenario: per-instance cache key

  • GIVEN backup services A and B
  • WHEN useBackupRuns("job1", "all", "A") and useBackupRuns("job1", "all", "B") are mounted
  • THEN the queryKeys MUST differ

Requirement: PI-106 — useBackupAlerts accepts serviceId

The hook useBackupAlerts(jobId, acknowledged, severity, serviceId?) MUST accept an optional serviceId?: string in addition to its existing arguments, include it in its queryKey, and pass it to its fetch function. Omitting it MUST preserve today's behavior.

Scenario: per-instance cache key

  • GIVEN backup services A and B
  • WHEN useBackupAlerts("job1", false, "all", "A") and useBackupAlerts("job1", false, "all", "B") are mounted
  • THEN the queryKeys MUST differ

Requirement: PI-107 — usePrometheusTargets and useMonitoringMachines stay global

The hooks usePrometheusTargets and useMonitoringMachines MUST NOT gain a serviceId parameter.

  • usePrometheusTargets returns Node Exporter scrape targets for external Prometheus instances via http_sd_configs (cross-instance by design).
  • useMonitoringMachines is a global cross-service concept (not per-instance).

Scenario: global hooks unchanged

  • GIVEN the change is applied
  • WHEN usePrometheusTargets() and useMonitoringMachines() signatures are inspected
  • THEN neither accepts a serviceId arg and both behave identically to before

2. API client functions (serviceId → query param)

Requirement: PI-108 — Observability fetch functions pass service_id query param

The fetch functions fetchAlertmanagerAlerts, fetchAlertmanagerStatus, and fetchPrometheusStatus (in api/client.ts) MUST each accept an optional serviceId?: string. When provided, the function MUST append service_id=<id> to the request URL as a query parameter (correctly using ? for the first param and & for subsequent ones). When omitted, the request MUST be identical to today (no service_id param sent).

Scenario: serviceId appended

  • GIVEN fetchPrometheusStatus("svc-abc") is called
  • WHEN the request is issued
  • THEN the URL contains service_id=svc-abc

Scenario: omitted serviceId is a no-op

  • GIVEN fetchPrometheusStatus() is called (no serviceId)
  • WHEN the request is issued
  • THEN the URL MUST NOT contain service_id

Requirement: PI-109 — Backup fetch functions pass service_id query param

The fetch functions fetchBackupJobs, fetchBackupRuns, fetchBackupAlerts, and fetchBackupDashboard (in api/backups.ts) MUST each accept an optional serviceId?: string. When provided, the function MUST append service_id=<id> to the request URL. When omitted, the request MUST be identical to today.

Scenario: serviceId appended alongside existing params

  • GIVEN fetchBackupRuns("job1", "failed", "svc-xyz") is called
  • WHEN the request is issued
  • THEN the URL contains both job_id=job1&status=failed (existing params) AND service_id=svc-xyz

Scenario: omitted serviceId is a no-op

  • GIVEN fetchBackupJobs() is called (no serviceId)
  • WHEN the request is issued
  • THEN the URL MUST NOT contain service_id

3. Backend backup endpoints (service_id filter)

Requirement: PI-110 — Backup endpoints accept optional service_id

The endpoints get_backup_jobs, get_backup_runs, get_backup_alerts, and get_backup_dashboard in routers/backups.py MUST each accept an optional service_id: str | None = None query parameter and thread it into the corresponding SettingsStore query method.

Scenario: service_id passed through to store

  • GIVEN a request GET /api/backups/jobs?service_id=svc-xyz
  • WHEN the endpoint runs
  • THEN store.list_backup_jobs MUST be called with service_id="svc-xyz"

Requirement: PI-111 — SettingsStore backup queries filter by service_id when provided

The methods list_backup_jobs, list_backup_runs, and list_backup_alerts in services/settings_store.py MUST each accept an optional service_id: str | None parameter. When service_id is a non-null, non-empty string, the query MUST include a WHERE service_id = ? clause (or equivalent scoping) returning only rows attributed to that service. When service_id is None or empty, the query MUST return ALL rows (no filter) — preserving backward-compatibility for existing callers.

The backup_jobs and backup_runs tables already carry service_id attribution (from the services-as-hub-ia Slice 3 work); this requirement adds the filter, not the column.

Scenario: filtered to one service

  • GIVEN the store holds jobs for services A and B
  • WHEN list_backup_jobs(service_id="A") is called
  • THEN only service A's jobs MUST be returned

Scenario: no filter returns all

  • GIVEN the store holds jobs for services A and B
  • WHEN list_backup_jobs(service_id=None) is called
  • THEN jobs for BOTH services MUST be returned (backward-compat)

Scenario: empty string is treated as no filter

  • GIVEN the store holds jobs for services A and B
  • WHEN list_backup_jobs(service_id="") is called
  • THEN jobs for BOTH services MUST be returned (defensive against falsy strings)

Requirement: PI-112 — Alertmanager/Prometheus status endpoints already accept service_id

The endpoints get_alertmanager_alerts, get_alertmanager_status, and get_prometheus_status in routers/monitoring.py ALREADY accept a service_id: str | None = None query parameter and resolve via resolve_service_record(store, "<type>", service_id). No backend change is required for these. This requirement exists to document the asymmetry: the frontend wiring change for these hooks is necessary (PI-108 + PI-101..103), but the backend endpoint work is zero.

Scenario: existing endpoint behavior confirmed

  • GIVEN the change is applied
  • WHEN get_alertmanager_alerts(service_id="svc-a") is called
  • THEN it MUST resolve to the svc-a Alertmanager instance (pre-existing behavior, unchanged)

4. Tabs pass instance.id (TODO comments removed)

Requirement: PI-113 — AlertsTab passes instance.id

The component pages/service-tabs/AlertsTab.tsx MUST call useAlertmanagerAlerts(instance.id) and useAlertmanagerStatus(instance.id) (passing the instance.id it already receives as a prop). The existing TODO comment about "hooks don't accept a service_id yet" MUST be removed.

Scenario: data is instance-scoped

  • GIVEN two Alertmanager instances A and B are configured
  • WHEN the operator navigates to instance A's service page
  • THEN AlertsTab MUST show alerts sourced from instance A (not B, not first-resolved)

Requirement: PI-114 — MetricsTab passes instance.id

The component pages/service-tabs/MetricsTab.tsx MUST call usePrometheusStatus(instance.id) (passing the instance.id it already receives as a prop). The existing TODO comment MUST be removed. (usePrometheusTargets stays global — PI-107.)

Scenario: data is instance-scoped

  • GIVEN two Prometheus instances A and B are configured
  • WHEN the operator navigates to instance A's service page
  • THEN MetricsTab MUST show status for instance A (not B, not first-resolved)

Requirement: PI-115 — JobsTab passes instance.id

The component pages/service-tabs/JobsTab.tsx MUST call useBackupJobs(instance.id), useBackupRuns(<jobId>, <status>, instance.id), and useBackupAlerts(<jobId>, <acknowledged>, <severity>, instance.id). The existing TODO comment MUST be removed.

Scenario: data is instance-scoped

  • GIVEN two backups service instances A and B are configured
  • WHEN the operator navigates to instance A's service page
  • THEN JobsTab MUST show jobs/runs/alerts for service A only

Requirement: PI-116 — Instance switcher re-scopes tab data

The ServicePage instance switcher (present when enabledSiblings.length > 1) changes the active instance. When the operator switches from instance A to instance B, the three tabs MUST re-fetch and re-render with instance B's data. (This falls out naturally from passing instance.id into the hook queryKeys — switching the prop changes the key, triggering a fresh fetch — but it is verified explicitly because it's the user-visible correctness goal.)

Scenario: switch re-scopes

  • GIVEN the operator is viewing instance A's MetricsTab (data from A)
  • WHEN the operator uses the instance switcher to select instance B
  • THEN the tab MUST re-render with instance B's status (not A's cached data)

5. Non-regression (dashboard widgets, global hooks untouched)

Requirement: PI-117 — Dashboard widgets render identically before/after

Dashboard widgets (AlertmanagerAlertsWidget, BackupsWidget, PrometheusMetricWidget/Metric*, etc.) resolve their service via the widget-instance's service_id binding through useWidgetData — a different code path from the hooks changed here. They MUST render identically before and after this change. The widget component tests MUST remain unchanged and green.

Scenario: widget path unaffected

  • GIVEN the change is applied
  • WHEN a dashboard widget fetches its data
  • THEN the fetch path (useWidgetData(widget.id, …)) MUST be unchanged; no useAlertmanager* / useBackup* hook is on the widget data path

6. Backward compatibility

Requirement: PI-118 — All new params are optional; omitting equals today's behavior

Every new parameter added by this change (hook serviceId?: string, fetch-function serviceId?: string, endpoint service_id: str | None = None, store-method service_id: str | None = None) MUST be optional with a default that preserves today's behavior. No existing caller is forced to pass the new argument. Existing callers that omit it MUST see identical results to the pre-change code.

Scenario: existing callers unbroken

  • GIVEN any pre-existing caller of useBackupJobs(), fetchBackupRuns(), GET /api/backups/jobs, or store.list_backup_jobs()
  • WHEN the change is applied and the caller is run without modification
  • THEN the behavior MUST be identical to before (no error, same data returned)

7. Test + build greenness

Requirement: PI-119 — Backend tests + lint green

PYTHONPATH=src python3 -m pytest -q and PYTHONPATH=src python3 -m ruff check src tests from backend/ MUST pass. New backend tests MUST cover: list_backup_* filtering by service_id (filtered, unfiltered, empty-string); backup endpoint threading service_id into the store.

Scenario: backup filter tests exist

  • GIVEN the change is applied
  • WHEN pytest tests/test_backups.py (or equivalent) runs
  • THEN it MUST include a test asserting service_id="A" returns only A's rows AND service_id=None returns all rows

Requirement: PI-120 — Frontend typechecks, builds, and lints

npm run build (which runs tsc -b + vite build) and npm run lint from frontend/ MUST pass (0 errors; pre-existing warnings are acceptable).

Requirement: PI-121 — Hook + tab tests cover the new serviceId wiring

The frontend test suite MUST include:

  • A hook test asserting the serviceId appears in the queryKey (per-instance cache isolation) for at least one representative hook (e.g. useBackupJobs).
  • A tab test asserting instance.id is passed through to the hook (for at least one of AlertsTab / MetricsTab / JobsTab).

Scenario: per-instance queryKey test exists

  • GIVEN the change is applied
  • WHEN the test suite runs
  • THEN a test MUST assert that calling the hook with different serviceId values produces different queryKeys

Scenario: tab passes instance.id test exists

  • GIVEN the change is applied
  • WHEN the test suite runs
  • THEN a test MUST assert that at least one tab component passes its received instance.id to the scoped hook

Notes for downstream phases

  • Design (next phase) should settle: the exact queryKey shape convention (e.g. ["backups", "jobs", serviceId ?? ""] vs. appending the id only when present), whether a small shared helper for appendServiceIdParam(url, serviceId) is worth extracting in api/shared.ts, and whether fetchBackupDashboard (which feeds the dashboard widget via a different hook path) is in scope or deliberately excluded.
  • Tasks should be a single slice (~250350 lines, frontend-dominant) per the proposal; if the backend SettingsStore filter changes touch more than expected, split backend-filter from frontend-wiring.
  • Verify should confirm PI-101..PI-121 against source; the load-bearing items are PI-111 (store filter correctness), PI-113..115 (tabs actually pass instance.id), and PI-117 (widget non-regression).
  • This change is independent of the qBittorrent widgets, the storage harness, MediaIndex, the credential tester, and the Grafana gateway — none are constrained by or dependent on it.