Skip to content

Commit 74b7a4d

Browse files
committed
fix(admin): clarify partial transit status
1 parent f2d9cba commit 74b7a4d

10 files changed

Lines changed: 153 additions & 85 deletions

File tree

apps/web/src/app/admin/transit/CurrentJobCard.tsx

Lines changed: 23 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import LinearProgress from "@mui/material/LinearProgress";
1212
import Paper from "@mui/material/Paper";
1313
import Stack from "@mui/material/Stack";
1414
import Typography from "@mui/material/Typography";
15+
import type { ReactNode } from "react";
1516
import { useAdminToast } from "@/components/admin/shared/AdminToast";
17+
import { JobStatusChip } from "@/components/admin/shared/JobStatusChip";
1618
import {
1719
type TransitStateSummary,
1820
useRestartMotis,
@@ -35,19 +37,23 @@ function formatTime(iso: string | null): string {
3537
}
3638
}
3739

38-
function statusColor(status: string | null): "default" | "success" | "error" | "warning" {
39-
if (status === "success") return "success";
40-
if (status === "failed" || status === "error") return "error";
41-
if (status === "partial" || status === "stale") return "warning";
42-
return "default";
43-
}
44-
4540
function durationSecs(startedAtIso: string): number {
4641
const started = new Date(startedAtIso).getTime();
4742
if (!Number.isFinite(started)) return 0;
4843
return Math.max(0, Math.round((Date.now() - started) / 1000));
4944
}
5045

