Frontend: backups Jobs tab (Slice 7)

Replace the JobsTab stub with a real implementation on the backups
service page, lifted from components/BackupsPage.tsx. Renders the
Jobs/Runs/Alerts sub-tabs with their existing tables
(BackupJobsTable, BackupRunsTable, BackupAlertsTable) and the
acknowledge-alert mutation.

The backup hooks (useBackupJobs/Runs/Alerts) currently query globally --
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 documented follow-up once
the hooks gain the parameter.

The page heading from BackupsPage is dropped (the service page header
already shows the instance name + 'Backups' binding).

stubs.tsx loses the JobsTab stub; index.ts wires the real component.

Tests: JobsTab renders sub-tabs + job-name rows with mocked hooks. 96
tests pass (+2); lint/build green.

Refs openspec/changes/services-as-hub-ia/ (spec R2.4, tasks slice 7).
This commit is contained in:
Developer
2026-06-26 19:34:05 +00:00
parent b2e1acd257
commit 6a1f8bbd59
4 changed files with 146 additions and 5 deletions
@@ -0,0 +1,87 @@
/**
* 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.
*/
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
useAcknowledgeAlert,
useBackupAlerts,
useBackupJobs,
useBackupRuns,
} from "../../hooks/useBackups";
import BackupAlertsTable from "../../components/BackupAlertsTable";
import BackupJobsTable from "../../components/BackupJobsTable";
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: alertsData, isLoading: alertsLoading } = useBackupAlerts(
undefined,
false,
);
const acknowledgeMutation = useAcknowledgeAlert();
// Build a map of latest runs per job
const latestRuns = new Map();
if (runsData) {
for (const run of runsData) {
const existing = latestRuns.get(run.job_id);
if (!existing || run.started_at > existing.started_at) {
latestRuns.set(run.job_id, run);
}
}
}
const alertsLabel = alertsData ? `Alerts (${alertsData.length})` : "Alerts";
return (
<div className="space-y-4">
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="jobs">Jobs</TabsTrigger>
<TabsTrigger value="runs">Runs</TabsTrigger>
<TabsTrigger value="alerts">{alertsLabel}</TabsTrigger>
</TabsList>
<TabsContent value="jobs">
{jobsLoading ? (
<p className="text-sm text-muted-foreground">Loading jobs</p>
) : (
<BackupJobsTable jobs={jobsData ?? []} latestRuns={latestRuns} />
)}
</TabsContent>
<TabsContent value="runs">
{runsLoading ? (
<p className="text-sm text-muted-foreground">Loading runs</p>
) : (
<BackupRunsTable runs={runsData ?? []} />
)}
</TabsContent>
<TabsContent value="alerts">
{alertsLoading ? (
<p className="text-sm text-muted-foreground">Loading alerts</p>
) : (
<BackupAlertsTable
alerts={alertsData ?? []}
onAcknowledge={(id) => acknowledgeMutation.mutate(id)}
/>
)}
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { JobsTab } from "../JobsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "bkp-1",
service_type: "backups",
name: "Main Backups",
config: { ingestion_label: "default" },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useBackups", () => ({
useBackupJobs: () => ({
data: [
{
id: "job-1",
name: "nightly",
source: "/data",
target: "s3://bucket",
schedule_interval_seconds: 86400,
created_at: 1_700_000_000,
},
],
isLoading: false,
}),
useBackupRuns: () => ({
data: [],
isLoading: false,
}),
useBackupAlerts: () => ({
data: [],
isLoading: false,
}),
useAcknowledgeAlert: () => ({ mutate: vi.fn() }),
}));
function renderTab() {
return render(<JobsTab instance={instance} />);
}
describe("JobsTab", () => {
it("renders the Jobs, Runs, and Alerts sub-tabs", () => {
renderTab();
expect(screen.getByRole("tab", { name: "Jobs" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Runs" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /Alerts/ })).toBeInTheDocument();
});
it("renders the backup job name in the Jobs tab", () => {
renderTab();
expect(screen.getByText("nightly")).toBeInTheDocument();
});
});
+1 -1
View File
@@ -8,7 +8,6 @@ import type { ComponentType } from "react";
import type { ServiceInstance } from "../../types";
import {
AlertsTab,
JobsTab,
LinksTab,
MessagingTab,
MetricsTab,
@@ -19,6 +18,7 @@ import { MediaTab } from "./MediaTab";
import { RequestsTab } from "./RequestsTab";
import { FilesTab } from "./FilesTab";
import { ActionsTab } from "./ActionsTab";
import { JobsTab } from "./JobsTab";
export type ServiceTabComponent = ComponentType<{ instance: ServiceInstance }>;
@@ -28,10 +28,6 @@ export function OverviewTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Service overview" instance={instance} />;
}
export function JobsTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Backup jobs" instance={instance} />;
}
export function UsersTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Users" instance={instance} />;
}