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,511 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState, useCallback } from "react";
4
+ import { useParams } from "next/navigation";
5
+ import Link from "next/link";
6
+ import { useApi, type BroadcastMeta, type SchemaField } from "../../hooks/use-api";
7
+ import { useStation } from "../../hooks/use-station";
8
+ import { useBreadcrumb } from "../../hooks/use-breadcrumb";
9
+ import { StatusBadge } from "../../components/status-badge";
10
+ import { DAGView, computeLayers, type DagNode } from "../../components/dag-view";
11
+ import { NodeDetail } from "../../components/node-detail";
12
+ import { WorkflowNodeSidebar } from "../../components/workflow-node-sidebar";
13
+ import { SchemaForm } from "../../components/schema-form";
14
+ import { RelativeTime } from "../../components/relative-time";
15
+
16
+ interface BroadcastLogEntry {
17
+ runId: string;
18
+ signalName: string;
19
+ level: string;
20
+ message: string;
21
+ timestamp: string;
22
+ nodeName: string;
23
+ }
24
+
25
+ function formatMs(ms: number): string {
26
+ if (ms < 1000) return `${ms}ms`;
27
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
28
+ if (ms < 3_600_000) return `${(ms / 60_000).toFixed(1)}m`;
29
+ return `${(ms / 3_600_000).toFixed(1)}h`;
30
+ }
31
+
32
+ function computeDuration(startedAt?: string, completedAt?: string): string {
33
+ if (!startedAt) return "\u2014";
34
+ const start = new Date(startedAt).getTime();
35
+ const end = completedAt ? new Date(completedAt).getTime() : Date.now();
36
+ return formatMs(end - start);
37
+ }
38
+
39
+ export default function BroadcastDetailPage() {
40
+ const params = useParams();
41
+ const id = params.id as string;
42
+ const isUUID = /^[0-9a-f]{8}-/.test(id);
43
+
44
+ if (isUUID) {
45
+ return <BroadcastRunView id={id} />;
46
+ }
47
+ return <BroadcastNameView name={id} />;
48
+ }
49
+
50
+ /* ─── Name View (workflow overview + runs list) ──────────── */
51
+
52
+ function BroadcastNameView({ name }: { name: string }) {
53
+ const decodedName = decodeURIComponent(name);
54
+ const api = useApi();
55
+ const { events } = useStation();
56
+ const [broadcast, setBroadcast] = useState<BroadcastMeta | null>(null);
57
+ const [broadcastRuns, setBroadcastRuns] = useState<any[]>([]);
58
+ const [loading, setLoading] = useState(true);
59
+ const [inputJson, setInputJson] = useState("{}");
60
+ const [triggering, setTriggering] = useState(false);
61
+ const [rootInputSchema, setRootInputSchema] = useState<SchemaField | null>(null);
62
+ const [showTrigger, setShowTrigger] = useState(false);
63
+
64
+ useBreadcrumb(
65
+ [{ label: "Broadcasts", href: "/broadcasts" }, { label: decodedName }],
66
+ "broadcasts",
67
+ );
68
+
69
+ const loadRuns = useCallback(() => {
70
+ api.getBroadcastRuns(name).then((r) => setBroadcastRuns(r.data)).catch((e) => console.error("Failed to refresh broadcast runs:", e));
71
+ }, [name]);
72
+
73
+ useEffect(() => {
74
+ async function load() {
75
+ try {
76
+ const [bcRes, runsRes] = await Promise.all([
77
+ api.getBroadcast(name),
78
+ api.getBroadcastRuns(name),
79
+ ]);
80
+ setBroadcast(bcRes.data);
81
+ setBroadcastRuns(runsRes.data);
82
+ } catch (err: unknown) {
83
+ if (err instanceof Error) {
84
+ console.error("Failed to load broadcast:", err.message);
85
+ }
86
+ }
87
+ setLoading(false);
88
+ }
89
+ load();
90
+ }, [name]);
91
+
92
+ useEffect(() => {
93
+ if (!broadcast) return;
94
+ const rootNodes = broadcast.nodes.filter((n) => n.dependsOn.length === 0);
95
+ if (rootNodes.length === 1) {
96
+ api.getSignal(rootNodes[0].signalName)
97
+ .then((res) => setRootInputSchema(res.data.inputSchema))
98
+ .catch((e) => console.error("Failed to load root input schema:", e));
99
+ }
100
+ }, [broadcast]);
101
+
102
+ useEffect(() => {
103
+ if (events.length === 0) return;
104
+ const latest = events[0];
105
+ if (latest.type.startsWith("broadcast:") || latest.type.startsWith("node:")) {
106
+ loadRuns();
107
+ }
108
+ }, [events.length, loadRuns]);
109
+
110
+ async function handleTrigger() {
111
+ setTriggering(true);
112
+ try {
113
+ const input = JSON.parse(inputJson);
114
+ await api.triggerBroadcast(name, input);
115
+ setInputJson("{}");
116
+ setShowTrigger(false);
117
+ setTimeout(loadRuns, 300);
118
+ } catch (err: unknown) {
119
+ if (err instanceof Error) {
120
+ console.error("Trigger failed:", err.message);
121
+ }
122
+ }
123
+ setTriggering(false);
124
+ }
125
+
126
+ if (loading) {
127
+ return (
128
+ <div>
129
+ <h1 className="page-title">{decodedName}</h1>
130
+ <div className="loading-bar"><div className="loading-bar-fill" /></div>
131
+ </div>
132
+ );
133
+ }
134
+
135
+ if (!broadcast) {
136
+ return (
137
+ <div>
138
+ <h1 className="page-title">{decodedName}</h1>
139
+ <div className="empty-state">
140
+ <p className="empty-state-text">Broadcast not found.</p>
141
+ </div>
142
+ </div>
143
+ );
144
+ }
145
+
146
+ const dagNodes: DagNode[] = broadcast.nodes.map((n) => ({
147
+ name: n.name,
148
+ signalName: n.signalName,
149
+ dependsOn: n.dependsOn,
150
+ }));
151
+
152
+ return (
153
+ <div>
154
+ <div className="page-header">
155
+ <h1 className="page-title" style={{ marginBottom: 0 }}>{decodedName}</h1>
156
+ <div className="page-header-actions">
157
+ <button className="btn btn--primary" onClick={() => setShowTrigger(!showTrigger)}>
158
+ {showTrigger ? "Close" : "Trigger"}
159
+ </button>
160
+ </div>
161
+ </div>
162
+
163
+ {/* Trigger form — collapsible */}
164
+ {showTrigger && (
165
+ <div className="detail-section">
166
+ <SchemaForm
167
+ schema={rootInputSchema}
168
+ value={inputJson}
169
+ onChange={setInputJson}
170
+ />
171
+ <div style={{ marginTop: "0.5rem" }}>
172
+ <button
173
+ className="btn btn--primary"
174
+ onClick={handleTrigger}
175
+ disabled={triggering}
176
+ >
177
+ {triggering ? "Dispatching..." : "Dispatch"}
178
+ </button>
179
+ </div>
180
+ </div>
181
+ )}
182
+
183
+ {/* Runs list — primary content */}
184
+ <div className="detail-section">
185
+ <div className="detail-section-label">Runs</div>
186
+ {broadcastRuns.length === 0 ? (
187
+ <div className="empty-state">
188
+ <p className="empty-state-text">No runs yet. Trigger a broadcast to get started.</p>
189
+ </div>
190
+ ) : (
191
+ <table className="station-table">
192
+ <thead>
193
+ <tr>
194
+ <th>Status</th>
195
+ <th>Run</th>
196
+ <th>Duration</th>
197
+ <th>Created</th>
198
+ </tr>
199
+ </thead>
200
+ <tbody>
201
+ {broadcastRuns.map((run: any, i: number) => (
202
+ <tr
203
+ key={run.id}
204
+ className="reveal-item clickable-row"
205
+ style={{ animationDelay: `${i * 40}ms` }}
206
+ onClick={() => window.location.assign(`/broadcasts/${run.id}`)}
207
+ >
208
+ <td><StatusBadge status={run.status} /></td>
209
+ <td className="mono">{run.id.slice(0, 8)}</td>
210
+ <td className="mono">{computeDuration(run.startedAt, run.completedAt)}</td>
211
+ <td><RelativeTime date={run.createdAt} /></td>
212
+ </tr>
213
+ ))}
214
+ </tbody>
215
+ </table>
216
+ )}
217
+ </div>
218
+
219
+ {/* Configuration */}
220
+ <div className="detail-section">
221
+ <div className="detail-section-label">Configuration</div>
222
+ <div className="config-grid">
223
+ <div className="config-item">
224
+ <span className="config-item-label">Failure Policy</span>
225
+ <span className="config-item-value">{broadcast.failurePolicy}</span>
226
+ </div>
227
+ <div className="config-item">
228
+ <span className="config-item-label">Timeout</span>
229
+ <span className="config-item-value">{broadcast.timeout !== null ? formatMs(broadcast.timeout) : "\u2014"}</span>
230
+ </div>
231
+ <div className="config-item">
232
+ <span className="config-item-label">Schedule</span>
233
+ <span className="config-item-value">{broadcast.interval ?? "Manual trigger"}</span>
234
+ </div>
235
+ </div>
236
+ </div>
237
+
238
+ {/* Workflow definition */}
239
+ <div className="detail-section">
240
+ <div className="detail-section-label">Workflow</div>
241
+ <DAGView nodes={dagNodes} />
242
+ </div>
243
+ </div>
244
+ );
245
+ }
246
+
247
+ /* ─── Run View (GitHub Actions style) ────────────────────── */
248
+
249
+ function BroadcastRunView({ id }: { id: string }) {
250
+ const api = useApi();
251
+ const { events } = useStation();
252
+ const [broadcastRun, setBroadcastRun] = useState<any>(null);
253
+ const [nodeRuns, setNodeRuns] = useState<any[]>([]);
254
+ const [broadcastMeta, setBroadcastMeta] = useState<BroadcastMeta | null>(null);
255
+ const [logs, setLogs] = useState<BroadcastLogEntry[]>([]);
256
+ const [loading, setLoading] = useState(true);
257
+ const [cancelling, setCancelling] = useState(false);
258
+ const [selectedNode, setSelectedNode] = useState<string | null>(null);
259
+ const signalRunIdsRef = useRef<Set<string>>(new Set());
260
+ const autoSelectedRef = useRef(false);
261
+
262
+ useBreadcrumb(
263
+ broadcastRun
264
+ ? [
265
+ { label: "Broadcasts", href: "/broadcasts" },
266
+ { label: broadcastRun.broadcastName, href: `/broadcasts/${encodeURIComponent(broadcastRun.broadcastName)}` },
267
+ { label: `Run ${id.slice(0, 8)}` },
268
+ ]
269
+ : [{ label: "Broadcasts", href: "/broadcasts" }, { label: `Run ${id.slice(0, 8)}` }],
270
+ "broadcasts",
271
+ );
272
+
273
+ useEffect(() => {
274
+ async function load() {
275
+ try {
276
+ const [runRes, nodesRes, logsRes] = await Promise.all([
277
+ api.getBroadcastRun(id),
278
+ api.getBroadcastRunNodes(id),
279
+ api.getBroadcastRunLogs(id),
280
+ ]);
281
+ setBroadcastRun(runRes.data);
282
+ setNodeRuns(nodesRes.data);
283
+ setLogs(logsRes.data);
284
+
285
+ const ids = new Set<string>();
286
+ for (const nr of nodesRes.data) {
287
+ if (nr.signalRunId) ids.add(nr.signalRunId);
288
+ }
289
+ signalRunIdsRef.current = ids;
290
+
291
+ // Auto-select: first failed, then first running, then first node
292
+ if (!autoSelectedRef.current && nodesRes.data.length > 0) {
293
+ autoSelectedRef.current = true;
294
+ const failed = nodesRes.data.find((nr: any) => nr.status === "failed");
295
+ const running = nodesRes.data.find((nr: any) => nr.status === "running");
296
+ setSelectedNode((failed ?? running ?? nodesRes.data[0]).nodeName);
297
+ }
298
+
299
+ if (runRes.data.broadcastName) {
300
+ api.getBroadcast(runRes.data.broadcastName)
301
+ .then((r) => setBroadcastMeta(r.data))
302
+ .catch((e) => console.error("Failed to load broadcast meta:", e));
303
+ }
304
+ } catch (err: unknown) {
305
+ if (err instanceof Error) {
306
+ console.error("Failed to load broadcast run:", err.message);
307
+ }
308
+ }
309
+ setLoading(false);
310
+ }
311
+ load();
312
+ }, [id]);
313
+
314
+ useEffect(() => {
315
+ if (events.length === 0) return;
316
+ const latest = events[0];
317
+
318
+ if (latest.type === "log:output") {
319
+ const eventRunId = latest.data.runId as string;
320
+ if (signalRunIdsRef.current.has(eventRunId)) {
321
+ const node = nodeRuns.find((nr: any) => nr.signalRunId === eventRunId);
322
+ setLogs((prev) => [...prev, {
323
+ runId: eventRunId,
324
+ signalName: latest.data.signalName as string,
325
+ level: latest.data.level as string,
326
+ message: latest.data.message as string,
327
+ timestamp: (latest.data.timestamp as string) ?? latest.timestamp,
328
+ nodeName: node?.nodeName ?? "",
329
+ }]);
330
+ }
331
+ }
332
+
333
+ if (latest.type.startsWith("broadcast:") || latest.type.startsWith("node:")) {
334
+ api.getBroadcastRun(id).then((r) => setBroadcastRun(r.data)).catch((e) => console.error("Failed to refresh broadcast run:", e));
335
+ api.getBroadcastRunNodes(id).then((r) => {
336
+ setNodeRuns(r.data);
337
+ const ids = new Set<string>();
338
+ for (const nr of r.data) {
339
+ if (nr.signalRunId) ids.add(nr.signalRunId);
340
+ }
341
+ signalRunIdsRef.current = ids;
342
+ }).catch((e) => console.error("Failed to refresh node runs:", e));
343
+ }
344
+ }, [events.length, id, nodeRuns]);
345
+
346
+ async function handleCancel() {
347
+ setCancelling(true);
348
+ try {
349
+ await api.cancelBroadcastRun(id);
350
+ const res = await api.getBroadcastRun(id);
351
+ setBroadcastRun(res.data);
352
+ } catch (err: unknown) {
353
+ if (err instanceof Error) {
354
+ console.error("Cancel failed:", err.message);
355
+ }
356
+ }
357
+ setCancelling(false);
358
+ }
359
+
360
+ // Build deps map from broadcast metadata
361
+ const depsMap = useMemo(() => {
362
+ const map = new Map<string, string[]>();
363
+ if (broadcastMeta) {
364
+ for (const node of broadcastMeta.nodes) {
365
+ map.set(node.name, node.dependsOn);
366
+ }
367
+ }
368
+ return map;
369
+ }, [broadcastMeta]);
370
+
371
+ // Build DAG nodes for compact view
372
+ const dagNodes: DagNode[] = useMemo(() =>
373
+ nodeRuns.map((nr: any) => ({
374
+ name: nr.nodeName,
375
+ signalName: nr.signalName,
376
+ dependsOn: depsMap.get(nr.nodeName) ?? [],
377
+ status: nr.status,
378
+ startedAt: nr.startedAt,
379
+ completedAt: nr.completedAt,
380
+ })),
381
+ [nodeRuns, depsMap],
382
+ );
383
+
384
+ // Build sidebar nodes with tiers
385
+ const sidebarNodes = useMemo(() => {
386
+ if (dagNodes.length === 0) return [];
387
+ const layers = computeLayers(dagNodes);
388
+ const tierMap = new Map<string, number>();
389
+ layers.forEach((layer, idx) => {
390
+ for (const node of layer) {
391
+ tierMap.set(node.name, idx);
392
+ }
393
+ });
394
+ // Flatten layers to get topological order
395
+ const ordered = layers.flat();
396
+ return ordered.map((node) => {
397
+ const nr = nodeRuns.find((r: any) => r.nodeName === node.name);
398
+ return {
399
+ nodeName: node.name,
400
+ signalName: node.signalName,
401
+ status: nr?.status ?? "pending",
402
+ startedAt: nr?.startedAt,
403
+ completedAt: nr?.completedAt,
404
+ tier: tierMap.get(node.name) ?? 0,
405
+ };
406
+ });
407
+ }, [dagNodes, nodeRuns]);
408
+
409
+ // Filter logs for selected node
410
+ const selectedNodeLogs = useMemo(() => {
411
+ if (!selectedNode) return [];
412
+ return logs
413
+ .filter((l) => l.nodeName === selectedNode)
414
+ .map(({ level, message, timestamp }) => ({ level, message, timestamp }));
415
+ }, [logs, selectedNode]);
416
+
417
+ const selectedNodeRun = selectedNode
418
+ ? nodeRuns.find((nr: any) => nr.nodeName === selectedNode)
419
+ : null;
420
+
421
+ if (loading) {
422
+ return (
423
+ <div>
424
+ <h1 className="page-title">Broadcast Run</h1>
425
+ <div className="loading-bar"><div className="loading-bar-fill" /></div>
426
+ </div>
427
+ );
428
+ }
429
+
430
+ if (!broadcastRun) {
431
+ return (
432
+ <div>
433
+ <h1 className="page-title">Broadcast Run</h1>
434
+ <div className="empty-state">
435
+ <p className="empty-state-text">Broadcast run not found.</p>
436
+ </div>
437
+ </div>
438
+ );
439
+ }
440
+
441
+ const canCancel = broadcastRun.status === "pending" || broadcastRun.status === "running";
442
+
443
+ return (
444
+ <div>
445
+ {/* Header with status and metadata inline */}
446
+ <div className="page-header">
447
+ <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
448
+ <StatusBadge status={broadcastRun.status} />
449
+ <Link
450
+ href={`/broadcasts/${encodeURIComponent(broadcastRun.broadcastName)}`}
451
+ className="page-title"
452
+ style={{ marginBottom: 0, textDecoration: "none" }}
453
+ >
454
+ {broadcastRun.broadcastName}
455
+ </Link>
456
+ <span className="mono" style={{ color: "var(--muted)", fontSize: "0.75rem" }}>
457
+ {computeDuration(broadcastRun.startedAt, broadcastRun.completedAt)}
458
+ </span>
459
+ </div>
460
+ <div className="page-header-actions">
461
+ {canCancel && (
462
+ <button className="btn btn--danger" onClick={handleCancel} disabled={cancelling}>
463
+ {cancelling ? "Cancelling..." : "Cancel"}
464
+ </button>
465
+ )}
466
+ </div>
467
+ </div>
468
+
469
+ {broadcastRun.error && (
470
+ <div className="detail-section">
471
+ <div className="error-block">{broadcastRun.error}</div>
472
+ </div>
473
+ )}
474
+
475
+ {/* Compact DAG overview */}
476
+ {dagNodes.length > 0 && (
477
+ <div className="detail-section">
478
+ <DAGView
479
+ nodes={dagNodes}
480
+ onNodeClick={(name) => setSelectedNode(name)}
481
+ selectedNode={selectedNode ?? undefined}
482
+ compact
483
+ />
484
+ </div>
485
+ )}
486
+
487
+ {/* Sidebar + Node detail panel */}
488
+ {sidebarNodes.length > 0 && (
489
+ <div className="workflow-layout">
490
+ <WorkflowNodeSidebar
491
+ nodes={sidebarNodes}
492
+ selectedNode={selectedNode}
493
+ onSelectNode={setSelectedNode}
494
+ />
495
+ {selectedNodeRun ? (
496
+ <NodeDetail
497
+ node={selectedNodeRun}
498
+ logs={selectedNodeLogs}
499
+ />
500
+ ) : (
501
+ <div className="workflow-panel">
502
+ <div className="empty-state">
503
+ <p className="empty-state-text">Select a node to view details.</p>
504
+ </div>
505
+ </div>
506
+ )}
507
+ </div>
508
+ )}
509
+ </div>
510
+ );
511
+ }
@@ -0,0 +1,158 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { useApi, type BroadcastMeta } from "../hooks/use-api";
6
+ import { useBreadcrumb } from "../hooks/use-breadcrumb";
7
+ import { SchemaForm } from "../components/schema-form";
8
+
9
+ function formatMs(ms: number): string {
10
+ if (ms < 1000) return `${ms}ms`;
11
+ if (ms < 60_000) return `${(ms / 1000).toFixed(0)}s`;
12
+ if (ms < 3_600_000) return `${(ms / 60_000).toFixed(0)}m`;
13
+ return `${(ms / 3_600_000).toFixed(0)}h`;
14
+ }
15
+
16
+ export default function BroadcastsPage() {
17
+ const api = useApi();
18
+ const router = useRouter();
19
+ const [broadcasts, setBroadcasts] = useState<BroadcastMeta[]>([]);
20
+ const [loading, setLoading] = useState(true);
21
+ const [triggerTarget, setTriggerTarget] = useState<string | null>(null);
22
+ const [inputJson, setInputJson] = useState("{}");
23
+ const [triggering, setTriggering] = useState(false);
24
+
25
+ useBreadcrumb([{ label: "Broadcasts" }], "broadcasts");
26
+
27
+ useEffect(() => {
28
+ api.getBroadcasts()
29
+ .then((res) => setBroadcasts(res.data))
30
+ .catch((err: unknown) => {
31
+ if (err instanceof Error) {
32
+ console.error("Failed to load broadcasts:", err.message);
33
+ }
34
+ })
35
+ .finally(() => setLoading(false));
36
+ }, []);
37
+
38
+ async function handleTrigger(name: string) {
39
+ setTriggering(true);
40
+ try {
41
+ const input = JSON.parse(inputJson);
42
+ await api.triggerBroadcast(name, input);
43
+ setTriggerTarget(null);
44
+ setInputJson("{}");
45
+ } catch (err: unknown) {
46
+ if (err instanceof Error) {
47
+ console.error("Trigger failed:", err.message);
48
+ }
49
+ }
50
+ setTriggering(false);
51
+ }
52
+
53
+ function toggleTrigger(name: string) {
54
+ if (triggerTarget === name) {
55
+ setTriggerTarget(null);
56
+ setInputJson("{}");
57
+ } else {
58
+ setTriggerTarget(name);
59
+ setInputJson("{}");
60
+ }
61
+ }
62
+
63
+ if (loading) {
64
+ return (
65
+ <div>
66
+ <h1 className="page-title">Broadcasts</h1>
67
+ <div className="loading-bar"><div className="loading-bar-fill" /></div>
68
+ </div>
69
+ );
70
+ }
71
+
72
+ if (broadcasts.length === 0) {
73
+ return (
74
+ <div>
75
+ <h1 className="page-title">Broadcasts</h1>
76
+ <div className="empty-state">
77
+ <p className="empty-state-text">No broadcasts discovered.</p>
78
+ </div>
79
+ </div>
80
+ );
81
+ }
82
+
83
+ return (
84
+ <div>
85
+ <h1 className="page-title">Broadcasts</h1>
86
+
87
+ <table className="station-table">
88
+ <thead>
89
+ <tr>
90
+ <th>Name</th>
91
+ <th>Nodes</th>
92
+ <th>Failure Policy</th>
93
+ <th>Timeout</th>
94
+ <th></th>
95
+ </tr>
96
+ </thead>
97
+ <tbody>
98
+ {broadcasts.map((b, i) => {
99
+ const isOpen = triggerTarget === b.name;
100
+ return isOpen ? (
101
+ <tr key={b.name} className="reveal-item" style={{ animationDelay: `${i * 40}ms` }}>
102
+ <td colSpan={5} style={{ padding: 0 }}>
103
+ <div style={{ padding: "0.75rem" }}>
104
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "0.75rem" }}>
105
+ <span className="mono" style={{ fontWeight: 600 }}>{b.name}</span>
106
+ <span style={{ color: "var(--muted)", fontSize: "0.8125rem" }}>
107
+ {b.nodes.length} nodes / {b.failurePolicy} / {b.timeout !== null ? formatMs(b.timeout) : "\u2014"}
108
+ </span>
109
+ </div>
110
+ <SchemaForm
111
+ schema={null}
112
+ value={inputJson}
113
+ onChange={setInputJson}
114
+ />
115
+ <div style={{ marginTop: "0.5rem", display: "flex", gap: "0.5rem" }}>
116
+ <button
117
+ className="btn btn--primary"
118
+ onClick={() => handleTrigger(b.name)}
119
+ disabled={triggering}
120
+ >
121
+ {triggering ? "Dispatching..." : "Dispatch"}
122
+ </button>
123
+ <button className="btn" onClick={() => toggleTrigger(b.name)}>
124
+ Cancel
125
+ </button>
126
+ </div>
127
+ </div>
128
+ </td>
129
+ </tr>
130
+ ) : (
131
+ <tr
132
+ key={b.name}
133
+ className="reveal-item clickable-row"
134
+ style={{ animationDelay: `${i * 40}ms` }}
135
+ onClick={() => router.push(`/broadcasts/${encodeURIComponent(b.name)}`)}
136
+ >
137
+ <td className="mono">{b.name}</td>
138
+ <td className="mono">{b.nodes.length}</td>
139
+ <td className="mono" style={{ color: "var(--muted)" }}>{b.failurePolicy}</td>
140
+ <td className="mono" style={{ fontSize: "0.8125rem" }}>
141
+ {b.timeout !== null ? formatMs(b.timeout) : "\u2014"}
142
+ </td>
143
+ <td>
144
+ <button
145
+ className="btn btn--sm btn--primary"
146
+ onClick={(e) => { e.stopPropagation(); toggleTrigger(b.name); }}
147
+ >
148
+ Trigger
149
+ </button>
150
+ </td>
151
+ </tr>
152
+ );
153
+ })}
154
+ </tbody>
155
+ </table>
156
+ </div>
157
+ );
158
+ }