46+
function SummaryMetric({ label, children }: { label: string; children: ReactNode }) {
47+
return (
48+
<Stack spacing={0.25}>
49+
<Typography variant="caption" sx={{ color: "text.secondary" }}>
50+
{label}
51+
</Typography>
52+
{children}
53+
</Stack>
54+
);
55+
}
56+
5157
function RunningJobDetail({ jobId }: { jobId: string }) {
5258
const { data, isLoading } = useTransitJobDetail(jobId);
5359

@@ -87,10 +93,10 @@ function RunningJobDetail({ jobId }: { jobId: string }) {
8793
/>
8894
<Chip label={`${stages.length} stage(s) recorded`} size="small" variant="outlined" />
8995
{latest && (
90-
<Chip
96+
<JobStatusChip
97+
status={latest.status}
9198
label={`stage: ${latest.stage} (${latest.status})`}
92-
size="small"
93-
color={statusColor(latest.status)}
99+
variant="filled"
94100
/>
95101
)}
96102
</Stack>
@@ -195,44 +201,15 @@ export function CurrentJobCard({ state }: { state: TransitStateSummary }) {
195201
flexWrap: "wrap",
196202
}}
197203
>
198-
<Box>
199-
<Typography
200-
variant="caption"
201-
sx={{
202-
color: "text.secondary",
203-
}}
204-
>
205-
Last sync
206-
</Typography>
204+
<SummaryMetric label="Last sync">
207205
<Typography variant="body2">{formatTime(state.lastSyncAt)}</Typography>
208-
</Box>
209-
<Box>
210-
<Typography
211-
variant="caption"
212-
sx={{
213-
color: "text.secondary",
214-
}}
215-
>
216-
Last status
217-
</Typography>
218-
<Chip
219-
label={state.lastSyncStatus ?? "never"}
220-
size="small"
221-
color={statusColor(state.lastSyncStatus)}
222-
variant="outlined"
223-
/>
224-
</Box>
225-
<Box>
226-
<Typography
227-
variant="caption"
228-
sx={{
229-
color: "text.secondary",
230-
}}
231-
>
232-
Total feeds tracked
233-
</Typography>
206+
</SummaryMetric>
207+
<SummaryMetric label="Last status">
208+
<JobStatusChip status={state.lastSyncStatus} />
209+
</SummaryMetric>
210+
<SummaryMetric label="Total feeds tracked">
234211
<Typography variant="body2">{state.feedCount}</Typography>
235-
</Box>
212+
</SummaryMetric>
236213
</Stack>
237214
)}
238215
{state.lastSyncStatus === "failed" && (

apps/web/src/app/admin/transit/RecentJobsTable.tsx

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import Typography from "@mui/material/Typography";
1717
import { useState } from "react";
1818
import { AdminTableSurface } from "@/components/admin/shared/AdminTableSurface";
1919
import { DataManagerJobStages } from "@/components/admin/shared/DataManagerJobStages";
20-
import { jobStatusColor } from "@/components/admin/shared/jobStatus";
20+
import { JobStatusChip } from "@/components/admin/shared/JobStatusChip";
2121
import { useTransitJobDetail, useTransitJobs } from "@/lib/admin/transitHooks";
2222

2323
function formatTime(iso: string | null): string {
@@ -117,12 +117,7 @@ function JobDetailDrawer({ jobId, onClose }: { jobId: string; onClose: () => voi
117117
flexWrap: "wrap",
118118
}}
119119
>
120-
<Chip
121-
size="small"
122-
label={data.status}
123-
color={jobStatusColor(data.status)}
124-
variant={data.status === "running" ? "filled" : "outlined"}
125-
/>
120+
<JobStatusChip status={data.status} />
126121
<Chip
127122
size="small"
128123
label={`triggered by ${data.triggeredBy ?? "—"}`}
@@ -214,12 +209,7 @@ export function RecentJobsTable() {
214209
<TableCell>{formatTime(job.startedAt)}</TableCell>
215210
<TableCell>{formatTime(job.finishedAt)}</TableCell>
216211
<TableCell>
217-
<Chip
218-
size="small"
219-
label={job.status}
220-
color={jobStatusColor(job.status)}
221-
variant={job.status === "running" ? "filled" : "outlined"}
222-
/>
212+
<JobStatusChip status={job.status} />
223213
</TableCell>
224214
<TableCell>
225215
<Typography

apps/web/src/components/admin/activity/JobDetail.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1111
import { useEffect, useRef } from "react";
1212
import { useEnv } from "@/lib/EnvProvider";
1313
import { DataManagerJobStages } from "../shared/DataManagerJobStages";
14-
import { JobStatusChip } from "./JobStatusChip";
14+
import { JobStatusChip } from "../shared/JobStatusChip";
1515

1616
interface JobLog {
1717
id: string;

apps/web/src/components/admin/activity/JobList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ import { useEnv } from "@/lib/EnvProvider";
2424
import { relativeTimeFromIso } from "@/lib/formatTime";
2525
import { AdminTablePagination } from "../shared/AdminTablePagination";
2626
import { AdminTableSurface } from "../shared/AdminTableSurface";
27+
import { JobStatusChip } from "../shared/JobStatusChip";
2728
import { TableSkeleton } from "../shared/TableSkeleton";
2829
import { useServerPagination } from "../shared/tableHooks";
2930
import { ActorCell } from "./ActorCell";
3031
import { JobDetail } from "./JobDetail";
31-
import { JobStatusChip } from "./JobStatusChip";
3232

3333
interface AdminJob {
3434
source: "application" | "data-manager";

apps/web/src/components/admin/activity/JobStatusChip.tsx

Lines changed: 0 additions & 15 deletions
This file was deleted.

apps/web/src/components/admin/shared/DataManagerJobStages.tsx

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import Chip from "@mui/material/Chip";
21
import Stack from "@mui/material/Stack";
32
import Table from "@mui/material/Table";
43
import TableBody from "@mui/material/TableBody";
@@ -7,7 +6,8 @@ import TableContainer from "@mui/material/TableContainer";
76
import TableHead from "@mui/material/TableHead";
87
import TableRow from "@mui/material/TableRow";
98
import Typography from "@mui/material/Typography";
10-
import { formatStageError, jobStatusColor } from "./jobStatus";
9+
import { JobStatusChip } from "./JobStatusChip";
10+
import { formatStageError } from "./jobStatus";
1111

1212
export interface DataManagerJobStage {
1313
id: string;
@@ -16,6 +16,7 @@ export interface DataManagerJobStage {
1616
durationMs: number;
1717
message: string | null;
1818
error: unknown;
19+
artifacts?: unknown;
1920
}
2021

2122
function durationLabel(durationMs: number): string {
@@ -27,6 +28,24 @@ function durationLabel(durationMs: number): string {
2728
return `${minutes}m ${seconds}s`;
2829
}
2930

31+
function validationDetails(stage: DataManagerJobStage): string | null {
32+
if (stage.stage !== "validate" || !stage.artifacts || typeof stage.artifacts !== "object") {
33+
return null;
34+
}
35+
const invalid = (stage.artifacts as { invalid?: unknown }).invalid;
36+
if (!Array.isArray(invalid) || invalid.length === 0) return null;
37+
38+
const details = invalid.flatMap((entry) => {
39+
if (!entry || typeof entry !== "object") return [];
40+
const { id, reason } = entry as { id?: unknown; reason?: unknown };
41+
if (typeof id !== "string") return [];
42+
return [`${id}${typeof reason === "string" ? `: ${reason}` : ""}`];
43+
});
44+
if (details.length === 0) return null;
45+
const hidden = details.length - 5;
46+
return `Invalid archives — ${details.slice(0, 5).join("; ")}${hidden > 0 ? `; +${hidden} more` : ""}`;
47+
}
48+
3049
export function DataManagerJobStages({
3150
stages,
3251
emptyMessage = "No stages recorded",
@@ -56,6 +75,7 @@ export function DataManagerJobStages({
5675
<TableBody>
5776
{stages.map((stage) => {
5877
const error = formatStageError(stage.error);
78+
const artifactDetails = validationDetails(stage);
5979
return (
6080
<TableRow key={stage.id} hover>
6181
<TableCell>
@@ -70,14 +90,17 @@ export function DataManagerJobStages({
7090
{error ?? stage.message}
7191
</Typography>
7292
)}
93+
{artifactDetails && (
94+
<Typography
95+
variant="caption"
96+
sx={{ color: "warning.main", display: "block" }}
97+
>
98+
{artifactDetails}
99+
</Typography>
100+
)}
73101
</TableCell>
74102
<TableCell>
75-
<Chip
76-
size="small"
77-
label={stage.status}
78-
color={jobStatusColor(stage.status)}
79-
variant="outlined"
80-
/>
103+
<JobStatusChip status={stage.status} />
81104
</TableCell>
82105
<TableCell align="right">{durationLabel(stage.durationMs)}</TableCell>
83106
</TableRow>
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"use client";
2+
3+
import Chip, { type ChipProps } from "@mui/material/Chip";
4+
import Tooltip from "@mui/material/Tooltip";
5+
import type { ReactNode } from "react";
6+
import { jobStatusColor, jobStatusDescription } from "./jobStatus";
7+
8+
export function JobStatusChip({
9+
status,
10+
label,
11+
variant,
12+
sx,
13+
}: {
14+
status: string | null;
15+
label?: ReactNode;
16+
variant?: ChipProps["variant"];
17+
sx?: ChipProps["sx"];
18+
}) {
19+
const value = status ?? "never";
20+
const chip = (
21+
<Chip
22+
label={label ?? value}
23+
size="small"
24+
color={jobStatusColor(value)}
25+
variant={variant ?? (value === "running" ? "filled" : "outlined")}
26+
sx={sx}
27+
/>
28+
);
29+
const description = jobStatusDescription(value);
30+
return description ? <Tooltip title={description}>{chip}</Tooltip> : chip;
31+
}

apps/web/src/components/admin/shared/__tests__/DataManagerJobStages.test.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ describe("DataManagerJobStages", () => {
1414
durationMs: 61_250,
1515
message: "Import failed",
1616
error: { message: "Transactional import failed", stack: "long stack" },
17+
artifacts: null,
1718
},
1819
]}
1920
/>,
@@ -25,6 +26,33 @@ describe("DataManagerJobStages", () => {
2526
expect(html).not.toContain("long stack");
2627
});
2728

29+
it("explains which validation archives caused a partial result", () => {
30+
const html = renderToStaticMarkup(
31+
<DataManagerJobStages
32+
stages={[
33+
{
34+
id: "stage-validate",
35+
stage: "validate",
36+
status: "partial",
37+
durationMs: 171,
38+
message: "Validated 4 / 6 archive(s); 2 invalid",
39+
error: null,
40+
artifacts: {
41+
invalid: [
42+
{ id: "de_VBB", reason: "missing feed_info.txt" },
43+
{ id: "de_VBN", reason: "missing feed_info.txt" },
44+
],
45+
},
46+
},
47+
]}
48+
/>,
49+
);
50+
51+
expect(html).toContain("Invalid archives");
52+
expect(html).toContain("de_VBB: missing feed_info.txt");
53+
expect(html).toContain("de_VBN: missing feed_info.txt");
54+
});
55+
2856
it("uses a context-specific empty state", () => {
2957
const html = renderToStaticMarkup(
3058
<DataManagerJobStages stages={[]} emptyMessage="Waiting for the first stage..." />,

apps/web/src/components/admin/shared/__tests__/jobStatus.test.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, it } from "vitest";
2-
import { formatStageError, JOB_STATUS_COLOR, jobStatusColor } from "../jobStatus";
2+
import {
3+
formatStageError,
4+
JOB_STATUS_COLOR,
5+
jobStatusColor,
6+
jobStatusDescription,
7+
} from "../jobStatus";
38

49
describe("jobStatusColor", () => {
510
it("maps each known status to its color", () => {
@@ -27,6 +32,17 @@ describe("jobStatusColor", () => {
2732
});
2833
});
2934

35+
describe("jobStatusDescription", () => {
36+
it("explains partial as a completed job with warnings", () => {
37+
expect(jobStatusDescription("partial")).toContain("Completed with warnings");
38+
expect(jobStatusDescription("partial")).toContain("partially succeeded");
39+
});
40+
41+
it("omits a tooltip for an unknown status", () => {
42+
expect(jobStatusDescription("custom-status")).toBeNull();
43+
});
44+
});
45+
3046
describe("formatStageError", () => {
3147
it("returns null for empty values so the UI renders nothing", () => {
3248
expect(formatStageError(null)).toBeNull();

apps/web/src/components/admin/shared/jobStatus.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,24 @@ export function jobStatusColor(status: string): JobStatusColor {
2020
return JOB_STATUS_COLOR[status] ?? "default";
2121
}
2222

23+
const JOB_STATUS_DESCRIPTION: Record<string, string> = {
24+
queued: "Waiting to start.",
25+
running: "Currently in progress.",
26+
success: "Completed successfully.",
27+
ok: "Completed successfully.",
28+
failed: "Stopped because the job failed.",
29+
error: "Stopped because this stage failed.",
30+
canceled: "Canceled before completion.",
31+
partial:
32+
"Completed with warnings: one or more stages only partially succeeded. Open the job details to review them.",
33+
interrupted: "Stopped before completion, usually because the data manager restarted.",
34+
skipped: "Skipped because this stage was disabled or not required.",
35+
};
36+
37+
export function jobStatusDescription(status: string): string | null {
38+
return JOB_STATUS_DESCRIPTION[status] ?? null;
39+
}
40+
2341
/**
2442
* Coerce a job/stage `error` value into a displayable string.
2543
*

0 commit comments

Comments
 (0)