Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dashboard/biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"noForEach": "off"
},
"correctness": {
"useExhaustiveDependencies": "warn"
"useExhaustiveDependencies": "error"
},
"style": {
"noNonNullAssertion": "off"
Expand Down
122 changes: 122 additions & 0 deletions dashboard/src/features/jobs/components/dag-layout.test.ts
Original file line number Diff line number Diff line change
@@ -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" });
});
});
126 changes: 126 additions & 0 deletions dashboard/src/features/jobs/components/dag-layout.ts
Original file line number Diff line number Diff line change
@@ -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<string, number> {
const incoming = new Map<string, number>();
const outgoing = new Map<string, string[]>();
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<string, number>();
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<string, number>): Map<number, string[]> {
const layers = new Map<number, string[]>();
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;
}
96 changes: 2 additions & 94 deletions dashboard/src/features/jobs/components/job-dag-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,99 +31,6 @@ const STATUS_STROKE: Record<JobStatus, string> = {
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<string, number>();
const outgoing = new Map<string, string[]>();
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<string, number>();
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<number, string[]>();
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 }),
Expand Down
Loading
Loading