feat(per-instance-hook-scoping): scope observability + backup hooks by instance
This commit is contained in:
@@ -129,9 +129,10 @@ def post_backup_start(
|
|||||||
|
|
||||||
@router.get("/jobs")
|
@router.get("/jobs")
|
||||||
def get_backup_jobs(
|
def get_backup_jobs(
|
||||||
|
service_id: str | None = None,
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
jobs = store.list_backup_jobs()
|
jobs = store.list_backup_jobs(service_id=service_id)
|
||||||
return jobs
|
return jobs
|
||||||
|
|
||||||
|
|
||||||
@@ -155,9 +156,10 @@ def get_backup_runs(
|
|||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
service_id: str | None = None,
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> list[BackupRunResponse]:
|
) -> list[BackupRunResponse]:
|
||||||
runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit)
|
runs = store.list_backup_runs(job_id=job_id, status=status, limit=limit, service_id=service_id)
|
||||||
return [BackupRunResponse(**run) for run in runs]
|
return [BackupRunResponse(**run) for run in runs]
|
||||||
|
|
||||||
|
|
||||||
@@ -177,9 +179,12 @@ def get_backup_alerts(
|
|||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
acknowledged: bool | None = None,
|
acknowledged: bool | None = None,
|
||||||
severity: str | None = None,
|
severity: str | None = None,
|
||||||
|
service_id: str | None = None,
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> list[BackupAlertResponse]:
|
) -> list[BackupAlertResponse]:
|
||||||
alerts = store.list_backup_alerts(job_id=job_id, acknowledged=acknowledged, severity=severity)
|
alerts = store.list_backup_alerts(
|
||||||
|
job_id=job_id, acknowledged=acknowledged, severity=severity, service_id=service_id,
|
||||||
|
)
|
||||||
return [BackupAlertResponse(**alert) for alert in alerts]
|
return [BackupAlertResponse(**alert) for alert in alerts]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1075,10 +1075,16 @@ class SettingsStore:
|
|||||||
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
|
row = conn.execute("SELECT * FROM backup_jobs WHERE id = ?", (job_id,)).fetchone()
|
||||||
return self._row_to_job(row) if row else None
|
return self._row_to_job(row) if row else None
|
||||||
|
|
||||||
def list_backup_jobs(self) -> list[dict[str, Any]]:
|
def list_backup_jobs(self, service_id: str | None = None) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
|
where = ""
|
||||||
|
params: list[Any] = []
|
||||||
|
if service_id:
|
||||||
|
where = "WHERE service_id = ?"
|
||||||
|
params.append(service_id)
|
||||||
|
sql = f"SELECT * FROM backup_jobs {where} ORDER BY created_at DESC"
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
rows = conn.execute("SELECT * FROM backup_jobs ORDER BY created_at DESC").fetchall()
|
rows = conn.execute(sql, params).fetchall()
|
||||||
return [self._row_to_job(row) for row in rows]
|
return [self._row_to_job(row) for row in rows]
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -1167,6 +1173,7 @@ class SettingsStore:
|
|||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
|
service_id: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
clauses: list[str] = []
|
clauses: list[str] = []
|
||||||
@@ -1177,6 +1184,9 @@ class SettingsStore:
|
|||||||
if status:
|
if status:
|
||||||
clauses.append("status = ?")
|
clauses.append("status = ?")
|
||||||
params.append(status)
|
params.append(status)
|
||||||
|
if service_id:
|
||||||
|
clauses.append("job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)")
|
||||||
|
params.append(service_id)
|
||||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
sql = f"SELECT * FROM backup_runs {where} ORDER BY created_at DESC LIMIT ?"
|
sql = f"SELECT * FROM backup_runs {where} ORDER BY created_at DESC LIMIT ?"
|
||||||
params.append(max(1, min(int(limit), 200)))
|
params.append(max(1, min(int(limit), 200)))
|
||||||
@@ -1257,6 +1267,7 @@ class SettingsStore:
|
|||||||
job_id: str | None = None,
|
job_id: str | None = None,
|
||||||
acknowledged: bool | None = None,
|
acknowledged: bool | None = None,
|
||||||
severity: str | None = None,
|
severity: str | None = None,
|
||||||
|
service_id: str | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
clauses: list[str] = []
|
clauses: list[str] = []
|
||||||
@@ -1270,6 +1281,9 @@ class SettingsStore:
|
|||||||
if severity:
|
if severity:
|
||||||
clauses.append("severity = ?")
|
clauses.append("severity = ?")
|
||||||
params.append(severity)
|
params.append(severity)
|
||||||
|
if service_id:
|
||||||
|
clauses.append("job_id IN (SELECT id FROM backup_jobs WHERE service_id = ?)")
|
||||||
|
params.append(service_id)
|
||||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||||
sql = f"SELECT * FROM backup_alerts {where} ORDER BY created_at DESC"
|
sql = f"SELECT * FROM backup_alerts {where} ORDER BY created_at DESC"
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from media_library_viewer_api.main import app
|
from media_library_viewer_api.main import app
|
||||||
@@ -33,6 +34,88 @@ def test_dashboard_backups():
|
|||||||
auth_module._API_KEY = None
|
auth_module._API_KEY = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Per-instance service scoping (PI-110, PI-111, PI-119)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store(tmp_path):
|
||||||
|
"""Fresh SettingsStore with schema initialized."""
|
||||||
|
db_path = tmp_path / "test_settings.sqlite"
|
||||||
|
s = SettingsStore(db_path)
|
||||||
|
s.init_schema()
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackupServiceScoping:
|
||||||
|
"""Verify service_id filtering on list_backup_jobs/runs/alerts."""
|
||||||
|
|
||||||
|
def test_list_backup_jobs_filtered_by_service(self, store: SettingsStore):
|
||||||
|
store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
|
||||||
|
store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
|
assert len(store.list_backup_jobs(service_id="svc-a")) == 1
|
||||||
|
assert len(store.list_backup_jobs(service_id="svc-b")) == 1
|
||||||
|
|
||||||
|
def test_list_backup_jobs_unfiltered_returns_all(self, store: SettingsStore):
|
||||||
|
store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
|
||||||
|
store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
|
assert len(store.list_backup_jobs()) == 2
|
||||||
|
assert len(store.list_backup_jobs(service_id="")) == 2
|
||||||
|
|
||||||
|
def test_list_backup_runs_filtered_by_service(self, store: SettingsStore):
|
||||||
|
job_a = store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
|
||||||
|
job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
|
store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"})
|
||||||
|
store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"})
|
||||||
|
assert len(store.list_backup_runs(service_id="svc-a")) == 1
|
||||||
|
assert len(store.list_backup_runs(service_id="svc-b")) == 1
|
||||||
|
|
||||||
|
def test_list_backup_alerts_filtered_by_service(self, store: SettingsStore):
|
||||||
|
job_a = store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
|
||||||
|
job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
|
store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"})
|
||||||
|
store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"})
|
||||||
|
store.create_backup_alert(
|
||||||
|
{"job_id": job_a["id"], "alert_type": "test", "severity": "warning"}
|
||||||
|
)
|
||||||
|
store.create_backup_alert(
|
||||||
|
{"job_id": job_b["id"], "alert_type": "test", "severity": "warning"}
|
||||||
|
)
|
||||||
|
assert len(store.list_backup_alerts(service_id="svc-a")) == 1
|
||||||
|
assert len(store.list_backup_alerts(service_id="svc-b")) == 1
|
||||||
|
|
||||||
|
def test_list_backup_runs_unfiltered_returns_all(self, store: SettingsStore):
|
||||||
|
job_a = store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
|
||||||
|
job_b = store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
|
store.create_backup_run({"job_id": job_a["id"], "started_at": 1700000000, "status": "success"})
|
||||||
|
store.create_backup_run({"job_id": job_b["id"], "started_at": 1700000000, "status": "success"})
|
||||||
|
assert len(store.list_backup_runs()) == 2
|
||||||
|
|
||||||
|
def test_endpoint_threads_service_id_to_store(self, store: SettingsStore):
|
||||||
|
"""GET /api/backups/jobs?service_id=svc-a filters via the endpoint."""
|
||||||
|
import media_library_viewer_api.auth as auth_module
|
||||||
|
from media_library_viewer_api.services import settings_store
|
||||||
|
|
||||||
|
store.upsert_backup_job({"name": "job-a", "service_id": "svc-a"})
|
||||||
|
store.upsert_backup_job({"name": "job-b", "service_id": "svc-b"})
|
||||||
|
|
||||||
|
original_store = settings_store._store
|
||||||
|
settings_store._store = store
|
||||||
|
auth_module._API_KEY = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = TestClient(app)
|
||||||
|
resp = client.get("/api/backups/jobs", params={"service_id": "svc-a"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
assert data[0]["name"] == "job-a"
|
||||||
|
finally:
|
||||||
|
settings_store._store = original_store
|
||||||
|
auth_module._API_KEY = None
|
||||||
|
|
||||||
|
|
||||||
def test_post_backup_report():
|
def test_post_backup_report():
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
db_path = Path(tmpdir) / "test_settings.sqlite"
|
db_path = Path(tmpdir) / "test_settings.sqlite"
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import type {
|
|||||||
BackupRun,
|
BackupRun,
|
||||||
} from "../types/backups";
|
} from "../types/backups";
|
||||||
|
|
||||||
export async function fetchBackupJobs(): Promise<BackupJob[]> {
|
export async function fetchBackupJobs(serviceId?: string): Promise<BackupJob[]> {
|
||||||
return get<BackupJob[]>("/api/backups/jobs");
|
return get<BackupJob[]>(
|
||||||
|
"/api/backups/jobs",
|
||||||
|
serviceId ? { service_id: serviceId } : undefined,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchBackupJob(
|
export async function fetchBackupJob(
|
||||||
@@ -21,10 +24,12 @@ export async function fetchBackupJob(
|
|||||||
export async function fetchBackupRuns(
|
export async function fetchBackupRuns(
|
||||||
jobId?: string,
|
jobId?: string,
|
||||||
status?: string,
|
status?: string,
|
||||||
|
serviceId?: string,
|
||||||
): Promise<BackupRun[]> {
|
): Promise<BackupRun[]> {
|
||||||
return get<BackupRun[]>("/api/backups/runs", {
|
return get<BackupRun[]>("/api/backups/runs", {
|
||||||
...(jobId ? { job_id: jobId } : {}),
|
...(jobId ? { job_id: jobId } : {}),
|
||||||
...(status ? { status } : {}),
|
...(status ? { status } : {}),
|
||||||
|
...(serviceId ? { service_id: serviceId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +41,7 @@ export async function fetchBackupAlerts(
|
|||||||
jobId?: string,
|
jobId?: string,
|
||||||
acknowledged?: boolean,
|
acknowledged?: boolean,
|
||||||
severity?: string,
|
severity?: string,
|
||||||
|
serviceId?: string,
|
||||||
): Promise<BackupAlert[]> {
|
): Promise<BackupAlert[]> {
|
||||||
return get<BackupAlert[]>("/api/backups/alerts", {
|
return get<BackupAlert[]>("/api/backups/alerts", {
|
||||||
...(jobId ? { job_id: jobId } : {}),
|
...(jobId ? { job_id: jobId } : {}),
|
||||||
@@ -43,6 +49,7 @@ export async function fetchBackupAlerts(
|
|||||||
? { acknowledged: String(acknowledged) }
|
? { acknowledged: String(acknowledged) }
|
||||||
: {}),
|
: {}),
|
||||||
...(severity ? { severity } : {}),
|
...(severity ? { severity } : {}),
|
||||||
|
...(serviceId ? { service_id: serviceId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -292,14 +292,23 @@ export const sendUserMessage = (formData: FormData) =>
|
|||||||
postForm<UserMessageResponse>("/api/users/message", formData);
|
postForm<UserMessageResponse>("/api/users/message", formData);
|
||||||
|
|
||||||
// Observability summary endpoints
|
// Observability summary endpoints
|
||||||
export const fetchAlertmanagerAlerts = () =>
|
export const fetchAlertmanagerAlerts = (serviceId?: string) =>
|
||||||
get<AlertmanagerAlertSummary>("/api/monitoring/alerts");
|
get<AlertmanagerAlertSummary>(
|
||||||
|
"/api/monitoring/alerts",
|
||||||
|
serviceId ? { service_id: serviceId } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
export const fetchAlertmanagerStatus = () =>
|
export const fetchAlertmanagerStatus = (serviceId?: string) =>
|
||||||
get<AlertmanagerStatus>("/api/monitoring/alertmanager-status");
|
get<AlertmanagerStatus>(
|
||||||
|
"/api/monitoring/alertmanager-status",
|
||||||
|
serviceId ? { service_id: serviceId } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
export const fetchPrometheusStatus = () =>
|
export const fetchPrometheusStatus = (serviceId?: string) =>
|
||||||
get<PrometheusStatus>("/api/monitoring/prometheus-status");
|
get<PrometheusStatus>(
|
||||||
|
"/api/monitoring/prometheus-status",
|
||||||
|
serviceId ? { service_id: serviceId } : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
export const fetchPrometheusTargets = () =>
|
export const fetchPrometheusTargets = () =>
|
||||||
get<PrometheusTarget[]>("/api/monitoring/prometheus-targets");
|
get<PrometheusTarget[]>("/api/monitoring/prometheus-targets");
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { createElement, type ReactNode } from "react";
|
||||||
|
import { useBackupJobs, useBackupRuns, useBackupAlerts } from "../useBackups";
|
||||||
|
import { useAlertmanagerAlerts, usePrometheusStatus } from "../useObservability";
|
||||||
|
|
||||||
|
vi.mock("../../api/client", () => ({
|
||||||
|
fetchAlertmanagerAlerts: vi.fn(),
|
||||||
|
fetchAlertmanagerStatus: vi.fn(),
|
||||||
|
fetchPrometheusStatus: vi.fn(),
|
||||||
|
fetchPrometheusTargets: vi.fn(),
|
||||||
|
fetchMonitoringMachines: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock("../../api/backups", () => ({
|
||||||
|
fetchBackupJobs: vi.fn(),
|
||||||
|
fetchBackupRuns: vi.fn(),
|
||||||
|
fetchBackupAlerts: vi.fn(),
|
||||||
|
fetchBackupDashboard: vi.fn(),
|
||||||
|
fetchBackupJob: vi.fn(),
|
||||||
|
acknowledgeBackupAlert: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
});
|
||||||
|
return ({ children }: { children: ReactNode }) =>
|
||||||
|
createElement(QueryClientProvider, { client: queryClient }, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("per-instance hook queryKey isolation", () => {
|
||||||
|
it("useBackupJobs produces different keys for different serviceIds", () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const { result: a } = renderHook(() => useBackupJobs("svc-a"), { wrapper });
|
||||||
|
const { result: b } = renderHook(() => useBackupJobs("svc-b"), { wrapper });
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
expect(b).toBeDefined();
|
||||||
|
// Different serviceId → different query → different cache slot
|
||||||
|
expect(a).not.toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useBackupJobs with undefined serviceId is stable (same key)", () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const { result: a } = renderHook(() => useBackupJobs(), { wrapper });
|
||||||
|
const { result: b } = renderHook(() => useBackupJobs(), { wrapper });
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
expect(b).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useBackupRuns includes serviceId in queryKey", () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const { result: a } = renderHook(
|
||||||
|
() => useBackupRuns(undefined, undefined, "svc-a"),
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useBackupAlerts includes serviceId in queryKey", () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const { result: a } = renderHook(
|
||||||
|
() => useBackupAlerts(undefined, false, undefined, "svc-a"),
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("useAlertmanagerAlerts includes serviceId in queryKey", () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const { result: a } = renderHook(
|
||||||
|
() => useAlertmanagerAlerts("svc-a"),
|
||||||
|
{ wrapper },
|
||||||
|
);
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("usePrometheusStatus includes serviceId in queryKey", () => {
|
||||||
|
const wrapper = createWrapper();
|
||||||
|
const { result: a } = renderHook(() => usePrometheusStatus("svc-a"), {
|
||||||
|
wrapper,
|
||||||
|
});
|
||||||
|
expect(a).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,10 +8,10 @@ import {
|
|||||||
fetchBackupRuns,
|
fetchBackupRuns,
|
||||||
} from "../api/backups";
|
} from "../api/backups";
|
||||||
|
|
||||||
export function useBackupJobs() {
|
export function useBackupJobs(serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["backups", "jobs"],
|
queryKey: ["backups", "jobs", serviceId ?? ""],
|
||||||
queryFn: fetchBackupJobs,
|
queryFn: () => fetchBackupJobs(serviceId),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -24,18 +24,18 @@ export function useBackupJob(jobId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackupRuns(jobId?: string, status?: string) {
|
export function useBackupRuns(jobId?: string, status?: string, serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["backups", "runs", jobId, status],
|
queryKey: ["backups", "runs", jobId, status, serviceId ?? ""],
|
||||||
queryFn: () => fetchBackupRuns(jobId, status),
|
queryFn: () => fetchBackupRuns(jobId, status, serviceId),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackupAlerts(jobId?: string, acknowledged?: boolean, severity?: string) {
|
export function useBackupAlerts(jobId?: string, acknowledged?: boolean, severity?: string, serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["backups", "alerts", jobId, acknowledged, severity],
|
queryKey: ["backups", "alerts", jobId, acknowledged, severity, serviceId ?? ""],
|
||||||
queryFn: () => fetchBackupAlerts(jobId, acknowledged, severity),
|
queryFn: () => fetchBackupAlerts(jobId, acknowledged, severity, serviceId),
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,30 +7,30 @@ import {
|
|||||||
fetchMonitoringMachines,
|
fetchMonitoringMachines,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
|
||||||
export function useAlertmanagerAlerts() {
|
export function useAlertmanagerAlerts(serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["observability", "alerts"],
|
queryKey: ["observability", "alerts", serviceId ?? ""],
|
||||||
queryFn: fetchAlertmanagerAlerts,
|
queryFn: () => fetchAlertmanagerAlerts(serviceId),
|
||||||
retry: 2,
|
retry: 2,
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAlertmanagerStatus() {
|
export function useAlertmanagerStatus(serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["observability", "alertmanager-status"],
|
queryKey: ["observability", "alertmanager-status", serviceId ?? ""],
|
||||||
queryFn: fetchAlertmanagerStatus,
|
queryFn: () => fetchAlertmanagerStatus(serviceId),
|
||||||
retry: 2,
|
retry: 2,
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePrometheusStatus() {
|
export function usePrometheusStatus(serviceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["observability", "prometheus-status"],
|
queryKey: ["observability", "prometheus-status", serviceId ?? ""],
|
||||||
queryFn: fetchPrometheusStatus,
|
queryFn: () => fetchPrometheusStatus(serviceId),
|
||||||
retry: 2,
|
retry: 2,
|
||||||
staleTime: 10_000,
|
staleTime: 10_000,
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Alertmanager Alerts tab (spec R2.4, R8.2).
|
* Alertmanager Alerts tab (spec R2.4, R8.2).
|
||||||
*
|
*
|
||||||
* Lifts the Alertmanager alerts content from the old cross-service
|
* Instance-scoped tab rendering the active-alert summary (total + by severity)
|
||||||
* ObservabilityPage into an instance-scoped tab. Renders the active-alert
|
* and the expandable alert list. Hooks are scoped by instance.id so
|
||||||
* summary (total + by severity) and the expandable alert list.
|
* multi-instance setups show data for the selected Alertmanager only.
|
||||||
*
|
|
||||||
* The hooks (useAlertmanagerAlerts, useAlertmanagerStatus) are global /
|
|
||||||
* first-configured for now — they don't accept a service_id yet. Wiring
|
|
||||||
* `instance.id` into them is a documented follow-up once the hooks gain the
|
|
||||||
* parameter. The `instance` prop is accepted for future scoping.
|
|
||||||
*/
|
*/
|
||||||
import { AlertTriangle, Bell, ChevronDown, Inbox } from "lucide-react";
|
import { AlertTriangle, Bell, ChevronDown, Inbox } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -110,16 +105,12 @@ function AlertItem({ alert }: { alert: AlertmanagerAlert }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AlertsTab({ instance }: { instance: ServiceInstance }) {
|
export function AlertsTab({ instance }: { instance: ServiceInstance }) {
|
||||||
// Global / first-configured hooks for now; instance.id scoping is a
|
|
||||||
// follow-up (see file docstring).
|
|
||||||
void instance;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: alertsSummary,
|
data: alertsSummary,
|
||||||
isLoading: alertsLoading,
|
isLoading: alertsLoading,
|
||||||
error: alertsError,
|
error: alertsError,
|
||||||
} = useAlertmanagerAlerts();
|
} = useAlertmanagerAlerts(instance.id);
|
||||||
const { data: status, isLoading: statusLoading } = useAlertmanagerStatus();
|
const { data: status, isLoading: statusLoading } = useAlertmanagerStatus(instance.id);
|
||||||
|
|
||||||
const statusDetail = status?.up
|
const statusDetail = status?.up
|
||||||
? status.version
|
? status.version
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* JobsTab — operational content for the backups service page.
|
* JobsTab — operational content for the backups service page.
|
||||||
*
|
*
|
||||||
* Lifted from the old top-level `components/BackupsPage.tsx`. The three
|
* Instance-scoped: hooks filter by instance.id so multi-instance setups
|
||||||
* sub-tables (Jobs / Runs / Alerts) and their hooks are preserved verbatim.
|
* show only the selected backups service's jobs, runs, and alerts.
|
||||||
*
|
|
||||||
* NOTE: the backup hooks currently query globally (no service_id filter).
|
|
||||||
* The backend gained `service_id` attribution in Slice 3, but the hooks don't
|
|
||||||
* yet accept a serviceId param. This tab shows ALL backups data for now;
|
|
||||||
* per-instance scoping by `instance.id` is a follow-up once the hooks gain the
|
|
||||||
* parameter.
|
|
||||||
*/
|
*/
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
@@ -24,15 +18,18 @@ import BackupRunsTable from "../../components/BackupRunsTable";
|
|||||||
import type { ServiceInstance } from "../../types";
|
import type { ServiceInstance } from "../../types";
|
||||||
|
|
||||||
export function JobsTab({ instance }: { instance: ServiceInstance }) {
|
export function JobsTab({ instance }: { instance: ServiceInstance }) {
|
||||||
// instance.id is not yet used — backup hooks query globally (see file
|
|
||||||
// docstring). Per-instance scoping is a follow-up.
|
|
||||||
void instance;
|
|
||||||
const [tab, setTab] = useState("jobs");
|
const [tab, setTab] = useState("jobs");
|
||||||
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
|
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs(instance.id);
|
||||||
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
|
const { data: runsData, isLoading: runsLoading } = useBackupRuns(
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
instance.id,
|
||||||
|
);
|
||||||
const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(
|
const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(
|
||||||
undefined,
|
undefined,
|
||||||
false,
|
false,
|
||||||
|
undefined,
|
||||||
|
instance.id,
|
||||||
);
|
);
|
||||||
const acknowledgeMutation = useAcknowledgeAlert();
|
const acknowledgeMutation = useAcknowledgeAlert();
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Prometheus Metrics tab (spec R2.4, R8.2).
|
* Prometheus Metrics tab (spec R2.4, R8.2).
|
||||||
*
|
*
|
||||||
* Lifts the Prometheus status + targets content from the old cross-service
|
* Instance-scoped tab showing Prometheus service health.
|
||||||
* ObservabilityPage into an instance-scoped tab. Shows service health and
|
* usePrometheusStatus is scoped by instance.id; usePrometheusTargets
|
||||||
* the Node Exporter scrape-targets list.
|
* stays global (returns Node Exporter scrape targets for external Prom).
|
||||||
*
|
|
||||||
* The hooks (usePrometheusStatus, usePrometheusTargets) are global /
|
|
||||||
* first-configured for now. Wiring `instance.id` is a follow-up.
|
|
||||||
*/
|
*/
|
||||||
import { Radio } from "lucide-react";
|
import { Radio } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -41,15 +38,11 @@ function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MetricsTab({ instance }: { instance: ServiceInstance }) {
|
export function MetricsTab({ instance }: { instance: ServiceInstance }) {
|
||||||
// Global / first-configured hooks for now; instance.id scoping is a
|
|
||||||
// follow-up (see file docstring).
|
|
||||||
void instance;
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: status,
|
data: status,
|
||||||
isLoading: statusLoading,
|
isLoading: statusLoading,
|
||||||
error: statusError,
|
error: statusError,
|
||||||
} = usePrometheusStatus();
|
} = usePrometheusStatus(instance.id);
|
||||||
const {
|
const {
|
||||||
data: targets,
|
data: targets,
|
||||||
isLoading: targetsLoading,
|
isLoading: targetsLoading,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import * as useObservability from "../../../hooks/useObservability";
|
||||||
import { AlertsTab } from "../AlertsTab";
|
import { AlertsTab } from "../AlertsTab";
|
||||||
import type { ServiceInstance } from "../../../types";
|
import type { ServiceInstance } from "../../../types";
|
||||||
|
|
||||||
@@ -15,7 +16,7 @@ const instance: ServiceInstance = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("../../../hooks/useObservability", () => ({
|
vi.mock("../../../hooks/useObservability", () => ({
|
||||||
useAlertmanagerAlerts: () => ({
|
useAlertmanagerAlerts: vi.fn(() => ({
|
||||||
data: {
|
data: {
|
||||||
total: 2,
|
total: 2,
|
||||||
by_severity: { critical: 1, warning: 1 },
|
by_severity: { critical: 1, warning: 1 },
|
||||||
@@ -46,15 +47,25 @@ vi.mock("../../../hooks/useObservability", () => ({
|
|||||||
},
|
},
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
}),
|
})),
|
||||||
useAlertmanagerStatus: () => ({
|
useAlertmanagerStatus: vi.fn(() => ({
|
||||||
data: { up: true, version: "0.27.0", uptime: "", name: "", peers: [] },
|
data: { up: true, version: "0.27.0", uptime: "", name: "", peers: [] },
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
error: null,
|
||||||
}),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe("AlertsTab", () => {
|
describe("AlertsTab", () => {
|
||||||
|
it("passes instance.id to scoped hooks", () => {
|
||||||
|
render(<AlertsTab instance={instance} />);
|
||||||
|
expect(vi.mocked(useObservability.useAlertmanagerAlerts)).toHaveBeenCalledWith(
|
||||||
|
"am-1",
|
||||||
|
);
|
||||||
|
expect(vi.mocked(useObservability.useAlertmanagerStatus)).toHaveBeenCalledWith(
|
||||||
|
"am-1",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the alert count and alert names", () => {
|
it("renders the alert count and alert names", () => {
|
||||||
render(<AlertsTab instance={instance} />);
|
render(<AlertsTab instance={instance} />);
|
||||||
expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument();
|
expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument();
|
||||||
|
|||||||
Reference in New Issue
Block a user