feat(per-instance-hook-scoping): scope observability + backup hooks by instance

This commit is contained in:
Developer
2026-07-09 23:54:31 +00:00
parent ad61d92b32
commit 3bc7ce5269
12 changed files with 268 additions and 73 deletions
+9 -2
View File
@@ -6,8 +6,11 @@ import type {
BackupRun,
} from "../types/backups";
export async function fetchBackupJobs(): Promise<BackupJob[]> {
return get<BackupJob[]>("/api/backups/jobs");
export async function fetchBackupJobs(serviceId?: string): Promise<BackupJob[]> {
return get<BackupJob[]>(
"/api/backups/jobs",
serviceId ? { service_id: serviceId } : undefined,
);
}
export async function fetchBackupJob(
@@ -21,10 +24,12 @@ export async function fetchBackupJob(
export async function fetchBackupRuns(
jobId?: string,
status?: string,
serviceId?: string,
): Promise<BackupRun[]> {
return get<BackupRun[]>("/api/backups/runs", {
...(jobId ? { job_id: jobId } : {}),
...(status ? { status } : {}),
...(serviceId ? { service_id: serviceId } : {}),
});
}
@@ -36,6 +41,7 @@ export async function fetchBackupAlerts(
jobId?: string,
acknowledged?: boolean,
severity?: string,
serviceId?: string,
): Promise<BackupAlert[]> {
return get<BackupAlert[]>("/api/backups/alerts", {
...(jobId ? { job_id: jobId } : {}),
@@ -43,6 +49,7 @@ export async function fetchBackupAlerts(
? { acknowledged: String(acknowledged) }
: {}),
...(severity ? { severity } : {}),
...(serviceId ? { service_id: serviceId } : {}),
});
}
+15 -6
View File
@@ -292,14 +292,23 @@ export const sendUserMessage = (formData: FormData) =>
postForm<UserMessageResponse>("/api/users/message", formData);
// Observability summary endpoints
export const fetchAlertmanagerAlerts = () =>
get<AlertmanagerAlertSummary>("/api/monitoring/alerts");
export const fetchAlertmanagerAlerts = (serviceId?: string) =>
get<AlertmanagerAlertSummary>(
"/api/monitoring/alerts",
serviceId ? { service_id: serviceId } : undefined,
);
export const fetchAlertmanagerStatus = () =>
get<AlertmanagerStatus>("/api/monitoring/alertmanager-status");
export const fetchAlertmanagerStatus = (serviceId?: string) =>
get<AlertmanagerStatus>(
"/api/monitoring/alertmanager-status",
serviceId ? { service_id: serviceId } : undefined,
);
export const fetchPrometheusStatus = () =>
get<PrometheusStatus>("/api/monitoring/prometheus-status");
export const fetchPrometheusStatus = (serviceId?: string) =>
get<PrometheusStatus>(
"/api/monitoring/prometheus-status",
serviceId ? { service_id: serviceId } : undefined,
);
export const fetchPrometheusTargets = () =>
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();
});
});
+9 -9
View File
@@ -8,10 +8,10 @@ import {
fetchBackupRuns,
} from "../api/backups";
export function useBackupJobs() {
export function useBackupJobs(serviceId?: string) {
return useQuery({
queryKey: ["backups", "jobs"],
queryFn: fetchBackupJobs,
queryKey: ["backups", "jobs", serviceId ?? ""],
queryFn: () => fetchBackupJobs(serviceId),
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({
queryKey: ["backups", "runs", jobId, status],
queryFn: () => fetchBackupRuns(jobId, status),
queryKey: ["backups", "runs", jobId, status, serviceId ?? ""],
queryFn: () => fetchBackupRuns(jobId, status, serviceId),
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({
queryKey: ["backups", "alerts", jobId, acknowledged, severity],
queryFn: () => fetchBackupAlerts(jobId, acknowledged, severity),
queryKey: ["backups", "alerts", jobId, acknowledged, severity, serviceId ?? ""],
queryFn: () => fetchBackupAlerts(jobId, acknowledged, severity, serviceId),
refetchInterval: 30_000,
});
}
+9 -9
View File
@@ -7,30 +7,30 @@ import {
fetchMonitoringMachines,
} from "../api/client";
export function useAlertmanagerAlerts() {
export function useAlertmanagerAlerts(serviceId?: string) {
return useQuery({
queryKey: ["observability", "alerts"],
queryFn: fetchAlertmanagerAlerts,
queryKey: ["observability", "alerts", serviceId ?? ""],
queryFn: () => fetchAlertmanagerAlerts(serviceId),
retry: 2,
staleTime: 10_000,
refetchInterval: 15_000,
});
}
export function useAlertmanagerStatus() {
export function useAlertmanagerStatus(serviceId?: string) {
return useQuery({
queryKey: ["observability", "alertmanager-status"],
queryFn: fetchAlertmanagerStatus,
queryKey: ["observability", "alertmanager-status", serviceId ?? ""],
queryFn: () => fetchAlertmanagerStatus(serviceId),
retry: 2,
staleTime: 10_000,
refetchInterval: 30_000,
});
}
export function usePrometheusStatus() {
export function usePrometheusStatus(serviceId?: string) {
return useQuery({
queryKey: ["observability", "prometheus-status"],
queryFn: fetchPrometheusStatus,
queryKey: ["observability", "prometheus-status", serviceId ?? ""],
queryFn: () => fetchPrometheusStatus(serviceId),
retry: 2,
staleTime: 10_000,
refetchInterval: 30_000,
+5 -14
View File
@@ -1,14 +1,9 @@
/**
* Alertmanager Alerts tab (spec R2.4, R8.2).
*
* Lifts the Alertmanager alerts content from the old cross-service
* ObservabilityPage into an instance-scoped tab. Renders the active-alert
* summary (total + by severity) and the expandable alert list.
*
* 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.
* Instance-scoped tab rendering the active-alert summary (total + by severity)
* and the expandable alert list. Hooks are scoped by instance.id so
* multi-instance setups show data for the selected Alertmanager only.
*/
import { AlertTriangle, Bell, ChevronDown, Inbox } from "lucide-react";
import {
@@ -110,16 +105,12 @@ function AlertItem({ alert }: { alert: AlertmanagerAlert }) {
}
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 {
data: alertsSummary,
isLoading: alertsLoading,
error: alertsError,
} = useAlertmanagerAlerts();
const { data: status, isLoading: statusLoading } = useAlertmanagerStatus();
} = useAlertmanagerAlerts(instance.id);
const { data: status, isLoading: statusLoading } = useAlertmanagerStatus(instance.id);
const statusDetail = status?.up
? status.version
+10 -13
View File
@@ -1,14 +1,8 @@
/**
* JobsTab — operational content for the backups service page.
*
* Lifted from the old top-level `components/BackupsPage.tsx`. The three
* sub-tables (Jobs / Runs / Alerts) and their hooks are preserved verbatim.
*
* 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.
* Instance-scoped: hooks filter by instance.id so multi-instance setups
* show only the selected backups service's jobs, runs, and alerts.
*/
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -24,15 +18,18 @@ import BackupRunsTable from "../../components/BackupRunsTable";
import type { ServiceInstance } from "../../types";
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 { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs(instance.id);
const { data: runsData, isLoading: runsLoading } = useBackupRuns(
undefined,
undefined,
instance.id,
);
const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(
undefined,
false,
undefined,
instance.id,
);
const acknowledgeMutation = useAcknowledgeAlert();
+4 -11
View File
@@ -1,12 +1,9 @@
/**
* Prometheus Metrics tab (spec R2.4, R8.2).
*
* Lifts the Prometheus status + targets content from the old cross-service
* ObservabilityPage into an instance-scoped tab. Shows service health and
* the Node Exporter scrape-targets list.
*
* The hooks (usePrometheusStatus, usePrometheusTargets) are global /
* first-configured for now. Wiring `instance.id` is a follow-up.
* Instance-scoped tab showing Prometheus service health.
* usePrometheusStatus is scoped by instance.id; usePrometheusTargets
* stays global (returns Node Exporter scrape targets for external Prom).
*/
import { Radio } from "lucide-react";
import {
@@ -41,15 +38,11 @@ function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
}
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 {
data: status,
isLoading: statusLoading,
error: statusError,
} = usePrometheusStatus();
} = usePrometheusStatus(instance.id);
const {
data: targets,
isLoading: targetsLoading,
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import * as useObservability from "../../../hooks/useObservability";
import { AlertsTab } from "../AlertsTab";
import type { ServiceInstance } from "../../../types";
@@ -15,7 +16,7 @@ const instance: ServiceInstance = {
};
vi.mock("../../../hooks/useObservability", () => ({
useAlertmanagerAlerts: () => ({
useAlertmanagerAlerts: vi.fn(() => ({
data: {
total: 2,
by_severity: { critical: 1, warning: 1 },
@@ -46,15 +47,25 @@ vi.mock("../../../hooks/useObservability", () => ({
},
isLoading: false,
error: null,
}),
useAlertmanagerStatus: () => ({
})),
useAlertmanagerStatus: vi.fn(() => ({
data: { up: true, version: "0.27.0", uptime: "", name: "", peers: [] },
isLoading: false,
error: null,
}),
})),
}));
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", () => {
render(<AlertsTab instance={instance} />);
expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument();