diff --git a/dashboard/biome.json b/dashboard/biome.json index 0a95199..429508f 100644 --- a/dashboard/biome.json +++ b/dashboard/biome.json @@ -12,7 +12,7 @@ "noForEach": "off" }, "correctness": { - "useExhaustiveDependencies": "warn" + "useExhaustiveDependencies": "error" }, "style": { "noNonNullAssertion": "off" diff --git a/dashboard/src/features/jobs/components/dag-layout.test.ts b/dashboard/src/features/jobs/components/dag-layout.test.ts new file mode 100644 index 0000000..08edde7 --- /dev/null +++ b/dashboard/src/features/jobs/components/dag-layout.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { DagData, DagNode } from "@/lib/api-types"; +import { LAYER_GAP_X, layout, NODE_GAP_Y, NODE_HEIGHT, NODE_WIDTH, PADDING } from "./dag-layout"; + +function node(id: string): DagNode { + return { id, task_name: id, status: "complete" }; +} + +describe("layout (DAG)", () => { + it("returns zero-size canvas for an empty graph", () => { + expect(layout({ nodes: [], edges: [] })).toEqual({ nodes: [], width: 0, height: 0 }); + }); + + it("places a single node on layer 0 within padding", () => { + const result = layout({ nodes: [node("a")], edges: [] }); + expect(result.nodes).toHaveLength(1); + const [a] = result.nodes; + expect(a).toMatchObject({ id: "a", layer: 0, x: PADDING }); + expect(result.width).toBe(PADDING * 2 + NODE_WIDTH); + expect(result.height).toBe(PADDING * 2 + NODE_HEIGHT); + }); + + it("assigns BFS layers along a linear chain", () => { + const data: DagData = { + nodes: [node("a"), node("b"), node("c")], + edges: [ + { from: "a", to: "b" }, + { from: "b", to: "c" }, + ], + }; + const result = layout(data); + const layers = Object.fromEntries(result.nodes.map((n) => [n.id, n.layer])); + expect(layers).toEqual({ a: 0, b: 1, c: 2 }); + }); + + it("centers a fan-out layer vertically", () => { + const data: DagData = { + nodes: [node("root"), node("l"), node("r")], + edges: [ + { from: "root", to: "l" }, + { from: "root", to: "r" }, + ], + }; + const result = layout(data); + + const root = result.nodes.find((n) => n.id === "root"); + const left = result.nodes.find((n) => n.id === "l"); + const right = result.nodes.find((n) => n.id === "r"); + expect(root?.layer).toBe(0); + expect(left?.layer).toBe(1); + expect(right?.layer).toBe(1); + + const layerHeight = 2 * NODE_HEIGHT + NODE_GAP_Y; + const expectedStartY = (result.height - layerHeight) / 2; + expect(left?.y).toBe(expectedStartY); + expect(right?.y).toBe(expectedStartY + NODE_HEIGHT + NODE_GAP_Y); + }); + + it("places nodes from later layers at increasing x", () => { + const result = layout({ + nodes: [node("a"), node("b"), node("c")], + edges: [ + { from: "a", to: "b" }, + { from: "b", to: "c" }, + ], + }); + const a = result.nodes.find((n) => n.id === "a"); + const b = result.nodes.find((n) => n.id === "b"); + const c = result.nodes.find((n) => n.id === "c"); + expect(a?.x).toBeLessThan(b?.x ?? 0); + expect(b?.x).toBeLessThan(c?.x ?? 0); + expect((b?.x ?? 0) - (a?.x ?? 0)).toBe(NODE_WIDTH + LAYER_GAP_X); + }); + + it("ranks deeper of two paths to a shared sink", () => { + const data: DagData = { + nodes: [node("a"), node("b"), node("c"), node("d")], + edges: [ + { from: "a", to: "b" }, + { from: "b", to: "d" }, + { from: "a", to: "c" }, + { from: "c", to: "d" }, + ], + }; + const result = layout(data); + const layers = Object.fromEntries(result.nodes.map((n) => [n.id, n.layer])); + expect(layers.d).toBe(2); + }); + + it("falls back to layer 0 for cycles with no source", () => { + const data: DagData = { + nodes: [node("a"), node("b")], + edges: [ + { from: "a", to: "b" }, + { from: "b", to: "a" }, + ], + }; + const result = layout(data); + expect(result.nodes).toHaveLength(2); + expect(result.nodes.some((n) => n.layer === 0)).toBe(true); + expect(result.width).toBeGreaterThan(0); + }); + + it("includes isolated nodes as additional layer-0 entries", () => { + const data: DagData = { + nodes: [node("a"), node("b"), node("orphan")], + edges: [{ from: "a", to: "b" }], + }; + const result = layout(data); + const orphan = result.nodes.find((n) => n.id === "orphan"); + expect(orphan?.layer).toBe(0); + }); + + it("preserves the original task_name and status on laid-out nodes", () => { + const data: DagData = { + nodes: [{ id: "a", task_name: "send_email", status: "running" }], + edges: [], + }; + const [a] = layout(data).nodes; + expect(a).toMatchObject({ task_name: "send_email", status: "running" }); + }); +}); diff --git a/dashboard/src/features/jobs/components/dag-layout.ts b/dashboard/src/features/jobs/components/dag-layout.ts new file mode 100644 index 0000000..8745bff --- /dev/null +++ b/dashboard/src/features/jobs/components/dag-layout.ts @@ -0,0 +1,126 @@ +import type { DagData, DagNode } from "@/lib/api-types"; + +export const NODE_WIDTH = 180; +export const NODE_HEIGHT = 58; +export const LAYER_GAP_X = 80; +export const NODE_GAP_Y = 24; +export const PADDING = 32; + +export interface LaidOutNode extends DagNode { + x: number; + y: number; + layer: number; +} + +export interface LayoutResult { + nodes: LaidOutNode[]; + width: number; + height: number; +} + +/** + * Lay out DAG nodes in BFS layers from sources (nodes with no inbound edges). + * Each layer's nodes are centered vertically so the graph is readable even for + * lopsided fan-outs. Cycles are handled defensively by seeding layer 0 with + * the first node when no source exists. + */ +export function layout(data: DagData): LayoutResult { + const { nodes, edges } = data; + if (nodes.length === 0) return { nodes: [], width: 0, height: 0 }; + + const layerOf = assignLayers(nodes, edges); + const layers = groupByLayer(nodes, layerOf); + const sortedLayers = [...layers.entries()].sort(([a], [b]) => a - b); + + const { width, height } = canvasSize(sortedLayers); + const laidOut = positionNodes(nodes, sortedLayers, height); + + return { nodes: laidOut, width, height }; +} + +function assignLayers(nodes: DagNode[], edges: DagData["edges"]): Map { + const incoming = new Map(); + const outgoing = new Map(); + for (const n of nodes) incoming.set(n.id, 0); + for (const e of edges) { + incoming.set(e.to, (incoming.get(e.to) ?? 0) + 1); + const arr = outgoing.get(e.from); + if (arr) arr.push(e.to); + else outgoing.set(e.from, [e.to]); + } + + const layerOf = new Map(); + const queue: string[] = []; + for (const n of nodes) { + if ((incoming.get(n.id) ?? 0) === 0) { + layerOf.set(n.id, 0); + queue.push(n.id); + } + } + if (queue.length === 0) { + layerOf.set(nodes[0]!.id, 0); + queue.push(nodes[0]!.id); + } + + // Cap depth at nodes.length - 1 so cycles can't push layers up forever. + // The longest acyclic path through N nodes has N-1 edges, so any layer + // beyond that signals a back-edge in a cycle and is safe to drop. + const maxLayer = Math.max(0, nodes.length - 1); + while (queue.length > 0) { + const id = queue.shift()!; + const depth = layerOf.get(id) ?? 0; + if (depth >= maxLayer) continue; + for (const child of outgoing.get(id) ?? []) { + const nextDepth = depth + 1; + if ((layerOf.get(child) ?? -1) < nextDepth) { + layerOf.set(child, nextDepth); + queue.push(child); + } + } + } + + return layerOf; +} + +function groupByLayer(nodes: DagNode[], layerOf: Map): Map { + const layers = new Map(); + for (const n of nodes) { + const layer = layerOf.get(n.id) ?? 0; + const bucket = layers.get(layer) ?? []; + bucket.push(n.id); + layers.set(layer, bucket); + } + return layers; +} + +function canvasSize(sortedLayers: [number, string[]][]): { width: number; height: number } { + const tallestLayerSize = sortedLayers.reduce((max, [, ids]) => Math.max(max, ids.length), 0); + const height = PADDING * 2 + tallestLayerSize * NODE_HEIGHT + (tallestLayerSize - 1) * NODE_GAP_Y; + const width = + PADDING * 2 + sortedLayers.length * NODE_WIDTH + (sortedLayers.length - 1) * LAYER_GAP_X; + return { width, height }; +} + +function positionNodes( + nodes: DagNode[], + sortedLayers: [number, string[]][], + canvasHeight: number, +): LaidOutNode[] { + const byId = new Map(nodes.map((n) => [n.id, n])); + const laidOut: LaidOutNode[] = []; + for (const [layer, ids] of sortedLayers) { + const layerHeight = ids.length * NODE_HEIGHT + (ids.length - 1) * NODE_GAP_Y; + const startY = (canvasHeight - layerHeight) / 2; + ids.forEach((id, i) => { + const base = byId.get(id); + if (!base) return; + laidOut.push({ + ...base, + layer, + x: PADDING + layer * (NODE_WIDTH + LAYER_GAP_X), + y: startY + i * (NODE_HEIGHT + NODE_GAP_Y), + }); + }); + } + return laidOut; +} diff --git a/dashboard/src/features/jobs/components/job-dag-tab.tsx b/dashboard/src/features/jobs/components/job-dag-tab.tsx index cfe578b..77ffe81 100644 --- a/dashboard/src/features/jobs/components/job-dag-tab.tsx +++ b/dashboard/src/features/jobs/components/job-dag-tab.tsx @@ -2,8 +2,9 @@ import { Link } from "@tanstack/react-router"; import { Workflow } from "lucide-react"; import { useMemo } from "react"; import { EmptyState, ErrorState, Skeleton } from "@/components/ui"; -import type { DagData, DagEdge, DagNode, JobStatus } from "@/lib/api-types"; +import type { DagData, DagEdge, JobStatus } from "@/lib/api-types"; import { JOB_STATUS_LABEL } from "@/lib/status"; +import { type LaidOutNode, layout, NODE_HEIGHT, NODE_WIDTH } from "./dag-layout"; interface JobDagTabProps { dag: DagData | undefined; @@ -30,99 +31,6 @@ const STATUS_STROKE: Record = { cancelled: "var(--color-warning)", }; -const NODE_WIDTH = 180; -const NODE_HEIGHT = 58; -const LAYER_GAP_X = 80; -const NODE_GAP_Y = 24; -const PADDING = 32; - -interface LaidOutNode extends DagNode { - x: number; - y: number; - layer: number; -} - -/** - * Lay out DAG nodes in BFS layers from sources (nodes with no inbound edges). - * Each layer's nodes are centered vertically so the graph is readable even - * for lopsided fan-outs. Returns positioned nodes plus canvas dimensions. - */ -function layout(data: DagData): { nodes: LaidOutNode[]; width: number; height: number } { - const nodes = data.nodes; - const edges = data.edges; - if (nodes.length === 0) return { nodes: [], width: 0, height: 0 }; - - const incoming = new Map(); - const outgoing = new Map(); - for (const n of nodes) incoming.set(n.id, 0); - for (const e of edges) { - incoming.set(e.to, (incoming.get(e.to) ?? 0) + 1); - const arr = outgoing.get(e.from); - if (arr) arr.push(e.to); - else outgoing.set(e.from, [e.to]); - } - - const layerOf = new Map(); - const queue: string[] = []; - for (const n of nodes) { - if ((incoming.get(n.id) ?? 0) === 0) { - layerOf.set(n.id, 0); - queue.push(n.id); - } - } - // Handle cycles defensively: any node we haven't ranked sits on layer 0. - if (queue.length === 0 && nodes.length > 0) { - layerOf.set(nodes[0]!.id, 0); - queue.push(nodes[0]!.id); - } - - while (queue.length > 0) { - const id = queue.shift()!; - const depth = layerOf.get(id) ?? 0; - for (const child of outgoing.get(id) ?? []) { - const nextDepth = depth + 1; - if ((layerOf.get(child) ?? -1) < nextDepth) { - layerOf.set(child, nextDepth); - queue.push(child); - } - } - } - - const layers = new Map(); - for (const n of nodes) { - const layer = layerOf.get(n.id) ?? 0; - const bucket = layers.get(layer) ?? []; - bucket.push(n.id); - layers.set(layer, bucket); - } - - const sortedLayers = [...layers.entries()].sort(([a], [b]) => a - b); - const tallestLayerSize = sortedLayers.reduce((max, [, ids]) => Math.max(max, ids.length), 0); - const canvasHeight = - PADDING * 2 + tallestLayerSize * NODE_HEIGHT + (tallestLayerSize - 1) * NODE_GAP_Y; - const canvasWidth = - PADDING * 2 + sortedLayers.length * NODE_WIDTH + (sortedLayers.length - 1) * LAYER_GAP_X; - - const laidOut: LaidOutNode[] = []; - const byId = new Map(nodes.map((n) => [n.id, n])); - for (const [layer, ids] of sortedLayers) { - const layerHeight = ids.length * NODE_HEIGHT + (ids.length - 1) * NODE_GAP_Y; - const startY = (canvasHeight - layerHeight) / 2; - ids.forEach((id, i) => { - const base = byId.get(id); - if (!base) return; - laidOut.push({ - ...base, - layer, - x: PADDING + layer * (NODE_WIDTH + LAYER_GAP_X), - y: startY + i * (NODE_HEIGHT + NODE_GAP_Y), - }); - }); - } - - return { nodes: laidOut, width: canvasWidth, height: canvasHeight }; -} - export function JobDagTab({ dag, loading, error, onRetry }: JobDagTabProps) { const layoutResult = useMemo( () => (dag ? layout(dag) : { nodes: [], width: 0, height: 0 }), diff --git a/dashboard/src/features/jobs/components/job-filters.tsx b/dashboard/src/features/jobs/components/job-filters.tsx index 72ee5f9..b882036 100644 --- a/dashboard/src/features/jobs/components/job-filters.tsx +++ b/dashboard/src/features/jobs/components/job-filters.tsx @@ -1,5 +1,5 @@ import { X } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Button, Input, @@ -62,32 +62,34 @@ export function JobFiltersBar({ filters, onChange, className }: JobFiltersBarPro const debouncedMetadata = useDebouncedValue(local.metadata, 300); const debouncedError = useDebouncedValue(local.error, 300); + // Hold the latest filters/onChange in refs so the propagation effect re-runs + // only when debounced values settle. Listing them as deps would re-fire the + // effect on every parent render and defeat the debounce. + const filtersRef = useRef(filters); + const onChangeRef = useRef(onChange); useEffect(() => { + filtersRef.current = filters; + onChangeRef.current = onChange; + }); + + useEffect(() => { + const current = filtersRef.current; const next: JobFilters = { - ...filters, + ...current, queue: debouncedQueue || undefined, task: debouncedTask || undefined, metadata: debouncedMetadata || undefined, error: debouncedError || undefined, }; if ( - next.queue !== filters.queue || - next.task !== filters.task || - next.metadata !== filters.metadata || - next.error !== filters.error + next.queue !== current.queue || + next.task !== current.task || + next.metadata !== current.metadata || + next.error !== current.error ) { - onChange(next); + onChangeRef.current(next); } - // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: propagate only debounced values - }, [ - debouncedQueue, - debouncedTask, - debouncedMetadata, - debouncedError, - filters.task, - filters, - onChange, - ]); + }, [debouncedQueue, debouncedTask, debouncedMetadata, debouncedError]); const activeCount = countActiveFilters(filters); diff --git a/dashboard/src/features/jobs/components/job-logs-tab.tsx b/dashboard/src/features/jobs/components/job-logs-tab.tsx index 6877766..fe158b2 100644 --- a/dashboard/src/features/jobs/components/job-logs-tab.tsx +++ b/dashboard/src/features/jobs/components/job-logs-tab.tsx @@ -2,6 +2,7 @@ import { ScrollText } from "lucide-react"; import { EmptyState, ErrorState, Skeleton } from "@/components/ui"; import type { TaskLog } from "@/lib/api-types"; import { cn } from "@/lib/cn"; +import { logLevelClass } from "@/lib/status"; import { formatAbsolute } from "@/lib/time"; interface JobLogsTabProps { @@ -11,14 +12,6 @@ interface JobLogsTabProps { onRetry: () => void; } -const LEVEL_STYLE: Record = { - error: "text-danger", - warning: "text-warning", - warn: "text-warning", - info: "text-info", - debug: "text-[var(--fg-subtle)]", -}; - export function JobLogsTab({ logs, loading, error, onRetry }: JobLogsTabProps) { if (error) { return ; @@ -36,7 +29,7 @@ export function JobLogsTab({ logs, loading, error, onRetry }: JobLogsTabProps) {
    {logs.map((log, i) => { - const levelClass = LEVEL_STYLE[log.level.toLowerCase()] ?? "text-[var(--fg-muted)]"; + const levelClass = logLevelClass(log.level); const key = `${log.logged_at}-${log.level}-${i}`; return (
  • diff --git a/dashboard/src/features/jobs/components/job-replay-tab.tsx b/dashboard/src/features/jobs/components/job-replay-tab.tsx index de9bd0c..38471a3 100644 --- a/dashboard/src/features/jobs/components/job-replay-tab.tsx +++ b/dashboard/src/features/jobs/components/job-replay-tab.tsx @@ -49,8 +49,7 @@ export function JobReplayTab({ replays, loading, error, onRetry }: JobReplayTabP {entry.replay_job_id} - {formatAbsolute(entry.replayed_at * 1000)} ·{" "} - {formatRelative(entry.replayed_at * 1000)} + {formatAbsolute(entry.replayed_at)} · {formatRelative(entry.replayed_at)}
{entry.original_error ? ( diff --git a/dashboard/src/features/jobs/components/job-table.tsx b/dashboard/src/features/jobs/components/job-table.tsx index 63c3ea0..9d9e8d5 100644 --- a/dashboard/src/features/jobs/components/job-table.tsx +++ b/dashboard/src/features/jobs/components/job-table.tsx @@ -68,7 +68,7 @@ export function JobTable({ jobs, loading, error, onRetry }: JobTableProps) { header: "Created", cell: ({ getValue }) => ( - {formatRelative(getValue() * 1000)} + {formatRelative(getValue())} ), }, diff --git a/dashboard/src/features/logs/components/log-stream.tsx b/dashboard/src/features/logs/components/log-stream.tsx index 2431bfd..c05265a 100644 --- a/dashboard/src/features/logs/components/log-stream.tsx +++ b/dashboard/src/features/logs/components/log-stream.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, useState } from "react"; import { EmptyState, ErrorState, Skeleton } from "@/components/ui"; import type { TaskLog } from "@/lib/api-types"; import { cn } from "@/lib/cn"; +import { logLevelClass } from "@/lib/status"; import { formatAbsolute } from "@/lib/time"; interface LogStreamProps { @@ -14,14 +15,6 @@ interface LogStreamProps { className?: string; } -const LEVEL_TONE: Record = { - error: "text-danger", - warning: "text-warning", - warn: "text-warning", - info: "text-info", - debug: "text-[var(--fg-subtle)]", -}; - const ROW_ESTIMATE = 28; const AUTO_SCROLL_THRESHOLD_PX = 40; @@ -88,7 +81,7 @@ export function LogStream({ logs, loading, error, onRetry, className }: LogStrea {virtualizer.getVirtualItems().map((item) => { const log = logs[item.index]; if (!log) return null; - const levelClass = LEVEL_TONE[log.level.toLowerCase()] ?? "text-[var(--fg-muted)]"; + const levelClass = logLevelClass(log.level); return (
); } - -function formatAxisTime(value: number): string { - return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); -} diff --git a/dashboard/src/features/metrics/components/throughput-chart.tsx b/dashboard/src/features/metrics/components/throughput-chart.tsx index 915e4dc..8d353de 100644 --- a/dashboard/src/features/metrics/components/throughput-chart.tsx +++ b/dashboard/src/features/metrics/components/throughput-chart.tsx @@ -11,6 +11,7 @@ import { } from "recharts"; import { Card, CardContent, CardHeader, CardTitle, Skeleton } from "@/components/ui"; import type { TimeseriesBucket } from "@/lib/api-types"; +import { formatAxisTime } from "../utils"; interface ThroughputChartProps { buckets: TimeseriesBucket[] | undefined; @@ -27,7 +28,7 @@ export function ThroughputChart({ buckets, loading }: ThroughputChartProps) { const data = useMemo( () => (buckets ?? []).map((b) => ({ - t: b.timestamp * 1000, + t: b.timestamp, success: b.success, failure: b.failure, })), @@ -128,7 +129,3 @@ function ChartTooltip({
); } - -function formatAxisTime(value: number): string { - return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); -} diff --git a/dashboard/src/features/metrics/utils.ts b/dashboard/src/features/metrics/utils.ts new file mode 100644 index 0000000..8cc1ea9 --- /dev/null +++ b/dashboard/src/features/metrics/utils.ts @@ -0,0 +1,3 @@ +export function formatAxisTime(value: number): string { + return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); +} diff --git a/dashboard/src/features/settings/derived.test.ts b/dashboard/src/features/settings/derived.test.ts new file mode 100644 index 0000000..1609feb --- /dev/null +++ b/dashboard/src/features/settings/derived.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { applyJobContext, parseExternalLinks } from "./derived"; + +describe("parseExternalLinks", () => { + it("returns [] for undefined or empty input", () => { + expect(parseExternalLinks(undefined)).toEqual([]); + expect(parseExternalLinks("")).toEqual([]); + }); + + it("returns [] for invalid JSON", () => { + expect(parseExternalLinks("not json")).toEqual([]); + expect(parseExternalLinks("{")).toEqual([]); + }); + + it("returns [] when JSON is not an array", () => { + expect(parseExternalLinks('{"label":"x","url":"y"}')).toEqual([]); + expect(parseExternalLinks("null")).toEqual([]); + expect(parseExternalLinks("42")).toEqual([]); + }); + + it("parses well-formed array entries", () => { + const raw = JSON.stringify([ + { label: "Docs", url: "https://docs.example.com" }, + { label: "Repo", url: "https://github.com/example" }, + ]); + expect(parseExternalLinks(raw)).toEqual([ + { label: "Docs", url: "https://docs.example.com" }, + { label: "Repo", url: "https://github.com/example" }, + ]); + }); + + it("filters out entries missing label or url", () => { + const raw = JSON.stringify([ + { label: "Docs", url: "https://docs.example.com" }, + { label: "Missing url" }, + { url: "https://nolabel.example.com" }, + { label: 42, url: "https://wrong-type.example.com" }, + null, + "string", + ]); + expect(parseExternalLinks(raw)).toEqual([{ label: "Docs", url: "https://docs.example.com" }]); + }); + + it("strips extra fields, keeping only label and url", () => { + const raw = JSON.stringify([{ label: "Docs", url: "/d", danger: "