station-kit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli-main.d.ts +2 -0
  3. package/dist/cli-main.d.ts.map +1 -0
  4. package/dist/cli-main.js +58 -0
  5. package/dist/cli-main.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +25 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config/loader.d.ts +3 -0
  11. package/dist/config/loader.d.ts.map +1 -0
  12. package/dist/config/loader.js +29 -0
  13. package/dist/config/loader.js.map +1 -0
  14. package/dist/config/schema.d.ts +36 -0
  15. package/dist/config/schema.d.ts.map +1 -0
  16. package/dist/config/schema.js +40 -0
  17. package/dist/config/schema.js.map +1 -0
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/server/auth/keys.d.ts +28 -0
  23. package/dist/server/auth/keys.d.ts.map +1 -0
  24. package/dist/server/auth/keys.js +91 -0
  25. package/dist/server/auth/keys.js.map +1 -0
  26. package/dist/server/auth/session.d.ts +9 -0
  27. package/dist/server/auth/session.d.ts.map +1 -0
  28. package/dist/server/auth/session.js +42 -0
  29. package/dist/server/auth/session.js.map +1 -0
  30. package/dist/server/index.d.ts +7 -0
  31. package/dist/server/index.d.ts.map +1 -0
  32. package/dist/server/index.js +253 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/log-buffer.d.ts +20 -0
  35. package/dist/server/log-buffer.d.ts.map +1 -0
  36. package/dist/server/log-buffer.js +33 -0
  37. package/dist/server/log-buffer.js.map +1 -0
  38. package/dist/server/log-store.d.ts +11 -0
  39. package/dist/server/log-store.d.ts.map +1 -0
  40. package/dist/server/log-store.js +40 -0
  41. package/dist/server/log-store.js.map +1 -0
  42. package/dist/server/metadata.d.ts +38 -0
  43. package/dist/server/metadata.d.ts.map +1 -0
  44. package/dist/server/metadata.js +130 -0
  45. package/dist/server/metadata.js.map +1 -0
  46. package/dist/server/middleware/auth.d.ts +12 -0
  47. package/dist/server/middleware/auth.d.ts.map +1 -0
  48. package/dist/server/middleware/auth.js +42 -0
  49. package/dist/server/middleware/auth.js.map +1 -0
  50. package/dist/server/middleware/rate-limit.d.ts +15 -0
  51. package/dist/server/middleware/rate-limit.d.ts.map +1 -0
  52. package/dist/server/middleware/rate-limit.js +36 -0
  53. package/dist/server/middleware/rate-limit.js.map +1 -0
  54. package/dist/server/middleware/scope-guard.d.ts +9 -0
  55. package/dist/server/middleware/scope-guard.d.ts.map +1 -0
  56. package/dist/server/middleware/scope-guard.js +17 -0
  57. package/dist/server/middleware/scope-guard.js.map +1 -0
  58. package/dist/server/routes/broadcasts.d.ts +12 -0
  59. package/dist/server/routes/broadcasts.d.ts.map +1 -0
  60. package/dist/server/routes/broadcasts.js +135 -0
  61. package/dist/server/routes/broadcasts.js.map +1 -0
  62. package/dist/server/routes/health.d.ts +9 -0
  63. package/dist/server/routes/health.d.ts.map +1 -0
  64. package/dist/server/routes/health.js +27 -0
  65. package/dist/server/routes/health.js.map +1 -0
  66. package/dist/server/routes/runs.d.ts +12 -0
  67. package/dist/server/routes/runs.d.ts.map +1 -0
  68. package/dist/server/routes/runs.js +122 -0
  69. package/dist/server/routes/runs.js.map +1 -0
  70. package/dist/server/routes/signals.d.ts +10 -0
  71. package/dist/server/routes/signals.d.ts.map +1 -0
  72. package/dist/server/routes/signals.js +120 -0
  73. package/dist/server/routes/signals.js.map +1 -0
  74. package/dist/server/routes/v1/auth.d.ts +7 -0
  75. package/dist/server/routes/v1/auth.d.ts.map +1 -0
  76. package/dist/server/routes/v1/auth.js +28 -0
  77. package/dist/server/routes/v1/auth.js.map +1 -0
  78. package/dist/server/routes/v1/broadcasts.d.ts +10 -0
  79. package/dist/server/routes/v1/broadcasts.d.ts.map +1 -0
  80. package/dist/server/routes/v1/broadcasts.js +68 -0
  81. package/dist/server/routes/v1/broadcasts.js.map +1 -0
  82. package/dist/server/routes/v1/events.d.ts +7 -0
  83. package/dist/server/routes/v1/events.d.ts.map +1 -0
  84. package/dist/server/routes/v1/events.js +57 -0
  85. package/dist/server/routes/v1/events.js.map +1 -0
  86. package/dist/server/routes/v1/health.d.ts +9 -0
  87. package/dist/server/routes/v1/health.d.ts.map +1 -0
  88. package/dist/server/routes/v1/health.js +31 -0
  89. package/dist/server/routes/v1/health.js.map +1 -0
  90. package/dist/server/routes/v1/keys.d.ts +7 -0
  91. package/dist/server/routes/v1/keys.d.ts.map +1 -0
  92. package/dist/server/routes/v1/keys.js +43 -0
  93. package/dist/server/routes/v1/keys.js.map +1 -0
  94. package/dist/server/routes/v1/runs.d.ts +12 -0
  95. package/dist/server/routes/v1/runs.d.ts.map +1 -0
  96. package/dist/server/routes/v1/runs.js +76 -0
  97. package/dist/server/routes/v1/runs.js.map +1 -0
  98. package/dist/server/routes/v1/signals.d.ts +9 -0
  99. package/dist/server/routes/v1/signals.d.ts.map +1 -0
  100. package/dist/server/routes/v1/signals.js +33 -0
  101. package/dist/server/routes/v1/signals.js.map +1 -0
  102. package/dist/server/routes/v1/trigger.d.ts +12 -0
  103. package/dist/server/routes/v1/trigger.d.ts.map +1 -0
  104. package/dist/server/routes/v1/trigger.js +73 -0
  105. package/dist/server/routes/v1/trigger.js.map +1 -0
  106. package/dist/server/sse.d.ts +19 -0
  107. package/dist/server/sse.d.ts.map +1 -0
  108. package/dist/server/sse.js +51 -0
  109. package/dist/server/sse.js.map +1 -0
  110. package/dist/server/subscriber.d.ts +128 -0
  111. package/dist/server/subscriber.d.ts.map +1 -0
  112. package/dist/server/subscriber.js +246 -0
  113. package/dist/server/subscriber.js.map +1 -0
  114. package/dist/server/ws.d.ts +15 -0
  115. package/dist/server/ws.d.ts.map +1 -0
  116. package/dist/server/ws.js +32 -0
  117. package/dist/server/ws.js.map +1 -0
  118. package/next-env.d.ts +6 -0
  119. package/next.config.ts +10 -0
  120. package/package.json +49 -0
  121. package/src/app/broadcasts/[id]/page.tsx +511 -0
  122. package/src/app/broadcasts/page.tsx +158 -0
  123. package/src/app/components/auth-provider.tsx +75 -0
  124. package/src/app/components/breadcrumb-provider.tsx +18 -0
  125. package/src/app/components/dag-view.tsx +380 -0
  126. package/src/app/components/empty-state.tsx +7 -0
  127. package/src/app/components/json-viewer.tsx +153 -0
  128. package/src/app/components/login-page.tsx +78 -0
  129. package/src/app/components/node-detail.tsx +158 -0
  130. package/src/app/components/pulse-dot.tsx +8 -0
  131. package/src/app/components/relative-time.tsx +34 -0
  132. package/src/app/components/run-table.tsx +96 -0
  133. package/src/app/components/schema-form.tsx +121 -0
  134. package/src/app/components/shell.tsx +203 -0
  135. package/src/app/components/status-badge.tsx +10 -0
  136. package/src/app/components/step-timeline.tsx +134 -0
  137. package/src/app/components/theme-provider.tsx +45 -0
  138. package/src/app/components/workflow-node-sidebar.tsx +68 -0
  139. package/src/app/globals.css +1523 -0
  140. package/src/app/hooks/use-api.ts +129 -0
  141. package/src/app/hooks/use-breadcrumb.ts +37 -0
  142. package/src/app/hooks/use-realtime.ts +68 -0
  143. package/src/app/hooks/use-station.tsx +34 -0
  144. package/src/app/layout.tsx +42 -0
  145. package/src/app/page.tsx +275 -0
  146. package/src/app/runs/[id]/page.tsx +277 -0
  147. package/src/app/signals/[name]/page.tsx +250 -0
  148. package/src/app/signals/page.tsx +99 -0
  149. package/src/cli-main.ts +70 -0
  150. package/src/cli.ts +27 -0
  151. package/src/config/loader.ts +33 -0
  152. package/src/config/schema.ts +80 -0
  153. package/src/index.ts +7 -0
  154. package/src/server/auth/keys.ts +112 -0
  155. package/src/server/auth/session.ts +48 -0
  156. package/src/server/index.ts +296 -0
  157. package/src/server/log-buffer.ts +43 -0
  158. package/src/server/log-store.ts +56 -0
  159. package/src/server/metadata.ts +180 -0
  160. package/src/server/middleware/auth.ts +50 -0
  161. package/src/server/middleware/rate-limit.ts +61 -0
  162. package/src/server/middleware/scope-guard.ts +20 -0
  163. package/src/server/routes/broadcasts.ts +160 -0
  164. package/src/server/routes/health.ts +37 -0
  165. package/src/server/routes/runs.ts +149 -0
  166. package/src/server/routes/signals.ts +153 -0
  167. package/src/server/routes/v1/auth.ts +47 -0
  168. package/src/server/routes/v1/broadcasts.ts +84 -0
  169. package/src/server/routes/v1/events.ts +71 -0
  170. package/src/server/routes/v1/health.ts +41 -0
  171. package/src/server/routes/v1/keys.ts +57 -0
  172. package/src/server/routes/v1/runs.ts +97 -0
  173. package/src/server/routes/v1/signals.ts +44 -0
  174. package/src/server/routes/v1/trigger.ts +111 -0
  175. package/src/server/sse.ts +70 -0
  176. package/src/server/subscriber.ts +288 -0
  177. package/src/server/ws.ts +44 -0
  178. package/station.config.example.ts +16 -0
  179. package/tsconfig.json +12 -0
  180. package/tsconfig.next.json +15 -0
  181. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from "react";
