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,277 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import { useParams } from "next/navigation";
|
|
5
|
+
import Link from "next/link";
|
|
6
|
+
import { useApi } 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 { StepTimeline } from "../../components/step-timeline";
|
|
11
|
+
import { JsonViewer } from "../../components/json-viewer";
|
|
12
|
+
|
|
13
|
+
interface LogEntry {
|
|
14
|
+
runId: string;
|
|
15
|
+
signalName: string;
|
|
16
|
+
level: string;
|
|
17
|
+
message: string;
|
|
18
|
+
timestamp: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatMs(ms: number): string {
|
|
22
|
+
if (ms < 1000) return `${ms}ms`;
|
|
23
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
24
|
+
if (ms < 3_600_000) return `${(ms / 60_000).toFixed(1)}m`;
|
|
25
|
+
return `${(ms / 3_600_000).toFixed(1)}h`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function computeDuration(startedAt?: string, completedAt?: string): string {
|
|
29
|
+
if (!startedAt) return "\u2014";
|
|
30
|
+
const start = new Date(startedAt).getTime();
|
|
31
|
+
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
|
32
|
+
const ms = end - start;
|
|
33
|
+
return formatMs(ms);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default function RunDetailPage() {
|
|
37
|
+
const params = useParams();
|
|
38
|
+
const id = params.id as string;
|
|
39
|
+
const api = useApi();
|
|
40
|
+
const { events } = useStation();
|
|
41
|
+
const [run, setRun] = useState<any>(null);
|
|
42
|
+
const [steps, setSteps] = useState<any[]>([]);
|
|
43
|
+
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
44
|
+
const [loading, setLoading] = useState(true);
|
|
45
|
+
const [cancelling, setCancelling] = useState(false);
|
|
46
|
+
const [copied, setCopied] = useState(false);
|
|
47
|
+
const logEndRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
|
|
49
|
+
useBreadcrumb(
|
|
50
|
+
run
|
|
51
|
+
? [
|
|
52
|
+
{ label: "Signals", href: "/signals" },
|
|
53
|
+
{ label: run.signalName, href: `/signals/${encodeURIComponent(run.signalName)}` },
|
|
54
|
+
{ label: `Run ${id.slice(0, 8)}` },
|
|
55
|
+
]
|
|
56
|
+
: [{ label: "Signals", href: "/signals" }, { label: `Run ${id.slice(0, 8)}` }],
|
|
57
|
+
"signals",
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
async function load() {
|
|
62
|
+
try {
|
|
63
|
+
const [runRes, stepsRes, logsRes] = await Promise.all([
|
|
64
|
+
api.getRun(id),
|
|
65
|
+
api.getRunSteps(id),
|
|
66
|
+
api.getRunLogs(id),
|
|
67
|
+
]);
|
|
68
|
+
setRun(runRes.data);
|
|
69
|
+
setSteps(stepsRes.data);
|
|
70
|
+
setLogs(logsRes.data);
|
|
71
|
+
} catch (err: unknown) {
|
|
72
|
+
if (err instanceof Error) {
|
|
73
|
+
console.error("Failed to load run:", err.message);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
setLoading(false);
|
|
77
|
+
}
|
|
78
|
+
load();
|
|
79
|
+
}, [id]);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (events.length === 0) return;
|
|
83
|
+
const latest = events[0];
|
|
84
|
+
const eventRunId = (latest.data.run as Record<string, unknown>)?.id ?? (latest.data as Record<string, unknown>)?.runId;
|
|
85
|
+
if (eventRunId === id) {
|
|
86
|
+
if (latest.type === "log:output") {
|
|
87
|
+
setLogs((prev) => [...prev, {
|
|
88
|
+
runId: latest.data.runId as string,
|
|
89
|
+
signalName: latest.data.signalName as string,
|
|
90
|
+
level: latest.data.level as string,
|
|
91
|
+
message: latest.data.message as string,
|
|
92
|
+
timestamp: (latest.data.timestamp as string) ?? latest.timestamp,
|
|
93
|
+
}]);
|
|
94
|
+
} else {
|
|
95
|
+
api.getRun(id).then((r) => setRun(r.data)).catch((e) => console.error("Failed to refresh run:", e));
|
|
96
|
+
api.getRunSteps(id).then((r) => setSteps(r.data)).catch((e) => console.error("Failed to refresh steps:", e));
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}, [events.length, id]);
|
|
100
|
+
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
103
|
+
}, [logs.length]);
|
|
104
|
+
|
|
105
|
+
async function handleCancel() {
|
|
106
|
+
setCancelling(true);
|
|
107
|
+
try {
|
|
108
|
+
await api.cancelRun(id);
|
|
109
|
+
const res = await api.getRun(id);
|
|
110
|
+
setRun(res.data);
|
|
111
|
+
} catch (err: unknown) {
|
|
112
|
+
if (err instanceof Error) {
|
|
113
|
+
console.error("Cancel failed:", err.message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
setCancelling(false);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function handleCopyId() {
|
|
120
|
+
navigator.clipboard.writeText(run.id).then(() => {
|
|
121
|
+
setCopied(true);
|
|
122
|
+
setTimeout(() => setCopied(false), 1500);
|
|
123
|
+
}).catch(() => {});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (loading) {
|
|
127
|
+
return (
|
|
128
|
+
<div>
|
|
129
|
+
<h1 className="page-title">Run</h1>
|
|
130
|
+
<div className="loading-bar"><div className="loading-bar-fill" /></div>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!run) {
|
|
136
|
+
return (
|
|
137
|
+
<div>
|
|
138
|
+
<h1 className="page-title">Run</h1>
|
|
139
|
+
<div className="empty-state">
|
|
140
|
+
<p className="empty-state-text">Run not found.</p>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const canCancel = run.status === "pending" || run.status === "running";
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<div>
|
|
150
|
+
<div className="page-header">
|
|
151
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
152
|
+
<StatusBadge status={run.status} />
|
|
153
|
+
<Link href={`/signals/${run.signalName}`} className="page-title" style={{ marginBottom: 0, textDecoration: "none" }}>
|
|
154
|
+
{run.signalName}
|
|
155
|
+
</Link>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="page-header-actions">
|
|
158
|
+
{canCancel && (
|
|
159
|
+
<button className="btn btn--danger" onClick={handleCancel} disabled={cancelling}>
|
|
160
|
+
{cancelling ? "Cancelling..." : "Cancel"}
|
|
161
|
+
</button>
|
|
162
|
+
)}
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<div className="detail-section">
|
|
167
|
+
<div className="detail-section-label">Metadata</div>
|
|
168
|
+
<div className="detail-grid">
|
|
169
|
+
<span className="detail-label">ID</span>
|
|
170
|
+
<span className="detail-value mono" style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
171
|
+
{run.id}
|
|
172
|
+
<button
|
|
173
|
+
onClick={handleCopyId}
|
|
174
|
+
className="btn btn--sm"
|
|
175
|
+
style={{
|
|
176
|
+
fontSize: "0.625rem",
|
|
177
|
+
padding: "0.125rem 0.375rem",
|
|
178
|
+
lineHeight: 1.2,
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{copied ? "copied" : "copy"}
|
|
182
|
+
</button>
|
|
183
|
+
</span>
|
|
184
|
+
|
|
185
|
+
<span className="detail-label">Signal</span>
|
|
186
|
+
<span className="detail-value">
|
|
187
|
+
<Link href={`/signals/${run.signalName}`} className="mono meta-value--link">
|
|
188
|
+
{run.signalName}
|
|
189
|
+
</Link>
|
|
190
|
+
</span>
|
|
191
|
+
|
|
192
|
+
<span className="detail-label">Kind</span>
|
|
193
|
+
<span className="detail-value">{run.kind}</span>
|
|
194
|
+
|
|
195
|
+
<span className="detail-label">Attempts</span>
|
|
196
|
+
<span className="detail-value">{run.attempts} of {run.maxAttempts}</span>
|
|
197
|
+
|
|
198
|
+
<span className="detail-label">Timeout</span>
|
|
199
|
+
<span className="detail-value mono">{run.timeout ? formatMs(run.timeout) : "\u2014"}</span>
|
|
200
|
+
|
|
201
|
+
<span className="detail-label">Duration</span>
|
|
202
|
+
<span className="detail-value mono">{computeDuration(run.startedAt, run.completedAt)}</span>
|
|
203
|
+
|
|
204
|
+
<span className="detail-label">Created</span>
|
|
205
|
+
<span className="detail-value mono">{run.createdAt}</span>
|
|
206
|
+
|
|
207
|
+
<span className="detail-label">Started</span>
|
|
208
|
+
<span className="detail-value mono">{run.startedAt ?? "\u2014"}</span>
|
|
209
|
+
|
|
210
|
+
<span className="detail-label">Completed</span>
|
|
211
|
+
<span className="detail-value mono">{run.completedAt ?? "\u2014"}</span>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{run.error && (
|
|
216
|
+
<div className="detail-section">
|
|
217
|
+
<div className="detail-section-label">Error</div>
|
|
218
|
+
<div className="error-block">{run.error}</div>
|
|
219
|
+
</div>
|
|
220
|
+
)}
|
|
221
|
+
|
|
222
|
+
<div className="detail-section">
|
|
223
|
+
<div className="detail-section-label">Input</div>
|
|
224
|
+
<JsonViewer data={run.input} />
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
{run.output && (
|
|
228
|
+
<div className="detail-section">
|
|
229
|
+
<div className="detail-section-label">Output</div>
|
|
230
|
+
<JsonViewer data={run.output} />
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{steps.length > 0 && (
|
|
235
|
+
<div className="detail-section">
|
|
236
|
+
<div className="detail-section-label">Steps</div>
|
|
237
|
+
<StepTimeline steps={steps} />
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
<div className="detail-section">
|
|
242
|
+
<div className="detail-section-label">Logs</div>
|
|
243
|
+
<div className="log-container">
|
|
244
|
+
{logs.length === 0 ? (
|
|
245
|
+
<div style={{
|
|
246
|
+
padding: "1rem",
|
|
247
|
+
color: "var(--muted)",
|
|
248
|
+
fontFamily: "var(--font-mono)",
|
|
249
|
+
fontSize: "0.75rem",
|
|
250
|
+
}}>
|
|
251
|
+
{run.status === "pending" ? "Waiting for execution..." : "No log output captured."}
|
|
252
|
+
</div>
|
|
253
|
+
) : (
|
|
254
|
+
logs.map((log, i) => (
|
|
255
|
+
<div
|
|
256
|
+
key={i}
|
|
257
|
+
className="log-line"
|
|
258
|
+
style={{
|
|
259
|
+
color: log.level === "stderr" ? "var(--rust)" : "var(--charcoal)",
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
262
|
+
<span className="log-timestamp">
|
|
263
|
+
{new Date(log.timestamp).toLocaleTimeString()}
|
|
264
|
+
</span>
|
|
265
|
+
<span className="log-level" data-level={log.level}>
|
|
266
|
+
{log.level === "stderr" ? "ERR" : "OUT"}
|
|
267
|
+
</span>
|
|
268
|
+
<span className="log-message">{log.message}</span>
|
|
269
|
+
</div>
|
|
270
|
+
))
|
|
271
|
+
)}
|
|
272
|
+
<div ref={logEndRef} />
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
);
|
|
277
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback } from "react";
|
|
4
|
+
import { useParams } from "next/navigation";
|
|
5
|
+
import { useApi, type SignalMeta, type SchemaField } from "../../hooks/use-api";
|
|
6
|
+
import { useStation } from "../../hooks/use-station";
|
|
7
|
+
import { useBreadcrumb } from "../../hooks/use-breadcrumb";
|
|
8
|
+
import { RunTable } from "../../components/run-table";
|
|
9
|
+
import { SchemaForm } from "../../components/schema-form";
|
|
10
|
+
|
|
11
|
+
function formatMs(ms: number): string {
|
|
12
|
+
if (ms < 1000) return `${ms}ms`;
|
|
13
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(0)}s`;
|
|
14
|
+
if (ms < 3_600_000) return `${(ms / 60_000).toFixed(0)}m`;
|
|
15
|
+
return `${(ms / 3_600_000).toFixed(0)}h`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function renderSchemaInline(schema: SchemaField): string {
|
|
19
|
+
if (schema.type === "object" && schema.properties) {
|
|
20
|
+
const fields = Object.entries(schema.properties)
|
|
21
|
+
.map(([key, field]) => `${key}: ${field.type}${field.required ? "" : "?"}`)
|
|
22
|
+
.join(", ");
|
|
23
|
+
return `{ ${fields} }`;
|
|
24
|
+
}
|
|
25
|
+
if (schema.type === "array" && schema.items) {
|
|
26
|
+
return `${renderSchemaInline(schema.items)}[]`;
|
|
27
|
+
}
|
|
28
|
+
if (schema.type === "enum" && schema.values) {
|
|
29
|
+
return schema.values.map((v) => `"${v}"`).join(" | ");
|
|
30
|
+
}
|
|
31
|
+
return schema.type;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const STATUS_FILTERS = ["all", "completed", "failed", "running", "pending"] as const;
|
|
35
|
+
type StatusFilter = (typeof STATUS_FILTERS)[number];
|
|
36
|
+
|
|
37
|
+
export default function SignalDetailPage() {
|
|
38
|
+
const params = useParams();
|
|
39
|
+
const name = params.name as string;
|
|
40
|
+
const decodedName = decodeURIComponent(name);
|
|
41
|
+
const api = useApi();
|
|
42
|
+
const { events } = useStation();
|
|
43
|
+
|
|
44
|
+
const [signal, setSignal] = useState<SignalMeta | null>(null);
|
|
45
|
+
const [runs, setRuns] = useState<any[]>([]);
|
|
46
|
+
const [loading, setLoading] = useState(true);
|
|
47
|
+
const [triggering, setTriggering] = useState(false);
|
|
48
|
+
const [inputJson, setInputJson] = useState("{}");
|
|
49
|
+
const [filter, setFilter] = useState<StatusFilter>("all");
|
|
50
|
+
|
|
51
|
+
useBreadcrumb(
|
|
52
|
+
[{ label: "Signals", href: "/signals" }, { label: decodedName }],
|
|
53
|
+
"signals",
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const loadRuns = useCallback(() => {
|
|
57
|
+
api.getSignalRuns(name).then((r) => setRuns(r.data)).catch((e) => console.error("Failed to refresh runs:", e));
|
|
58
|
+
}, [name]);
|
|
59
|
+
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
async function load() {
|
|
62
|
+
try {
|
|
63
|
+
const [signalRes, runsRes] = await Promise.all([
|
|
64
|
+
api.getSignal(name),
|
|
65
|
+
api.getSignalRuns(name),
|
|
66
|
+
]);
|
|
67
|
+
setSignal(signalRes.data);
|
|
68
|
+
setRuns(runsRes.data);
|
|
69
|
+
} catch (err: unknown) {
|
|
70
|
+
if (err instanceof Error) {
|
|
71
|
+
console.error("Failed to load signal:", err.message);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
setLoading(false);
|
|
75
|
+
}
|
|
76
|
+
load();
|
|
77
|
+
}, [name]);
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (events.length === 0) return;
|
|
81
|
+
const latest = events[0];
|
|
82
|
+
if (latest.type.startsWith("run:")) {
|
|
83
|
+
const eventSignal =
|
|
84
|
+
(latest.data.run as Record<string, unknown>)?.signalName ??
|
|
85
|
+
(latest.data as Record<string, unknown>).signalName;
|
|
86
|
+
if (eventSignal === decodedName) {
|
|
87
|
+
loadRuns();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}, [events.length, decodedName, loadRuns]);
|
|
91
|
+
|
|
92
|
+
async function handleTrigger() {
|
|
93
|
+
setTriggering(true);
|
|
94
|
+
try {
|
|
95
|
+
const input = JSON.parse(inputJson);
|
|
96
|
+
await api.triggerSignal(name, input);
|
|
97
|
+
setInputJson("{}");
|
|
98
|
+
setTimeout(loadRuns, 300);
|
|
99
|
+
} catch (err: unknown) {
|
|
100
|
+
if (err instanceof Error) {
|
|
101
|
+
console.error("Trigger failed:", err.message);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
setTriggering(false);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (loading) {
|
|
108
|
+
return (
|
|
109
|
+
<div>
|
|
110
|
+
<h1 className="page-title">{decodedName}</h1>
|
|
111
|
+
<div className="loading-bar"><div className="loading-bar-fill" /></div>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!signal) {
|
|
117
|
+
return (
|
|
118
|
+
<div>
|
|
119
|
+
<h1 className="page-title">{decodedName}</h1>
|
|
120
|
+
<div className="empty-state">
|
|
121
|
+
<p className="empty-state-text">Signal not found.</p>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const hasSchema = signal.inputSchema !== null || signal.outputSchema !== null;
|
|
128
|
+
const filteredRuns = filter === "all" ? runs : runs.filter((r) => r.status === filter);
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<div>
|
|
132
|
+
<div className="page-header">
|
|
133
|
+
<h1 className="page-title" style={{ marginBottom: 0 }}>{decodedName}</h1>
|
|
134
|
+
<div className="page-header-actions">
|
|
135
|
+
<button className="btn btn--primary" onClick={handleTrigger} disabled={triggering}>
|
|
136
|
+
{triggering ? "Dispatching..." : "Trigger"}
|
|
137
|
+
</button>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<div className="detail-section">
|
|
142
|
+
<div className="detail-section-label">Configuration</div>
|
|
143
|
+
<div className="config-grid">
|
|
144
|
+
<div className="config-item">
|
|
145
|
+
<span className="config-item-label">Schedule</span>
|
|
146
|
+
<span className="config-item-value">{signal.interval ?? "Manual trigger"}</span>
|
|
147
|
+
</div>
|
|
148
|
+
<div className="config-item">
|
|
149
|
+
<span className="config-item-label">Timeout</span>
|
|
150
|
+
<span className="config-item-value">{formatMs(signal.timeout)}</span>
|
|
151
|
+
</div>
|
|
152
|
+
<div className="config-item">
|
|
153
|
+
<span className="config-item-label">Max Attempts</span>
|
|
154
|
+
<span className="config-item-value">{signal.maxAttempts}</span>
|
|
155
|
+
</div>
|
|
156
|
+
<div className="config-item">
|
|
157
|
+
<span className="config-item-label">Max Concurrency</span>
|
|
158
|
+
<span className="config-item-value">{signal.maxConcurrency ?? "\u2014"}</span>
|
|
159
|
+
</div>
|
|
160
|
+
<div className="config-item">
|
|
161
|
+
<span className="config-item-label">Steps</span>
|
|
162
|
+
<span className="config-item-value">
|
|
163
|
+
{signal.hasSteps ? signal.stepNames.join(", ") : "Single handler"}
|
|
164
|
+
</span>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{hasSchema && (
|
|
170
|
+
<div className="detail-section">
|
|
171
|
+
<div className="detail-section-label">Schema</div>
|
|
172
|
+
<div className="schema-pair">
|
|
173
|
+
<div>
|
|
174
|
+
<div style={{
|
|
175
|
+
fontFamily: "var(--font-mono)",
|
|
176
|
+
fontSize: "0.6875rem",
|
|
177
|
+
textTransform: "uppercase",
|
|
178
|
+
letterSpacing: "0.08em",
|
|
179
|
+
color: "var(--muted)",
|
|
180
|
+
marginBottom: "0.5rem",
|
|
181
|
+
}}>
|
|
182
|
+
Input
|
|
183
|
+
</div>
|
|
184
|
+
{signal.inputSchema ? (
|
|
185
|
+
<pre className="json-viewer" style={{ fontSize: "0.75rem" }}>
|
|
186
|
+
{renderSchemaInline(signal.inputSchema)}
|
|
187
|
+
</pre>
|
|
188
|
+
) : (
|
|
189
|
+
<span style={{ color: "var(--muted)", fontSize: "0.8125rem" }}>None</span>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
<div>
|
|
193
|
+
<div style={{
|
|
194
|
+
fontFamily: "var(--font-mono)",
|
|
195
|
+
fontSize: "0.6875rem",
|
|
196
|
+
textTransform: "uppercase",
|
|
197
|
+
letterSpacing: "0.08em",
|
|
198
|
+
color: "var(--muted)",
|
|
199
|
+
marginBottom: "0.5rem",
|
|
200
|
+
}}>
|
|
201
|
+
Output
|
|
202
|
+
</div>
|
|
203
|
+
{signal.outputSchema ? (
|
|
204
|
+
<pre className="json-viewer" style={{ fontSize: "0.75rem" }}>
|
|
205
|
+
{renderSchemaInline(signal.outputSchema)}
|
|
206
|
+
</pre>
|
|
207
|
+
) : (
|
|
208
|
+
<span style={{ color: "var(--muted)", fontSize: "0.8125rem" }}>None</span>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
<div className="detail-section">
|
|
216
|
+
<div className="detail-section-label">Trigger</div>
|
|
217
|
+
<SchemaForm
|
|
218
|
+
schema={signal.inputSchema}
|
|
219
|
+
value={inputJson}
|
|
220
|
+
onChange={setInputJson}
|
|
221
|
+
/>
|
|
222
|
+
<div style={{ marginTop: "0.5rem" }}>
|
|
223
|
+
<button
|
|
224
|
+
className="btn btn--primary"
|
|
225
|
+
onClick={handleTrigger}
|
|
226
|
+
disabled={triggering}
|
|
227
|
+
>
|
|
228
|
+
{triggering ? "Dispatching..." : "Dispatch"}
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
<div className="detail-section">
|
|
234
|
+
<div className="detail-section-label">Run History</div>
|
|
235
|
+
<div className="filter-bar">
|
|
236
|
+
{STATUS_FILTERS.map((f) => (
|
|
237
|
+
<button
|
|
238
|
+
key={f}
|
|
239
|
+
className={`filter-btn${filter === f ? " filter-btn--active" : ""}`}
|
|
240
|
+
onClick={() => setFilter(f)}
|
|
241
|
+
>
|
|
242
|
+
{f.charAt(0).toUpperCase() + f.slice(1)}
|
|
243
|
+
</button>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
<RunTable runs={filteredRuns} />
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
);
|
|
250
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useApi, type SignalMeta } from "../hooks/use-api";
|
|
6
|
+
import { useBreadcrumb } from "../hooks/use-breadcrumb";
|
|
7
|
+
|
|
8
|
+
function formatMs(ms: number): string {
|
|
9
|
+
if (ms < 1000) return `${ms}ms`;
|
|
10
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(0)}s`;
|
|
11
|
+
if (ms < 3_600_000) return `${(ms / 60_000).toFixed(0)}m`;
|
|
12
|
+
return `${(ms / 3_600_000).toFixed(0)}h`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function SignalsPage() {
|
|
16
|
+
const api = useApi();
|
|
17
|
+
const router = useRouter();
|
|
18
|
+
const [signals, setSignals] = useState<SignalMeta[]>([]);
|
|
19
|
+
const [loading, setLoading] = useState(true);
|
|
20
|
+
|
|
21
|
+
useBreadcrumb([{ label: "Signals" }], "signals");
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
api.getSignals()
|
|
25
|
+
.then((res) => setSignals(res.data))
|
|
26
|
+
.catch((err: unknown) => {
|
|
27
|
+
if (err instanceof Error) {
|
|
28
|
+
console.error("Failed to load signals:", err.message);
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
.finally(() => setLoading(false));
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
if (loading) {
|
|
35
|
+
return (
|
|
36
|
+
<div>
|
|
37
|
+
<h1 className="page-title">Signals</h1>
|
|
38
|
+
<div className="loading-bar"><div className="loading-bar-fill" /></div>
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (signals.length === 0) {
|
|
44
|
+
return (
|
|
45
|
+
<div>
|
|
46
|
+
<h1 className="page-title">Signals</h1>
|
|
47
|
+
<div className="empty-state">
|
|
48
|
+
<p className="empty-state-text">No signals discovered.</p>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div>
|
|
56
|
+
<h1 className="page-title">Signals</h1>
|
|
57
|
+
|
|
58
|
+
<table className="station-table">
|
|
59
|
+
<thead>
|
|
60
|
+
<tr>
|
|
61
|
+
<th>Name</th>
|
|
62
|
+
<th>Kind</th>
|
|
63
|
+
<th>Schedule</th>
|
|
64
|
+
<th>Timeout</th>
|
|
65
|
+
<th>Retries</th>
|
|
66
|
+
<th>Steps</th>
|
|
67
|
+
</tr>
|
|
68
|
+
</thead>
|
|
69
|
+
<tbody>
|
|
70
|
+
{signals.map((signal, i) => (
|
|
71
|
+
<tr
|
|
72
|
+
key={signal.name}
|
|
73
|
+
className="reveal-item clickable-row"
|
|
74
|
+
style={{ animationDelay: `${i * 40}ms` }}
|
|
75
|
+
onClick={() => router.push(`/signals/${encodeURIComponent(signal.name)}`)}
|
|
76
|
+
>
|
|
77
|
+
<td className="mono">{signal.name}</td>
|
|
78
|
+
<td style={{ color: "var(--muted)", fontSize: "0.8125rem" }}>
|
|
79
|
+
{signal.interval ? "recurring" : "trigger"}
|
|
80
|
+
</td>
|
|
81
|
+
<td className="mono" style={{ color: "var(--muted)", fontSize: "0.8125rem" }}>
|
|
82
|
+
{signal.interval ?? "\u2014"}
|
|
83
|
+
</td>
|
|
84
|
+
<td className="mono" style={{ fontSize: "0.8125rem" }}>
|
|
85
|
+
{formatMs(signal.timeout)}
|
|
86
|
+
</td>
|
|
87
|
+
<td className="mono" style={{ fontSize: "0.8125rem" }}>
|
|
88
|
+
{signal.maxAttempts > 1 ? signal.maxAttempts - 1 : "0"}
|
|
89
|
+
</td>
|
|
90
|
+
<td className="mono" style={{ fontSize: "0.8125rem" }}>
|
|
91
|
+
{signal.hasSteps ? signal.stepNames.length : "\u2014"}
|
|
92
|
+
</td>
|
|
93
|
+
</tr>
|
|
94
|
+
))}
|
|
95
|
+
</tbody>
|
|
96
|
+
</table>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
package/src/cli-main.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { loadConfig } from "./config/loader.js";
|
|
4
|
+
import { createStation } from "./server/index.js";
|
|
5
|
+
|
|
6
|
+
const cwd = process.cwd();
|
|
7
|
+
|
|
8
|
+
const config = await loadConfig(cwd);
|
|
9
|
+
|
|
10
|
+
const station = await createStation(config, cwd);
|
|
11
|
+
await station.start();
|
|
12
|
+
|
|
13
|
+
// Start Next.js dev server as child process
|
|
14
|
+
const nextPort = config.port + 1;
|
|
15
|
+
const stationRoot = resolve(import.meta.dirname, "..");
|
|
16
|
+
|
|
17
|
+
const nextProcess: ChildProcess = spawn(
|
|
18
|
+
"npx",
|
|
19
|
+
["next", "dev", "--port", String(nextPort), "--hostname", config.host],
|
|
20
|
+
{
|
|
21
|
+
cwd: stationRoot,
|
|
22
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
23
|
+
env: {
|
|
24
|
+
...process.env,
|
|
25
|
+
NEXT_PUBLIC_STATION_API: `http://${config.host}:${config.port}`,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
nextProcess.stdout?.on("data", (chunk: Buffer) => {
|
|
31
|
+
const msg = chunk.toString().trim();
|
|
32
|
+
if (msg) console.log(`[station:ui] ${msg}`);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
nextProcess.stderr?.on("data", (chunk: Buffer) => {
|
|
36
|
+
const msg = chunk.toString().trim();
|
|
37
|
+
// Filter out noisy Next.js dev warnings
|
|
38
|
+
if (msg && !msg.includes("ExperimentalWarning")) {
|
|
39
|
+
console.error(`[station:ui] ${msg}`);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
console.log(`[station] Dashboard on http://${config.host}:${nextPort}`);
|
|
44
|
+
|
|
45
|
+
// Open browser
|
|
46
|
+
if (config.open) {
|
|
47
|
+
const url = `http://${config.host}:${nextPort}`;
|
|
48
|
+
const { execFile } = await import("node:child_process");
|
|
49
|
+
const platform = process.platform;
|
|
50
|
+
|
|
51
|
+
await new Promise((res) => setTimeout(() => res(true), 5000));
|
|
52
|
+
if (platform === "darwin") {
|
|
53
|
+
execFile("open", [url]);
|
|
54
|
+
} else if (platform === "linux") {
|
|
55
|
+
execFile("xdg-open", [url]);
|
|
56
|
+
} else if (platform === "win32") {
|
|
57
|
+
execFile("cmd", ["/c", "start", url]);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Graceful shutdown
|
|
62
|
+
const shutdown = async () => {
|
|
63
|
+
console.log("\n[station] Shutting down...");
|
|
64
|
+
nextProcess.kill("SIGTERM");
|
|
65
|
+
await station.stop();
|
|
66
|
+
process.exit(0);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
process.on("SIGINT", shutdown);
|
|
70
|
+
process.on("SIGTERM", shutdown);
|