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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user