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.
- package/LICENSE +21 -0
- package/dist/cli-main.d.ts +2 -0
- package/dist/cli-main.d.ts.map +1 -0
- package/dist/cli-main.js +58 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +25 -0
- package/dist/cli.js.map +1 -0
- package/dist/config/loader.d.ts +3 -0
- package/dist/config/loader.d.ts.map +1 -0
- package/dist/config/loader.js +29 -0
- package/dist/config/loader.js.map +1 -0
- package/dist/config/schema.d.ts +36 -0
- package/dist/config/schema.d.ts.map +1 -0
- package/dist/config/schema.js +40 -0
- package/dist/config/schema.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/server/auth/keys.d.ts +28 -0
- package/dist/server/auth/keys.d.ts.map +1 -0
- package/dist/server/auth/keys.js +91 -0
- package/dist/server/auth/keys.js.map +1 -0
- package/dist/server/auth/session.d.ts +9 -0
- package/dist/server/auth/session.d.ts.map +1 -0
- package/dist/server/auth/session.js +42 -0
- package/dist/server/auth/session.js.map +1 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +253 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/log-buffer.d.ts +20 -0
- package/dist/server/log-buffer.d.ts.map +1 -0
- package/dist/server/log-buffer.js +33 -0
- package/dist/server/log-buffer.js.map +1 -0
- package/dist/server/log-store.d.ts +11 -0
- package/dist/server/log-store.d.ts.map +1 -0
- package/dist/server/log-store.js +40 -0
- package/dist/server/log-store.js.map +1 -0
- package/dist/server/metadata.d.ts +38 -0
- package/dist/server/metadata.d.ts.map +1 -0
- package/dist/server/metadata.js +130 -0
- package/dist/server/metadata.js.map +1 -0
- package/dist/server/middleware/auth.d.ts +12 -0
- package/dist/server/middleware/auth.d.ts.map +1 -0
- package/dist/server/middleware/auth.js +42 -0
- package/dist/server/middleware/auth.js.map +1 -0
- package/dist/server/middleware/rate-limit.d.ts +15 -0
- package/dist/server/middleware/rate-limit.d.ts.map +1 -0
- package/dist/server/middleware/rate-limit.js +36 -0
- package/dist/server/middleware/rate-limit.js.map +1 -0
- package/dist/server/middleware/scope-guard.d.ts +9 -0
- package/dist/server/middleware/scope-guard.d.ts.map +1 -0
- package/dist/server/middleware/scope-guard.js +17 -0
- package/dist/server/middleware/scope-guard.js.map +1 -0
- package/dist/server/routes/broadcasts.d.ts +12 -0
- package/dist/server/routes/broadcasts.d.ts.map +1 -0
- package/dist/server/routes/broadcasts.js +135 -0
- package/dist/server/routes/broadcasts.js.map +1 -0
- package/dist/server/routes/health.d.ts +9 -0
- package/dist/server/routes/health.d.ts.map +1 -0
- package/dist/server/routes/health.js +27 -0
- package/dist/server/routes/health.js.map +1 -0
- package/dist/server/routes/runs.d.ts +12 -0
- package/dist/server/routes/runs.d.ts.map +1 -0
- package/dist/server/routes/runs.js +122 -0
- package/dist/server/routes/runs.js.map +1 -0
- package/dist/server/routes/signals.d.ts +10 -0
- package/dist/server/routes/signals.d.ts.map +1 -0
- package/dist/server/routes/signals.js +120 -0
- package/dist/server/routes/signals.js.map +1 -0
- package/dist/server/routes/v1/auth.d.ts +7 -0
- package/dist/server/routes/v1/auth.d.ts.map +1 -0
- package/dist/server/routes/v1/auth.js +28 -0
- package/dist/server/routes/v1/auth.js.map +1 -0
- package/dist/server/routes/v1/broadcasts.d.ts +10 -0
- package/dist/server/routes/v1/broadcasts.d.ts.map +1 -0
- package/dist/server/routes/v1/broadcasts.js +68 -0
- package/dist/server/routes/v1/broadcasts.js.map +1 -0
- package/dist/server/routes/v1/events.d.ts +7 -0
- package/dist/server/routes/v1/events.d.ts.map +1 -0
- package/dist/server/routes/v1/events.js +57 -0
- package/dist/server/routes/v1/events.js.map +1 -0
- package/dist/server/routes/v1/health.d.ts +9 -0
- package/dist/server/routes/v1/health.d.ts.map +1 -0
- package/dist/server/routes/v1/health.js +31 -0
- package/dist/server/routes/v1/health.js.map +1 -0
- package/dist/server/routes/v1/keys.d.ts +7 -0
- package/dist/server/routes/v1/keys.d.ts.map +1 -0
- package/dist/server/routes/v1/keys.js +43 -0
- package/dist/server/routes/v1/keys.js.map +1 -0
- package/dist/server/routes/v1/runs.d.ts +12 -0
- package/dist/server/routes/v1/runs.d.ts.map +1 -0
- package/dist/server/routes/v1/runs.js +76 -0
- package/dist/server/routes/v1/runs.js.map +1 -0
- package/dist/server/routes/v1/signals.d.ts +9 -0
- package/dist/server/routes/v1/signals.d.ts.map +1 -0
- package/dist/server/routes/v1/signals.js +33 -0
- package/dist/server/routes/v1/signals.js.map +1 -0
- package/dist/server/routes/v1/trigger.d.ts +12 -0
- package/dist/server/routes/v1/trigger.d.ts.map +1 -0
- package/dist/server/routes/v1/trigger.js +73 -0
- package/dist/server/routes/v1/trigger.js.map +1 -0
- package/dist/server/sse.d.ts +19 -0
- package/dist/server/sse.d.ts.map +1 -0
- package/dist/server/sse.js +51 -0
- package/dist/server/sse.js.map +1 -0
- package/dist/server/subscriber.d.ts +128 -0
- package/dist/server/subscriber.d.ts.map +1 -0
- package/dist/server/subscriber.js +246 -0
- package/dist/server/subscriber.js.map +1 -0
- package/dist/server/ws.d.ts +15 -0
- package/dist/server/ws.d.ts.map +1 -0
- package/dist/server/ws.js +32 -0
- package/dist/server/ws.js.map +1 -0
- package/next-env.d.ts +6 -0
- package/next.config.ts +10 -0
- package/package.json +49 -0
- package/src/app/broadcasts/[id]/page.tsx +511 -0
- package/src/app/broadcasts/page.tsx +158 -0
- package/src/app/components/auth-provider.tsx +75 -0
- package/src/app/components/breadcrumb-provider.tsx +18 -0
- package/src/app/components/dag-view.tsx +380 -0
- package/src/app/components/empty-state.tsx +7 -0
- package/src/app/components/json-viewer.tsx +153 -0
- package/src/app/components/login-page.tsx +78 -0
- package/src/app/components/node-detail.tsx +158 -0
- package/src/app/components/pulse-dot.tsx +8 -0
- package/src/app/components/relative-time.tsx +34 -0
- package/src/app/components/run-table.tsx +96 -0
- package/src/app/components/schema-form.tsx +121 -0
- package/src/app/components/shell.tsx +203 -0
- package/src/app/components/status-badge.tsx +10 -0
- package/src/app/components/step-timeline.tsx +134 -0
- package/src/app/components/theme-provider.tsx +45 -0
- package/src/app/components/workflow-node-sidebar.tsx +68 -0
- package/src/app/globals.css +1523 -0
- package/src/app/hooks/use-api.ts +129 -0
- package/src/app/hooks/use-breadcrumb.ts +37 -0
- package/src/app/hooks/use-realtime.ts +68 -0
- package/src/app/hooks/use-station.tsx +34 -0
- package/src/app/layout.tsx +42 -0
- package/src/app/page.tsx +275 -0
- package/src/app/runs/[id]/page.tsx +277 -0
- package/src/app/signals/[name]/page.tsx +250 -0
- package/src/app/signals/page.tsx +99 -0
- package/src/cli-main.ts +70 -0
- package/src/cli.ts +27 -0
- package/src/config/loader.ts +33 -0
- package/src/config/schema.ts +80 -0
- package/src/index.ts +7 -0
- package/src/server/auth/keys.ts +112 -0
- package/src/server/auth/session.ts +48 -0
- package/src/server/index.ts +296 -0
- package/src/server/log-buffer.ts +43 -0
- package/src/server/log-store.ts +56 -0
- package/src/server/metadata.ts +180 -0
- package/src/server/middleware/auth.ts +50 -0
- package/src/server/middleware/rate-limit.ts +61 -0
- package/src/server/middleware/scope-guard.ts +20 -0
- package/src/server/routes/broadcasts.ts +160 -0
- package/src/server/routes/health.ts +37 -0
- package/src/server/routes/runs.ts +149 -0
- package/src/server/routes/signals.ts +153 -0
- package/src/server/routes/v1/auth.ts +47 -0
- package/src/server/routes/v1/broadcasts.ts +84 -0
- package/src/server/routes/v1/events.ts +71 -0
- package/src/server/routes/v1/health.ts +41 -0
- package/src/server/routes/v1/keys.ts +57 -0
- package/src/server/routes/v1/runs.ts +97 -0
- package/src/server/routes/v1/signals.ts +44 -0
- package/src/server/routes/v1/trigger.ts +111 -0
- package/src/server/sse.ts +70 -0
- package/src/server/subscriber.ts +288 -0
- package/src/server/ws.ts +44 -0
- package/station.config.example.ts +16 -0
- package/tsconfig.json +12 -0
- package/tsconfig.next.json +15 -0
- 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
|
+
}
|