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,78 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type FormEvent } from "react";
|
|
4
|
+
import { login } from "../hooks/use-api";
|
|
5
|
+
import { useLoginCallback } from "./auth-provider";
|
|
6
|
+
|
|
7
|
+
export function LoginPage() {
|
|
8
|
+
const onSuccess = useLoginCallback();
|
|
9
|
+
const [username, setUsername] = useState("");
|
|
10
|
+
const [password, setPassword] = useState("");
|
|
11
|
+
const [error, setError] = useState("");
|
|
12
|
+
const [loading, setLoading] = useState(false);
|
|
13
|
+
|
|
14
|
+
async function handleSubmit(e: FormEvent) {
|
|
15
|
+
e.preventDefault();
|
|
16
|
+
setError("");
|
|
17
|
+
setLoading(true);
|
|
18
|
+
try {
|
|
19
|
+
const ok = await login(username, password);
|
|
20
|
+
if (ok) {
|
|
21
|
+
onSuccess();
|
|
22
|
+
} else {
|
|
23
|
+
setError("Invalid credentials.");
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
setError("Connection failed.");
|
|
27
|
+
}
|
|
28
|
+
setLoading(false);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="login-page">
|
|
33
|
+
<div className="login-card">
|
|
34
|
+
<div className="login-header">
|
|
35
|
+
<svg width="28" height="28" viewBox="0 0 100 100" fill="none" aria-hidden="true">
|
|
36
|
+
<path d="M50 2 L39 25 L27 50 L18 70 L10 88 L90 88 L82 70 L73 50 L61 25 Z" stroke="currentColor" strokeWidth="1.5" />
|
|
37
|
+
<line x1="50" y1="2" x2="50" y2="88" stroke="currentColor" strokeWidth="1" />
|
|
38
|
+
<line x1="16" y1="70" x2="84" y2="70" stroke="currentColor" strokeWidth="1.2" />
|
|
39
|
+
<line x1="39" y1="25" x2="61" y2="25" stroke="currentColor" strokeWidth="0.8" />
|
|
40
|
+
<line x1="27" y1="50" x2="73" y2="50" stroke="currentColor" strokeWidth="0.8" />
|
|
41
|
+
<rect x="43" y="88" width="14" height="5" fill="currentColor" opacity="0.5" />
|
|
42
|
+
<circle cx="50" cy="2" r="1.5" fill="currentColor" />
|
|
43
|
+
</svg>
|
|
44
|
+
<h1>Station</h1>
|
|
45
|
+
</div>
|
|
46
|
+
<form onSubmit={handleSubmit}>
|
|
47
|
+
<div className="login-field">
|
|
48
|
+
<label htmlFor="username">Username</label>
|
|
49
|
+
<input
|
|
50
|
+
id="username"
|
|
51
|
+
type="text"
|
|
52
|
+
value={username}
|
|
53
|
+
onChange={(e) => setUsername(e.target.value)}
|
|
54
|
+
autoComplete="username"
|
|
55
|
+
autoFocus
|
|
56
|
+
required
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
<div className="login-field">
|
|
60
|
+
<label htmlFor="password">Password</label>
|
|
61
|
+
<input
|
|
62
|
+
id="password"
|
|
63
|
+
type="password"
|
|
64
|
+
value={password}
|
|
65
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
66
|
+
autoComplete="current-password"
|
|
67
|
+
required
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
{error && <div className="login-error">{error}</div>}
|
|
71
|
+
<button type="submit" className="btn btn--primary login-btn" disabled={loading}>
|
|
72
|
+
{loading ? "Signing in..." : "Sign in"}
|
|
73
|
+
</button>
|
|
74
|
+
</form>
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import { StatusBadge } from "./status-badge";
|
|
6
|
+
import { JsonViewer } from "./json-viewer";
|
|
7
|
+
|
|
8
|
+
interface LogEntry {
|
|
9
|
+
level: string;
|
|
10
|
+
message: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface NodeDetailProps {
|
|
15
|
+
node: {
|
|
16
|
+
id: string;
|
|
17
|
+
broadcastRunId: string;
|
|
18
|
+
nodeName: string;
|
|
19
|
+
signalName: string;
|
|
20
|
+
signalRunId?: string;
|
|
21
|
+
status: string;
|
|
22
|
+
skipReason?: string;
|
|
23
|
+
input?: string;
|
|
24
|
+
output?: string;
|
|
25
|
+
error?: string;
|
|
26
|
+
startedAt?: string;
|
|
27
|
+
completedAt?: string;
|
|
28
|
+
};
|
|
29
|
+
logs?: LogEntry[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function duration(startedAt?: string, completedAt?: string): string {
|
|
33
|
+
if (!startedAt) return "-";
|
|
34
|
+
const start = new Date(startedAt).getTime();
|
|
35
|
+
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
|
36
|
+
const ms = end - start;
|
|
37
|
+
if (ms < 1000) return `${ms}ms`;
|
|
38
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
39
|
+
return `${(ms / 60_000).toFixed(1)}m`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function CollapsibleSection({ label, children }: { label: string; children: React.ReactNode }) {
|
|
43
|
+
const [open, setOpen] = useState(false);
|
|
44
|
+
return (
|
|
45
|
+
<div className="detail-section">
|
|
46
|
+
<button
|
|
47
|
+
className="collapsible-header"
|
|
48
|
+
onClick={() => setOpen(!open)}
|
|
49
|
+
>
|
|
50
|
+
<span className="collapsible-chevron">{open ? "\u25BE" : "\u25B8"}</span>
|
|
51
|
+
{label}
|
|
52
|
+
</button>
|
|
53
|
+
{open && <div className="collapsible-content">{children}</div>}
|
|
54
|
+
</div>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function NodeDetail({ node, logs }: NodeDetailProps) {
|
|
59
|
+
const logEndRef = useRef<HTMLDivElement>(null);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
63
|
+
}, [logs?.length]);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div className="workflow-panel">
|
|
67
|
+
<div className="node-detail-header">
|
|
68
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
69
|
+
<span className="node-detail-title">{node.nodeName}</span>
|
|
70
|
+
<StatusBadge status={node.status as "pending" | "running" | "completed" | "failed" | "cancelled" | "skipped"} />
|
|
71
|
+
</div>
|
|
72
|
+
<span className="node-detail-meta">
|
|
73
|
+
{node.signalName} {"\u00B7"} {duration(node.startedAt, node.completedAt)}
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{node.status === "skipped" && node.skipReason && (
|
|
78
|
+
<div className="detail-section">
|
|
79
|
+
<div
|
|
80
|
+
style={{
|
|
81
|
+
fontFamily: "var(--font-mono)",
|
|
82
|
+
fontSize: "0.8125rem",
|
|
83
|
+
color: "var(--muted)",
|
|
84
|
+
padding: "0.5rem 0",
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
Skipped: {node.skipReason}
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{node.error && (
|
|
93
|
+
<div className="detail-section">
|
|
94
|
+
<div className="error-block">{node.error}</div>
|
|
95
|
+
</div>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{/* Logs — primary content */}
|
|
99
|
+
<div className="detail-section">
|
|
100
|
+
<div className="detail-section-label">Logs</div>
|
|
101
|
+
<div className="log-container" style={{ maxHeight: "none", minHeight: "120px" }}>
|
|
102
|
+
{(!logs || logs.length === 0) ? (
|
|
103
|
+
<div style={{
|
|
104
|
+
padding: "1rem",
|
|
105
|
+
color: "var(--muted)",
|
|
106
|
+
fontFamily: "var(--font-mono)",
|
|
107
|
+
fontSize: "0.75rem",
|
|
108
|
+
}}>
|
|
109
|
+
{node.status === "pending" ? "Waiting for execution..." :
|
|
110
|
+
node.status === "skipped" ? "Node was skipped." :
|
|
111
|
+
"No log output captured."}
|
|
112
|
+
</div>
|
|
113
|
+
) : (
|
|
114
|
+
logs.map((log, i) => (
|
|
115
|
+
<div
|
|
116
|
+
key={i}
|
|
117
|
+
className="log-line"
|
|
118
|
+
style={{
|
|
119
|
+
color: log.level === "stderr" ? "var(--rust)" : "var(--charcoal)",
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<span className="log-timestamp">
|
|
123
|
+
{new Date(log.timestamp).toLocaleTimeString()}
|
|
124
|
+
</span>
|
|
125
|
+
<span className="log-level" data-level={log.level}>
|
|
126
|
+
{log.level === "stderr" ? "ERR" : "OUT"}
|
|
127
|
+
</span>
|
|
128
|
+
<span className="log-message">{log.message}</span>
|
|
129
|
+
</div>
|
|
130
|
+
))
|
|
131
|
+
)}
|
|
132
|
+
<div ref={logEndRef} />
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{/* Collapsible I/O */}
|
|
137
|
+
{node.input && (
|
|
138
|
+
<CollapsibleSection label="Input">
|
|
139
|
+
<JsonViewer data={node.input} />
|
|
140
|
+
</CollapsibleSection>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{node.output && (
|
|
144
|
+
<CollapsibleSection label="Output">
|
|
145
|
+
<JsonViewer data={node.output} />
|
|
146
|
+
</CollapsibleSection>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{node.signalRunId && (
|
|
150
|
+
<div className="detail-section" style={{ paddingTop: "0.25rem" }}>
|
|
151
|
+
<Link href={`/runs/${node.signalRunId}`} className="meta-value--link" style={{ fontSize: "0.8125rem" }}>
|
|
152
|
+
View signal run →
|
|
153
|
+
</Link>
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
|
|
5
|
+
function format(date: string): string {
|
|
6
|
+
const ms = Date.now() - new Date(date).getTime();
|
|
7
|
+
if (ms < 0) return "just now";
|
|
8
|
+
const seconds = Math.floor(ms / 1000);
|
|
9
|
+
if (seconds < 5) return "just now";
|
|
10
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
11
|
+
const minutes = Math.floor(seconds / 60);
|
|
12
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
13
|
+
const hours = Math.floor(minutes / 60);
|
|
14
|
+
if (hours < 24) return `${hours}h ago`;
|
|
15
|
+
const days = Math.floor(hours / 24);
|
|
16
|
+
return `${days}d ago`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function RelativeTime({ date }: { date: string }) {
|
|
20
|
+
const [text, setText] = useState(() => format(date));
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const interval = setInterval(() => {
|
|
24
|
+
setText(format(date));
|
|
25
|
+
}, 5000);
|
|
26
|
+
return () => clearInterval(interval);
|
|
27
|
+
}, [date]);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<span className="mono" title={new Date(date).toLocaleString()} style={{ fontSize: "0.8125rem", color: "var(--muted)" }}>
|
|
31
|
+
{text}
|
|
32
|
+
</span>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { StatusBadge } from "./status-badge";
|
|
5
|
+
import { RelativeTime } from "./relative-time";
|
|
6
|
+
|
|
7
|
+
interface Run {
|
|
8
|
+
id: string;
|
|
9
|
+
signalName: string;
|
|
10
|
+
status: string;
|
|
11
|
+
attempts: number;
|
|
12
|
+
maxAttempts: number;
|
|
13
|
+
createdAt: string;
|
|
14
|
+
startedAt?: string;
|
|
15
|
+
completedAt?: string;
|
|
16
|
+
error?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function duration(startedAt?: string, completedAt?: string): string {
|
|
20
|
+
if (!startedAt) return "-";
|
|
21
|
+
const start = new Date(startedAt).getTime();
|
|
22
|
+
const end = completedAt ? new Date(completedAt).getTime() : Date.now();
|
|
23
|
+
const ms = end - start;
|
|
24
|
+
if (ms < 1000) return `${ms}ms`;
|
|
25
|
+
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
|
26
|
+
return `${(ms / 60_000).toFixed(1)}m`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function truncateError(error: string | undefined, maxLen: number): string {
|
|
30
|
+
if (!error) return "-";
|
|
31
|
+
if (error.length <= maxLen) return error;
|
|
32
|
+
return error.slice(0, maxLen) + "\u2026";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function RunTable({ runs }: { runs: Run[] }) {
|
|
36
|
+
const router = useRouter();
|
|
37
|
+
|
|
38
|
+
if (runs.length === 0) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="empty-state">
|
|
41
|
+
<p className="empty-state-text">No runs recorded.</p>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<table className="station-table">
|
|
48
|
+
<thead>
|
|
49
|
+
<tr>
|
|
50
|
+
<th>Status</th>
|
|
51
|
+
<th>Signal</th>
|
|
52
|
+
<th>Run ID</th>
|
|
53
|
+
<th>Duration</th>
|
|
54
|
+
<th>Created</th>
|
|
55
|
+
<th>Error</th>
|
|
56
|
+
</tr>
|
|
57
|
+
</thead>
|
|
58
|
+
<tbody>
|
|
59
|
+
{runs.map((run, i) => (
|
|
60
|
+
<tr
|
|
61
|
+
key={run.id}
|
|
62
|
+
className="reveal-item clickable-row"
|
|
63
|
+
style={{ animationDelay: `${i * 40}ms` }}
|
|
64
|
+
onClick={() => router.push(`/runs/${run.id}`)}
|
|
65
|
+
>
|
|
66
|
+
<td>
|
|
67
|
+
<StatusBadge status={run.status as "pending" | "running" | "completed" | "failed" | "cancelled" | "skipped"} />
|
|
68
|
+
</td>
|
|
69
|
+
<td className="mono">{run.signalName}</td>
|
|
70
|
+
<td className="mono truncate" title={run.id}>
|
|
71
|
+
{run.id.slice(0, 8)}
|
|
72
|
+
</td>
|
|
73
|
+
<td className="mono">{duration(run.startedAt, run.completedAt)}</td>
|
|
74
|
+
<td>
|
|
75
|
+
<RelativeTime date={run.createdAt} />
|
|
76
|
+
</td>
|
|
77
|
+
<td
|
|
78
|
+
style={{
|
|
79
|
+
color: run.error ? "var(--rust)" : "var(--muted)",
|
|
80
|
+
fontFamily: "var(--font-mono)",
|
|
81
|
+
fontSize: "0.75rem",
|
|
82
|
+
maxWidth: "200px",
|
|
83
|
+
overflow: "hidden",
|
|
84
|
+
textOverflow: "ellipsis",
|
|
85
|
+
whiteSpace: "nowrap",
|
|
86
|
+
}}
|
|
87
|
+
title={run.error ?? undefined}
|
|
88
|
+
>
|
|
89
|
+
{truncateError(run.error, 60)}
|
|
90
|
+
</td>
|
|
91
|
+
</tr>
|
|
92
|
+
))}
|
|
93
|
+
</tbody>
|
|
94
|
+
</table>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from "react";
|
|
4
|
+
|
|
5
|
+
interface SchemaField {
|
|
6
|
+
type: string;
|
|
7
|
+
required: boolean;
|
|
8
|
+
properties?: Record<string, SchemaField>;
|
|
9
|
+
items?: SchemaField;
|
|
10
|
+
values?: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SchemaFormProps {
|
|
14
|
+
schema: SchemaField | null;
|
|
15
|
+
value: string;
|
|
16
|
+
onChange: (v: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function generateTemplate(field: SchemaField): unknown {
|
|
20
|
+
switch (field.type) {
|
|
21
|
+
case "string":
|
|
22
|
+
return "";
|
|
23
|
+
case "number":
|
|
24
|
+
case "integer":
|
|
25
|
+
return 0;
|
|
26
|
+
case "boolean":
|
|
27
|
+
return false;
|
|
28
|
+
case "array":
|
|
29
|
+
return [];
|
|
30
|
+
case "enum":
|
|
31
|
+
if (field.values && field.values.length > 0) {
|
|
32
|
+
return field.values[0];
|
|
33
|
+
}
|
|
34
|
+
return "";
|
|
35
|
+
case "object": {
|
|
36
|
+
if (!field.properties) return {};
|
|
37
|
+
const obj: Record<string, unknown> = {};
|
|
38
|
+
for (const [key, childField] of Object.entries(field.properties)) {
|
|
39
|
+
obj[key] = generateTemplate(childField);
|
|
40
|
+
}
|
|
41
|
+
return obj;
|
|
42
|
+
}
|
|
43
|
+
default:
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function SchemaReference({ schema }: { schema: SchemaField }) {
|
|
49
|
+
if (schema.type !== "object" || !schema.properties) return null;
|
|
50
|
+
|
|
51
|
+
const entries = Object.entries(schema.properties);
|
|
52
|
+
if (entries.length === 0) return null;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="schema-ref">
|
|
56
|
+
<div className="schema-ref-title">Expected Input</div>
|
|
57
|
+
{entries.map(([name, field]) => (
|
|
58
|
+
<div key={name} className="schema-field">
|
|
59
|
+
<span className="schema-field-name">{name}</span>
|
|
60
|
+
<span
|
|
61
|
+
className={`schema-field-type${!field.required ? " schema-field-type--optional" : ""}`}
|
|
62
|
+
>
|
|
63
|
+
{field.type}
|
|
64
|
+
</span>
|
|
65
|
+
</div>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function SchemaForm({ schema, value, onChange }: SchemaFormProps) {
|
|
72
|
+
const [parseError, setParseError] = useState<string | null>(null);
|
|
73
|
+
const [initialized, setInitialized] = useState(false);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
if (initialized) return;
|
|
77
|
+
if (schema && value === "{}") {
|
|
78
|
+
const template = generateTemplate(schema);
|
|
79
|
+
const templateStr = JSON.stringify(template, null, 2);
|
|
80
|
+
if (templateStr !== "{}") {
|
|
81
|
+
onChange(templateStr);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
setInitialized(true);
|
|
85
|
+
}, [schema, value, onChange, initialized]);
|
|
86
|
+
|
|
87
|
+
function handleChange(newValue: string) {
|
|
88
|
+
onChange(newValue);
|
|
89
|
+
if (newValue.trim() === "") {
|
|
90
|
+
setParseError(null);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
JSON.parse(newValue);
|
|
95
|
+
setParseError(null);
|
|
96
|
+
} catch (err: unknown) {
|
|
97
|
+
if (err instanceof SyntaxError) {
|
|
98
|
+
setParseError(err.message);
|
|
99
|
+
} else {
|
|
100
|
+
setParseError("Invalid JSON");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div>
|
|
107
|
+
{schema && schema.type === "object" && schema.properties && (
|
|
108
|
+
<SchemaReference schema={schema} />
|
|
109
|
+
)}
|
|
110
|
+
<textarea
|
|
111
|
+
className="input-textarea"
|
|
112
|
+
value={value}
|
|
113
|
+
onChange={(e) => handleChange(e.target.value)}
|
|
114
|
+
rows={6}
|
|
115
|
+
spellCheck={false}
|
|
116
|
+
placeholder="{}"
|
|
117
|
+
/>
|
|
118
|
+
{parseError && <div className="json-parse-error">{parseError}</div>}
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|