4
+ import { checkAuth, logout as apiLogout } from "../hooks/use-api";
5
+
6
+ interface AuthContextValue {
7
+ authenticated: boolean;
8
+ logout: () => Promise<void>;
9
+ }
10
+
11
+ const AuthContext = createContext<AuthContextValue>({
12
+ authenticated: true,
13
+ logout: async () => {},
14
+ });
15
+
16
+ export function useAuth() {
17
+ return useContext(AuthContext);
18
+ }
19
+
20
+ export function AuthProvider({ children, loginPage }: { children: ReactNode; loginPage: ReactNode }) {
21
+ const [state, setState] = useState<"loading" | "authenticated" | "unauthenticated">("loading");
22
+
23
+ useEffect(() => {
24
+ checkAuth()
25
+ .then(({ authenticated, authRequired }) => {
26
+ if (!authRequired || authenticated) {
27
+ setState("authenticated");
28
+ } else {
29
+ setState("unauthenticated");
30
+ }
31
+ })
32
+ .catch(() => {
33
+ // If auth check fails (server down), show dashboard anyway
34
+ setState("authenticated");
35
+ });
36
+ }, []);
37
+
38
+ const logout = useCallback(async () => {
39
+ await apiLogout();
40
+ setState("unauthenticated");
41
+ }, []);
42
+
43
+ const onLoginSuccess = useCallback(() => {
44
+ setState("authenticated");
45
+ }, []);
46
+
47
+ if (state === "loading") {
48
+ return null;
49
+ }
50
+
51
+ if (state === "unauthenticated") {
52
+ return <LoginPageWrapper onSuccess={onLoginSuccess}>{loginPage}</LoginPageWrapper>;
53
+ }
54
+
55
+ return (
56
+ <AuthContext.Provider value={{ authenticated: true, logout }}>
57
+ {children}
58
+ </AuthContext.Provider>
59
+ );
60
+ }
61
+
62
+ // Internal wrapper to pass onSuccess to the login page via context
63
+ const LoginCallbackContext = createContext<() => void>(() => {});
64
+
65
+ export function useLoginCallback() {
66
+ return useContext(LoginCallbackContext);
67
+ }
68
+
69
+ function LoginPageWrapper({ children, onSuccess }: { children: ReactNode; onSuccess: () => void }) {
70
+ return (
71
+ <LoginCallbackContext.Provider value={onSuccess}>
72
+ {children}
73
+ </LoginCallbackContext.Provider>
74
+ );
75
+ }
@@ -0,0 +1,18 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, type ReactNode } from "react";
4
+ import { BreadcrumbContext, type BreadcrumbSegment } from "../hooks/use-breadcrumb";
5
+
6
+ export function BreadcrumbProvider({ children }: { children: ReactNode }) {
7
+ const [segments, setSegmentsState] = useState<BreadcrumbSegment[]>([]);
8
+ const [activeSection, setActiveSectionState] = useState<string | null>(null);
9
+
10
+ const setSegments = useCallback((s: BreadcrumbSegment[]) => setSegmentsState(s), []);
11
+ const setActiveSection = useCallback((s: string | null) => setActiveSectionState(s), []);
12
+
13
+ return (
14
+ <BreadcrumbContext.Provider value={{ segments, activeSection, setSegments, setActiveSection }}>
15
+ {children}
16
+ </BreadcrumbContext.Provider>
17
+ );
18
+ }
@@ -0,0 +1,380 @@
1
+ "use client";
2
+
3
+ import { useTheme } from "./theme-provider";
4
+
5
+ export interface DagNode {
6
+ name: string;
7
+ signalName: string;
8
+ dependsOn: string[];
9
+ status?: string;
10
+ startedAt?: string;
11
+ completedAt?: string;
12
+ }
13
+
14
+ interface DagViewProps {
15
+ nodes: DagNode[];
16
+ onNodeClick?: (nodeName: string) => void;
17
+ selectedNode?: string;
18
+ compact?: boolean;
19
+ }
20
+
21
+ type StatusColorSet = Record<string, { bar: string; fill: string; stroke: string; text: string }>;
22
+
23
+ export const STATUS_COLORS: StatusColorSet = {
24
+ completed: { bar: "#4A6741", fill: "rgba(74, 103, 65, 0.06)", stroke: "#4A6741", text: "#4A6741" },
25
+ running: { bar: "#6B9962", fill: "rgba(107, 153, 98, 0.08)", stroke: "#6B9962", text: "#4A6741" },
26
+ failed: { bar: "#8B5A2B", fill: "rgba(139, 90, 43, 0.06)", stroke: "#8B5A2B", text: "#8B5A2B" },
27
+ pending: { bar: "#D4CEBF", fill: "#F9F7F3", stroke: "#D4CEBF", text: "#8A8A8E" },
28
+ cancelled: { bar: "#AEAEB2", fill: "rgba(139, 90, 43, 0.03)", stroke: "#AEAEB2", text: "#8A8A8E" },
29
+ skipped: { bar: "#AEAEB2", fill: "transparent", stroke: "#AEAEB2", text: "#8A8A8E" },
30
+ };
31
+
32
+ const DARK_STATUS_COLORS: StatusColorSet = {
33
+ completed: { bar: "#6B9962", fill: "rgba(107, 153, 98, 0.12)", stroke: "#6B9962", text: "#8BB882" },
34
+ running: { bar: "#8BB882", fill: "rgba(139, 185, 130, 0.1)", stroke: "#8BB882", text: "#8BB882" },
35
+ failed: { bar: "#C4834A", fill: "rgba(196, 131, 74, 0.1)", stroke: "#C4834A", text: "#D4975C" },
36
+ pending: { bar: "#4A4A4C", fill: "#252527", stroke: "#4A4A4C", text: "#8A8A8E" },
37
+ cancelled: { bar: "#6A6A6E", fill: "rgba(60, 60, 62, 0.3)", stroke: "#6A6A6E", text: "#8A8A8E" },
38
+ skipped: { bar: "#6A6A6E", fill: "transparent", stroke: "#6A6A6E", text: "#8A8A8E" },
39
+ };
40
+
41
+ const DEFAULT_COLORS = { bar: "#4A6741", fill: "#FFFFFF", stroke: "#D4CEBF", text: "#1C1C1E" };
42
+ const DARK_DEFAULT_COLORS = { bar: "#6B9962", fill: "#222224", stroke: "#4A4A4C", text: "#E8E4DC" };
43
+
44
+ export function useStatusColors() {
45
+ const { theme } = useTheme();
46
+ return theme === "dark" ? DARK_STATUS_COLORS : STATUS_COLORS;
47
+ }
48
+
49
+ const FULL_NODE_WIDTH = 184;
50
+ const FULL_NODE_HEIGHT = 60;
51
+ const COMPACT_NODE_WIDTH = 140;
52
+ const COMPACT_NODE_HEIGHT = 36;
53
+ const STATUS_BAR_HEIGHT = 4;
54
+ const FULL_GAP_X = 48;
55
+ const FULL_GAP_Y = 40;
56
+ const COMPACT_GAP_X = 32;
57
+ const COMPACT_GAP_Y = 28;
58
+ const FULL_PADDING_X = 32;
59
+ const FULL_PADDING_Y = 24;
60
+ const COMPACT_PADDING_X = 20;
61
+ const COMPACT_PADDING_Y = 16;
62
+
63
+ interface NodePosition {
64
+ x: number;
65
+ y: number;
66
+ node: DagNode;
67
+ }
68
+
69
+ function computeLayers(nodes: DagNode[]): DagNode[][] {
70
+ if (nodes.length === 0) return [];
71
+
72
+ const nameToNode = new Map<string, DagNode>();
73
+ for (const node of nodes) {
74
+ nameToNode.set(node.name, node);
75
+ }
76
+
77
+ const tierCache = new Map<string, number>();
78
+
79
+ function getTier(name: string, visiting: Set<string>): number {
80
+ if (tierCache.has(name)) return tierCache.get(name)!;
81
+ if (visiting.has(name)) return 0;
82
+ visiting.add(name);
83
+
84
+ const node = nameToNode.get(name);
85
+ if (!node || node.dependsOn.length === 0) {
86
+ tierCache.set(name, 0);
87
+ return 0;
88
+ }
89
+
90
+ let maxParentTier = 0;
91
+ for (const dep of node.dependsOn) {
92
+ if (nameToNode.has(dep)) {
93
+ maxParentTier = Math.max(maxParentTier, getTier(dep, visiting) + 1);
94
+ }
95
+ }
96
+
97
+ tierCache.set(name, maxParentTier);
98
+ return maxParentTier;
99
+ }
100
+
101
+ for (const node of nodes) {
102
+ getTier(node.name, new Set());
103
+ }
104
+
105
+ const layerMap = new Map<number, DagNode[]>();
106
+ for (const node of nodes) {
107
+ const tier = tierCache.get(node.name) ?? 0;
108
+ if (!layerMap.has(tier)) {
109
+ layerMap.set(tier, []);
110
+ }
111
+ layerMap.get(tier)!.push(node);
112
+ }
113
+
114
+ const maxTier = Math.max(...Array.from(layerMap.keys()), 0);
115
+ const layers: DagNode[][] = [];
116
+ for (let i = 0; i <= maxTier; i++) {
117
+ layers.push(layerMap.get(i) ?? []);
118
+ }
119
+
120
+ return layers.filter((l) => l.length > 0);
121
+ }
122
+
123
+ interface Dims {
124
+ nodeWidth: number;
125
+ nodeHeight: number;
126
+ gapX: number;
127
+ gapY: number;
128
+ paddingX: number;
129
+ paddingY: number;
130
+ }
131
+
132
+ function computePositions(layers: DagNode[][], dims: Dims): {
133
+ positions: Map<string, NodePosition>;
134
+ svgWidth: number;
135
+ svgHeight: number;
136
+ } {
137
+ const positions = new Map<string, NodePosition>();
138
+ if (layers.length === 0) return { positions, svgWidth: 0, svgHeight: 0 };
139
+
140
+ const { nodeWidth, nodeHeight, gapX, gapY, paddingX, paddingY } = dims;
141
+ const maxNodesInLayer = Math.max(1, ...layers.map((l) => l.length));
142
+ const svgWidth = maxNodesInLayer * (nodeWidth + gapX) - gapX + paddingX * 2;
143
+ const svgHeight = layers.length * (nodeHeight + gapY) - gapY + paddingY * 2;
144
+
145
+ for (let layerIdx = 0; layerIdx < layers.length; layerIdx++) {
146
+ const layer = layers[layerIdx];
147
+ const layerWidth = layer.length * (nodeWidth + gapX) - gapX;
148
+ const offsetX = (svgWidth - layerWidth) / 2;
149
+ const y = paddingY + layerIdx * (nodeHeight + gapY);
150
+
151
+ for (let nodeIdx = 0; nodeIdx < layer.length; nodeIdx++) {
152
+ const x = offsetX + nodeIdx * (nodeWidth + gapX);
153
+ positions.set(layer[nodeIdx].name, { x, y, node: layer[nodeIdx] });
154
+ }
155
+ }
156
+
157
+ return { positions, svgWidth, svgHeight };
158
+ }
159
+
160
+ function formatDuration(startedAt?: string, completedAt?: string): string | null {
161
+ if (!startedAt) return null;
162
+ const start = new Date(startedAt).getTime();
163
+ const end = completedAt ? new Date(completedAt).getTime() : Date.now();
164
+ const ms = end - start;
165
+ if (ms < 1000) return `${ms}ms`;
166
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
167
+ return `${(ms / 60_000).toFixed(1)}m`;
168
+ }
169
+
170
+ export { computeLayers };
171
+
172
+ export function DAGView({ nodes, onNodeClick, selectedNode, compact }: DagViewProps) {
173
+ const { theme } = useTheme();
174
+ const isDark = theme === "dark";
175
+ const statusColors = isDark ? DARK_STATUS_COLORS : STATUS_COLORS;
176
+ const defaultColors = isDark ? DARK_DEFAULT_COLORS : DEFAULT_COLORS;
177
+
178
+ if (nodes.length === 0) {
179
+ return (
180
+ <div className="empty-state">
181
+ <p className="empty-state-text">No nodes.</p>
182
+ </div>
183
+ );
184
+ }
185
+
186
+ const nodeWidth = compact ? COMPACT_NODE_WIDTH : FULL_NODE_WIDTH;
187
+ const nodeHeight = compact ? COMPACT_NODE_HEIGHT : FULL_NODE_HEIGHT;
188
+ const gapX = compact ? COMPACT_GAP_X : FULL_GAP_X;
189
+ const gapY = compact ? COMPACT_GAP_Y : FULL_GAP_Y;
190
+ const paddingX = compact ? COMPACT_PADDING_X : FULL_PADDING_X;
191
+ const paddingY = compact ? COMPACT_PADDING_Y : FULL_PADDING_Y;
192
+
193
+ const layers = computeLayers(nodes);
194
+ const { positions, svgWidth, svgHeight } = computePositions(layers, {
195
+ nodeWidth, nodeHeight, gapX, gapY, paddingX, paddingY,
196
+ });
197
+ const isRunMode = nodes.some((n) => n.status !== undefined);
198
+
199
+ const statusMap = new Map<string, string>();
200
+ for (const node of nodes) {
201
+ if (node.status) statusMap.set(node.name, node.status);
202
+ }
203
+
204
+ const edges: { from: string; to: string }[] = [];
205
+ for (const node of nodes) {
206
+ for (const dep of node.dependsOn) {
207
+ if (positions.has(dep)) {
208
+ edges.push({ from: dep, to: node.name });
209
+ }
210
+ }
211
+ }
212
+
213
+ return (
214
+ <div className="dag-container">
215
+ <svg
216
+ width={svgWidth}
217
+ height={svgHeight}
218
+ viewBox={`0 0 ${svgWidth} ${svgHeight}`}
219
+ style={{ display: "block", margin: "0 auto" }}
220
+ >
221
+ <defs>
222
+ <marker id="dag-arrow" viewBox="0 0 6 6" refX="6" refY="3"
223
+ markerWidth="6" markerHeight="6" orient="auto-start-reverse">
224
+ <path d="M 0 0 L 6 3 L 0 6 z" fill={isDark ? "#4A4A4C" : "#D4CEBF"} />
225
+ </marker>
226
+ <marker id="dag-arrow-active" viewBox="0 0 6 6" refX="6" refY="3"
227
+ markerWidth="6" markerHeight="6" orient="auto-start-reverse">
228
+ <path d="M 0 0 L 6 3 L 0 6 z" fill={isDark ? "#6B9962" : "#4A6741"} />
229
+ </marker>
230
+ </defs>
231
+
232
+ {/* Edges */}
233
+ {edges.map((edge) => {
234
+ const fromPos = positions.get(edge.from);
235
+ const toPos = positions.get(edge.to);
236
+ if (!fromPos || !toPos) return null;
237
+
238
+ const sourceCompleted = statusMap.get(edge.from) === "completed";
239
+ const isSkipped = statusMap.get(edge.to) === "skipped";
240
+
241
+ const x1 = fromPos.x + nodeWidth / 2;
242
+ const y1 = fromPos.y + nodeHeight;
243
+ const x2 = toPos.x + nodeWidth / 2;
244
+ const y2 = toPos.y;
245
+ const midY = (y1 + y2) / 2;
246
+ const d = `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`;
247
+
248
+ return (
249
+ <path
250
+ key={`${edge.from}->${edge.to}`}
251
+ d={d}
252
+ className={`dag-edge${sourceCompleted ? " dag-edge--active" : ""}`}
253
+ strokeDasharray={isSkipped ? "4 3" : undefined}
254
+ markerEnd={`url(#${sourceCompleted ? "dag-arrow-active" : "dag-arrow"})`}
255
+ />
256
+ );
257
+ })}
258
+
259
+ {/* Nodes */}
260
+ {Array.from(positions.values()).map(({ x, y, node }) => {
261
+ const hasStatus = node.status !== undefined && node.status !== null;
262
+ const colors = hasStatus
263
+ ? statusColors[node.status!] ?? defaultColors
264
+ : defaultColors;
265
+ const isSkipped = node.status === "skipped";
266
+ const isSelected = selectedNode === node.name;
267
+ const isRunning = node.status === "running";
268
+ const dur = formatDuration(node.startedAt, node.completedAt);
269
+
270
+ const maxNameLen = compact ? 14 : 18;
271
+ const truncName = node.name.length > maxNameLen ? node.name.slice(0, maxNameLen - 1) + "\u2026" : node.name;
272
+ const truncSignal = node.signalName.length > 22 ? node.signalName.slice(0, 21) + "\u2026" : node.signalName;
273
+
274
+ return (
275
+ <g
276
+ key={node.name}
277
+ className={`dag-node${isSelected ? " dag-node--selected" : ""}`}
278
+ onClick={() => onNodeClick?.(node.name)}
279
+ style={{ cursor: onNodeClick ? "pointer" : "default" }}
280
+ role={onNodeClick ? "button" : undefined}
281
+ tabIndex={onNodeClick ? 0 : undefined}
282
+ aria-label={`${node.name}${hasStatus ? ", " + node.status : ""}`}
283
+ onKeyDown={(e) => {
284
+ if (onNodeClick && (e.key === "Enter" || e.key === " ")) {
285
+ e.preventDefault();
286
+ onNodeClick(node.name);
287
+ }
288
+ }}
289
+ >
290
+ {/* Running pulse ring */}
291
+ {isRunning && (
292
+ <rect
293
+ x={x - 3} y={y - 3}
294
+ width={nodeWidth + 6} height={nodeHeight + 6}
295
+ rx={7} fill="none" stroke={isDark ? "#8BB882" : "#6B9962"} strokeWidth={2}
296
+ >
297
+ <animate
298
+ attributeName="opacity"
299
+ values="0;0.5;0"
300
+ dur="2.4s"
301
+ repeatCount="indefinite"
302
+ />
303
+ </rect>
304
+ )}
305
+
306
+ {/* Node body */}
307
+ <rect
308
+ x={x} y={y}
309
+ width={nodeWidth} height={nodeHeight}
310
+ rx={4} fill={colors.fill} stroke={colors.stroke}
311
+ strokeWidth={isSelected ? 2.5 : 1.5}
312
+ strokeDasharray={isSkipped ? "4 3" : undefined}
313
+ />
314
+
315
+ {/* Status bar (top edge) */}
316
+ <rect x={x} y={y} width={nodeWidth} height={STATUS_BAR_HEIGHT} rx={4} fill={colors.bar} />
317
+ <rect x={x} y={y + 2} width={nodeWidth} height={2} fill={colors.bar} />
318
+
319
+ {/* Node name */}
320
+ <text
321
+ x={x + 8} y={compact ? y + (nodeHeight / 2) + 2 : y + STATUS_BAR_HEIGHT + 16}
322
+ fill={colors.text} dominantBaseline="middle"
323
+ style={{ fontSize: compact ? "10px" : "11px", fontFamily: "var(--font-mono)", fontWeight: 500 }}
324
+ >
325
+ {truncName}
326
+ </text>
327
+
328
+ {/* In compact mode, only show status icon on the right */}
329
+ {compact && isRunMode && hasStatus && (
330
+ <text
331
+ x={x + nodeWidth - 8} y={y + (nodeHeight / 2) + 2}
332
+ fill={colors.text} opacity={0.5} dominantBaseline="middle" textAnchor="end"
333
+ style={{ fontSize: "8px", fontFamily: "var(--font-mono)", textTransform: "uppercase" as const, letterSpacing: "0.05em" }}
334
+ >
335
+ {dur ?? node.status}
336
+ </text>
337
+ )}
338
+
339
+ {/* Full mode extras */}
340
+ {!compact && (
341
+ <>
342
+ {/* Duration (top right, run mode only) */}
343
+ {isRunMode && dur && (
344
+ <text
345
+ x={x + nodeWidth - 10} y={y + STATUS_BAR_HEIGHT + 16}
346
+ fill={colors.text} opacity={0.6} dominantBaseline="middle" textAnchor="end"
347
+ style={{ fontSize: "9px", fontFamily: "var(--font-mono)" }}
348
+ >
349
+ {dur}
350
+ </text>
351
+ )}
352
+
353
+ {/* Signal name (bottom left) */}
354
+ <text
355
+ x={x + 10} y={y + nodeHeight - 10}
356
+ fill={colors.text} opacity={0.45} dominantBaseline="middle"
357
+ style={{ fontSize: "9px", fontFamily: "var(--font-mono)" }}
358
+ >
359
+ {truncSignal}
360
+ </text>
361
+
362
+ {/* Status label (bottom right, run mode only) */}
363
+ {isRunMode && hasStatus && (
364
+ <text
365
+ x={x + nodeWidth - 10} y={y + nodeHeight - 10}
366
+ fill={colors.text} opacity={0.5} dominantBaseline="middle" textAnchor="end"
367
+ style={{ fontSize: "8px", fontFamily: "var(--font-mono)", textTransform: "uppercase" as const, letterSpacing: "0.05em" }}
368
+ >
369
+ {node.status}
370
+ </text>
371
+ )}
372
+ </>
373
+ )}
374
+ </g>
375
+ );
376
+ })}
377
+ </svg>
378
+ </div>
379
+ );
380
+ }
@@ -0,0 +1,7 @@
1
+ export function EmptyState({ text }: { text: string }) {
2
+ return (
3
+ <div className="empty-state">
4
+ <p className="empty-state-text">{text}</p>
5
+ </div>
6
+ );
7
+ }
@@ -0,0 +1,153 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback } from "react";
4
+
5
+ function renderValue(value: unknown, depth: number, expandAll: boolean): React.ReactNode {
6
+ if (value === null) return <span className="json-null">null</span>;
7
+ if (typeof value === "boolean") return <span className="json-boolean">{String(value)}</span>;
8
+ if (typeof value === "number") return <span className="json-number">{value}</span>;
9
+ if (typeof value === "string") return <span className="json-string">&quot;{value}&quot;</span>;
10
+
11
+ if (Array.isArray(value)) {
12
+ if (value.length === 0) return <span>[]</span>;
13
+ if (!expandAll && depth > 3) {
14
+ return <span>[{value.length} items]</span>;
15
+ }
16
+ return (
17
+ <span>
18
+ {"[\n"}
19
+ {value.map((item, i) => (
20
+ <span key={i}>
21
+ {" ".repeat(depth + 1)}
22
+ {renderValue(item, depth + 1, expandAll)}
23
+ {i < value.length - 1 ? "," : ""}
24
+ {"\n"}
25
+ </span>
26
+ ))}
27
+ {" ".repeat(depth)}
28
+ {"]"}
29
+ </span>
30
+ );
31
+ }
32
+
33
+ if (typeof value === "object") {
34
+ const entries = Object.entries(value as Record<string, unknown>);
35
+ if (entries.length === 0) return <span>{"{}"}</span>;
36
+ if (!expandAll && depth > 3) {
37
+ return <span>{`{${entries.length} keys}`}</span>;
38
+ }
39
+ return (
40
+ <span>
41
+ {"{\n"}
42
+ {entries.map(([key, val], i) => (
43
+ <span key={key}>
44
+ {" ".repeat(depth + 1)}
45
+ <span className="json-key">&quot;{key}&quot;</span>: {renderValue(val, depth + 1, expandAll)}
46
+ {i < entries.length - 1 ? "," : ""}
47
+ {"\n"}
48
+ </span>
49
+ ))}
50
+ {" ".repeat(depth)}
51
+ {"}"}
52
+ </span>
53
+ );
54
+ }
55
+
56
+ return <span>{String(value)}</span>;
57
+ }
58
+
59
+ function getRawText(data: string): string {
60
+ try {
61
+ const parsed = JSON.parse(data);
62
+ return JSON.stringify(parsed, null, 2);
63
+ } catch {
64
+ return data;
65
+ }
66
+ }
67
+
68
+ export function JsonViewer({ data, label }: { data: string | undefined | null; label?: string }) {
69
+ const [expanded, setExpanded] = useState(true);
70
+ const [expandAll, setExpandAll] = useState(true);
71
+ const [copyLabel, setCopyLabel] = useState("copy");
72
+
73
+ const handleCopy = useCallback(() => {
74
+ if (!data) return;
75
+ const text = getRawText(data);
76
+ navigator.clipboard.writeText(text).then(() => {
77
+ setCopyLabel("copied");
78
+ setTimeout(() => setCopyLabel("copy"), 1500);
79
+ }).catch(() => {
80
+ setCopyLabel("failed");
81
+ setTimeout(() => setCopyLabel("copy"), 1500);
82
+ });
83
+ }, [data]);
84
+
85
+ if (!data) return null;
86
+
87
+ let parsed: unknown;
88
+ try {
89
+ parsed = JSON.parse(data);
90
+ } catch {
91
+ parsed = data;
92
+ }
93
+
94
+ const isDeep =
95
+ typeof parsed === "object" &&
96
+ parsed !== null &&
97
+ JSON.stringify(parsed).length > 200;
98
+
99
+ return (
100
+ <div>
101
+ {label && (
102
+ <button
103
+ onClick={() => setExpanded(!expanded)}
104
+ style={{
105
+ background: "none",
106
+ border: "none",
107
+ cursor: "pointer",
108
+ fontFamily: "var(--font-mono)",
109
+ fontSize: "0.75rem",
110
+ color: "var(--muted)",
111
+ padding: "0.25rem 0",
112
+ textTransform: "uppercase",
113
+ letterSpacing: "0.1em",
114
+ }}
115
+ >
116
+ {expanded ? "- " : "+ "}
117
+ {label}
118
+ </button>
119
+ )}
120
+ {expanded && (
121
+ <pre className="json-viewer" style={{ position: "relative" }}>
122
+ <button className="copy-btn" onClick={handleCopy} type="button">
123
+ {copyLabel}
124
+ </button>
125
+ {isDeep && (
126
+ <button
127
+ onClick={() => setExpandAll(!expandAll)}
128
+ style={{
129
+ position: "absolute",
130
+ top: "0.5rem",
131
+ right: "4rem",
132
+ background: "var(--wire)",
133
+ border: "none",
134
+ borderRadius: "3px",
135
+ padding: "0.25rem 0.5rem",
136
+ fontFamily: "var(--font-mono)",
137
+ fontSize: "0.625rem",
138
+ color: "var(--muted-light)",
139
+ cursor: "pointer",
140
+ textTransform: "uppercase",
141
+ letterSpacing: "0.1em",
142
+ }}
143
+ type="button"
144
+ >
145
+ {expandAll ? "collapse" : "expand"}
146
+ </button>
147
+ )}
148
+ {renderValue(parsed, 0, expandAll)}
149
+ </pre>
150
+ )}
151
+ </div>
152
+ );
153
+ }