Correctness fix: observability + backup hooks query globally, so multi-instance service pages show data for the wrong instance. Tabs already accept instance prop with TODO comments; backend mostly supports service_id already. Scope: add serviceId to 6 hooks + fetch fns + 3 tabs; add service_id to backup endpoints. Backward-compatible (optional params). ~250-350 lines, single slice.
8.4 KiB
SDD Proposal: Per-Instance Hook Scoping
Change: per-instance-hook-scoping
Phase: proposal
Date: 2026-07-09
1. Problem / Why Now
This is a correctness fix, not a feature.
When a user has multiple instances of Alertmanager, Prometheus, or the backups service and opens a specific instance's service page, the Observability/backup tabs show data for whichever instance resolves first globally — not the instance whose page they're viewing. The root cause: the frontend hooks (useAlertmanagerAlerts, useAlertmanagerStatus, usePrometheusStatus, useBackupJobs/Runs/Alerts) query without a serviceId, and the consuming tabs (AlertsTab, MetricsTab, JobsTab) don't pass instance.id to them.
This was documented as a carry-over risk in the services-as-hub-ia verify-report (2026-06-26): "Hooks query globally, not per-instance... JobsTab, AlertsTab, MetricsTab show data for whichever instance the hook resolves as first-configured." The tabs were deliberately built to accept an instance prop and wait for the hooks to catch up — each tab carries an explicit TODO comment to that effect. The fix is now due.
2. Target Users and Situations
- Primary users: Operators with more than one Alertmanager, Prometheus, or backups instance (e.g., a staging + production pair, or per-tenant instances).
- Workflow moment: open
/{serviceType}/{instanceId}to inspect a specific instance → the tabs must reflect THAT instance, not a global/first-resolved one. - Urgency: Medium. Single-instance operators see no bug; multi-instance operators see silently-wrong data. The incorrect-behavior surface grows as more instances are added.
3. Product Outcome
After this change, an authenticated user viewing a specific service instance's page sees alerts, status, and backup data scoped to that instance only. Switching the instance switcher (already present on ServicePage when enabledSiblings.length > 1) re-scopes the data. The dashboard widgets (which resolve via widget-instance → service_id) are unaffected — they were already correct.
4. Scope Boundaries and Non-Goals
In scope
- Frontend hooks gain a
serviceIdparam:useAlertmanagerAlerts(serviceId),useAlertmanagerStatus(serviceId),usePrometheusStatus(serviceId),useBackupJobs(serviceId),useBackupRuns(jobId, status, serviceId),useBackupAlerts(jobId, acknowledged, severity, serviceId). Each thread it intoqueryKey(so caches are per-instance) and pass it to the fetch function. - Fetch functions pass
service_idas a query param: the existingfetch*functions inapi/client.tsandapi/backups.tsgain an optionalserviceIdargument appended to the request URL. - Tabs pass
instance.id:AlertsTab,MetricsTab,JobsTabcall the hooks with their receivedinstance.id. - Backend backup endpoints gain
service_id:get_backup_jobs,get_backup_runs,get_backup_alerts,get_backup_dashboardaccept an optionalservice_id: str | Nonequery param and thread it intoSettingsStorequery methods (which already have service attribution per theservices-as-hub-iaSlice 3 work). get_prometheus_targetsstays global — it returns Node Exporter scrape targets for external Prometheus instances viahttp_sd_configs, not instance-scoped UI data. Out of scope by design.
Non-goals (explicitly out of scope)
- Widget-level data scoping. Dashboard widgets (
PrometheusChartWidget,AlertmanagerAlertsWidget,BackupsWidget, etc.) already resolve their service via the widget instance'sservice_idbinding — they are correct and untouched. useMonitoringMachines. Machines are a global cross-service concept (not per-instance); stays as-is.- Per-user scoping. Scoping is by
service_idonly. - Prometheus scrape-target endpoint. Stays global (see above).
- Refactoring the hook return shapes or polling intervals. Only the
serviceIdinput is added; outputs and timing are unchanged. - Grafana. Removed by
prometheus-direct-charting; not present.
5. High-Level Approach
5.1 Backend (small)
routers/backups.py— addservice_id: str | None = Nonequery param toget_backup_jobs,get_backup_runs,get_backup_alerts,get_backup_dashboard; thread intoSettingsStorecalls.services/settings_store.py—list_backup_jobs,list_backup_runs,list_backup_alertsaccept an optionalservice_idfilter and add aWHEREclause when non-null. (Thebackup_jobs/backup_runstables already carryservice_idattribution from the earlier change; confirm and add the filter.)- The Alertmanager/Prometheus status endpoints already accept
service_id— no backend change needed there.
5.2 Frontend (most of the work)
api/client.ts+api/backups.ts— eachfetch*function gains an optionalserviceId?: stringarg; when provided, append?service_id=<id>(or&service_id=<id>if other params exist) to the URL.hooks/useObservability.ts+hooks/useBackups.ts— each hook gains aserviceId?: stringarg; include it in thequeryKey(e.g.,["observability", "alerts", serviceId ?? ""]) so caches don't collide across instances; pass it to the fetch function.pages/service-tabs/AlertsTab.tsx,MetricsTab.tsx,JobsTab.tsx— replace the TODO comments withuseXxx(instance.id).- Tests: hook tests cover the per-instance queryKey; tab tests cover passing
instance.id.
5.3 Backward compatibility
- All new params are optional (
serviceId?: string/service_id: str | None = None). When omitted, behavior is identical to today (first-configured/global). This means the existing dashboard widget callers (which don't pass serviceId because they resolve via widget-instance binding) and any external callers keep working unchanged.
6. Success Criteria / Acceptance Criteria
- The three tabs (
AlertsTab,MetricsTab,JobsTab) passinstance.idto their hooks; the TODO comments are gone. - With two instances of a service type configured, viewing instance A's page shows A's data and viewing instance B's page shows B's data (no cross-contamination).
- The instance switcher on
ServicePagere-scopes the tab data on switch. - Hook
queryKeys includeserviceIdso React Query caches are per-instance (no stale cross-instance cache hits). - Backend backup endpoints accept
service_idand filter correctly; omitting it returns all (backward-compat). - Dashboard widgets render identically before/after (they don't use these hooks — verify no regression).
- Backend tests (
pytest) and frontendnpm run build+npm run lint+npm run teststay green.
7. Risks and Mitigations
| Risk | Mitigation |
|---|---|
Cache-key collision if serviceId is forgotten in some hook. |
Mandatory: every modified hook includes serviceId in queryKey; covered by a hook test. |
Backup store filter regresses existing callers. list_backup_* with service_id=None must return everything. |
Add a backend test: service_id=None → all rows; service_id="X" → only X's rows. |
A fetch function drops the param into the URL incorrectly (encoding, ? vs &). |
Use URLSearchParams or the existing pattern; one small util if helpful. |
| Dashboard widget regression (they use different hooks, but defensive check). | SC6 explicitly verifies widgets render identically; widget tests unchanged. |
| Review budget (>400 lines). | Single slice, likely ~250–350 lines (frontend-dominant). If it creeps, split backend-filter from frontend-wiring. |
8. Resolved Questions (no question round needed — investigation settled these)
- Q1 — Which hooks?
useAlertmanagerAlerts,useAlertmanagerStatus,usePrometheusStatus,useBackupJobs,useBackupRuns,useBackupAlerts. NOTusePrometheusTargets(global scrape config) oruseMonitoringMachines(global). - Q2 — Backend readiness? Alertmanager/Prometheus status endpoints already take
service_id. Backup endpoints need it added (data already attributed). - Q3 — Backward-compat? All params optional; omitting = today's behavior.
9. Future Phases
- Per-instance scoping for
usePrometheusTargetsif it ever becomes a UI concern (today it's an external-scrape endpoint). - A shared
useServiceScopedQueryhelper if the per-instancequeryKeypattern repeats beyond these hooks. - Rich PromQL editor / threshold alerting (separate deferred items, not this change).