station-kit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. package/LICENSE +21 -0
  2. package/dist/cli-main.d.ts +2 -0
  3. package/dist/cli-main.d.ts.map +1 -0
  4. package/dist/cli-main.js +58 -0
  5. package/dist/cli-main.js.map +1 -0
  6. package/dist/cli.d.ts +3 -0
  7. package/dist/cli.d.ts.map +1 -0
  8. package/dist/cli.js +25 -0
  9. package/dist/cli.js.map +1 -0
  10. package/dist/config/loader.d.ts +3 -0
  11. package/dist/config/loader.d.ts.map +1 -0
  12. package/dist/config/loader.js +29 -0
  13. package/dist/config/loader.js.map +1 -0
  14. package/dist/config/schema.d.ts +36 -0
  15. package/dist/config/schema.d.ts.map +1 -0
  16. package/dist/config/schema.js +40 -0
  17. package/dist/config/schema.js.map +1 -0
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +4 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/server/auth/keys.d.ts +28 -0
  23. package/dist/server/auth/keys.d.ts.map +1 -0
  24. package/dist/server/auth/keys.js +91 -0
  25. package/dist/server/auth/keys.js.map +1 -0
  26. package/dist/server/auth/session.d.ts +9 -0
  27. package/dist/server/auth/session.d.ts.map +1 -0
  28. package/dist/server/auth/session.js +42 -0
  29. package/dist/server/auth/session.js.map +1 -0
  30. package/dist/server/index.d.ts +7 -0
  31. package/dist/server/index.d.ts.map +1 -0
  32. package/dist/server/index.js +253 -0
  33. package/dist/server/index.js.map +1 -0
  34. package/dist/server/log-buffer.d.ts +20 -0
  35. package/dist/server/log-buffer.d.ts.map +1 -0
  36. package/dist/server/log-buffer.js +33 -0
  37. package/dist/server/log-buffer.js.map +1 -0
  38. package/dist/server/log-store.d.ts +11 -0
  39. package/dist/server/log-store.d.ts.map +1 -0
  40. package/dist/server/log-store.js +40 -0
  41. package/dist/server/log-store.js.map +1 -0
  42. package/dist/server/metadata.d.ts +38 -0
  43. package/dist/server/metadata.d.ts.map +1 -0
  44. package/dist/server/metadata.js +130 -0
  45. package/dist/server/metadata.js.map +1 -0
  46. package/dist/server/middleware/auth.d.ts +12 -0
  47. package/dist/server/middleware/auth.d.ts.map +1 -0
  48. package/dist/server/middleware/auth.js +42 -0
  49. package/dist/server/middleware/auth.js.map +1 -0
  50. package/dist/server/middleware/rate-limit.d.ts +15 -0
  51. package/dist/server/middleware/rate-limit.d.ts.map +1 -0
  52. package/dist/server/middleware/rate-limit.js +36 -0
  53. package/dist/server/middleware/rate-limit.js.map +1 -0
  54. package/dist/server/middleware/scope-guard.d.ts +9 -0
  55. package/dist/server/middleware/scope-guard.d.ts.map +1 -0
  56. package/dist/server/middleware/scope-guard.js +17 -0
  57. package/dist/server/middleware/scope-guard.js.map +1 -0
  58. package/dist/server/routes/broadcasts.d.ts +12 -0
  59. package/dist/server/routes/broadcasts.d.ts.map +1 -0
  60. package/dist/server/routes/broadcasts.js +135 -0
  61. package/dist/server/routes/broadcasts.js.map +1 -0
  62. package/dist/server/routes/health.d.ts +9 -0
  63. package/dist/server/routes/health.d.ts.map +1 -0
  64. package/dist/server/routes/health.js +27 -0
  65. package/dist/server/routes/health.js.map +1 -0
  66. package/dist/server/routes/runs.d.ts +12 -0
  67. package/dist/server/routes/runs.d.ts.map +1 -0
  68. package/dist/server/routes/runs.js +122 -0
  69. package/dist/server/routes/runs.js.map +1 -0
  70. package/dist/server/routes/signals.d.ts +10 -0
  71. package/dist/server/routes/signals.d.ts.map +1 -0
  72. package/dist/server/routes/signals.js +120 -0
  73. package/dist/server/routes/signals.js.map +1 -0
  74. package/dist/server/routes/v1/auth.d.ts +7 -0
  75. package/dist/server/routes/v1/auth.d.ts.map +1 -0
  76. package/dist/server/routes/v1/auth.js +28 -0
  77. package/dist/server/routes/v1/auth.js.map +1 -0
  78. package/dist/server/routes/v1/broadcasts.d.ts +10 -0
  79. package/dist/server/routes/v1/broadcasts.d.ts.map +1 -0
  80. package/dist/server/routes/v1/broadcasts.js +68 -0
  81. package/dist/server/routes/v1/broadcasts.js.map +1 -0
  82. package/dist/server/routes/v1/events.d.ts +7 -0
  83. package/dist/server/routes/v1/events.d.ts.map +1 -0
  84. package/dist/server/routes/v1/events.js +57 -0
  85. package/dist/server/routes/v1/events.js.map +1 -0
  86. package/dist/server/routes/v1/health.d.ts +9 -0
  87. package/dist/server/routes/v1/health.d.ts.map +1 -0
  88. package/dist/server/routes/v1/health.js +31 -0
  89. package/dist/server/routes/v1/health.js.map +1 -0
  90. package/dist/server/routes/v1/keys.d.ts +7 -0
  91. package/dist/server/routes/v1/keys.d.ts.map +1 -0
  92. package/dist/server/routes/v1/keys.js +43 -0
  93. package/dist/server/routes/v1/keys.js.map +1 -0
  94. package/dist/server/routes/v1/runs.d.ts +12 -0
  95. package/dist/server/routes/v1/runs.d.ts.map +1 -0
  96. package/dist/server/routes/v1/runs.js +76 -0
  97. package/dist/server/routes/v1/runs.js.map +1 -0
  98. package/dist/server/routes/v1/signals.d.ts +9 -0
  99. package/dist/server/routes/v1/signals.d.ts.map +1 -0
  100. package/dist/server/routes/v1/signals.js +33 -0
  101. package/dist/server/routes/v1/signals.js.map +1 -0
  102. package/dist/server/routes/v1/trigger.d.ts +12 -0
  103. package/dist/server/routes/v1/trigger.d.ts.map +1 -0
  104. package/dist/server/routes/v1/trigger.js +73 -0
  105. package/dist/server/routes/v1/trigger.js.map +1 -0
  106. package/dist/server/sse.d.ts +19 -0
  107. package/dist/server/sse.d.ts.map +1 -0
  108. package/dist/server/sse.js +51 -0
  109. package/dist/server/sse.js.map +1 -0
  110. package/dist/server/subscriber.d.ts +128 -0
  111. package/dist/server/subscriber.d.ts.map +1 -0
  112. package/dist/server/subscriber.js +246 -0
  113. package/dist/server/subscriber.js.map +1 -0
  114. package/dist/server/ws.d.ts +15 -0
  115. package/dist/server/ws.d.ts.map +1 -0
  116. package/dist/server/ws.js +32 -0
  117. package/dist/server/ws.js.map +1 -0
  118. package/next-env.d.ts +6 -0
  119. package/next.config.ts +10 -0
  120. package/package.json +49 -0
  121. package/src/app/broadcasts/[id]/page.tsx +511 -0
  122. package/src/app/broadcasts/page.tsx +158 -0
  123. package/src/app/components/auth-provider.tsx +75 -0
  124. package/src/app/components/breadcrumb-provider.tsx +18 -0
  125. package/src/app/components/dag-view.tsx +380 -0
  126. package/src/app/components/empty-state.tsx +7 -0
  127. package/src/app/components/json-viewer.tsx +153 -0
  128. package/src/app/components/login-page.tsx +78 -0
  129. package/src/app/components/node-detail.tsx +158 -0
  130. package/src/app/components/pulse-dot.tsx +8 -0
  131. package/src/app/components/relative-time.tsx +34 -0
  132. package/src/app/components/run-table.tsx +96 -0
  133. package/src/app/components/schema-form.tsx +121 -0
  134. package/src/app/components/shell.tsx +203 -0
  135. package/src/app/components/status-badge.tsx +10 -0
  136. package/src/app/components/step-timeline.tsx +134 -0
  137. package/src/app/components/theme-provider.tsx +45 -0
  138. package/src/app/components/workflow-node-sidebar.tsx +68 -0
  139. package/src/app/globals.css +1523 -0
  140. package/src/app/hooks/use-api.ts +129 -0
  141. package/src/app/hooks/use-breadcrumb.ts +37 -0
  142. package/src/app/hooks/use-realtime.ts +68 -0
  143. package/src/app/hooks/use-station.tsx +34 -0
  144. package/src/app/layout.tsx +42 -0
  145. package/src/app/page.tsx +275 -0
  146. package/src/app/runs/[id]/page.tsx +277 -0
  147. package/src/app/signals/[name]/page.tsx +250 -0
  148. package/src/app/signals/page.tsx +99 -0
  149. package/src/cli-main.ts +70 -0
  150. package/src/cli.ts +27 -0
  151. package/src/config/loader.ts +33 -0
  152. package/src/config/schema.ts +80 -0
  153. package/src/index.ts +7 -0
  154. package/src/server/auth/keys.ts +112 -0
  155. package/src/server/auth/session.ts +48 -0
  156. package/src/server/index.ts +296 -0
  157. package/src/server/log-buffer.ts +43 -0
  158. package/src/server/log-store.ts +56 -0
  159. package/src/server/metadata.ts +180 -0
  160. package/src/server/middleware/auth.ts +50 -0
  161. package/src/server/middleware/rate-limit.ts +61 -0
  162. package/src/server/middleware/scope-guard.ts +20 -0
  163. package/src/server/routes/broadcasts.ts +160 -0
  164. package/src/server/routes/health.ts +37 -0
  165. package/src/server/routes/runs.ts +149 -0
  166. package/src/server/routes/signals.ts +153 -0
  167. package/src/server/routes/v1/auth.ts +47 -0
  168. package/src/server/routes/v1/broadcasts.ts +84 -0
  169. package/src/server/routes/v1/events.ts +71 -0
  170. package/src/server/routes/v1/health.ts +41 -0
  171. package/src/server/routes/v1/keys.ts +57 -0
  172. package/src/server/routes/v1/runs.ts +97 -0
  173. package/src/server/routes/v1/signals.ts +44 -0
  174. package/src/server/routes/v1/trigger.ts +111 -0
  175. package/src/server/sse.ts +70 -0
  176. package/src/server/subscriber.ts +288 -0
  177. package/src/server/ws.ts +44 -0
  178. package/station.config.example.ts +16 -0
  179. package/tsconfig.json +12 -0
  180. package/tsconfig.next.json +15 -0
  181. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,203 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback } from "react";
4
+ import Link from "next/link";
5
+ import { usePathname } from "next/navigation";
6
+ import { useStation } from "../hooks/use-station";
7
+ import { useBreadcrumbContext } from "../hooks/use-breadcrumb";
8
+ import { useTheme } from "./theme-provider";
9
+ import { useAuth } from "./auth-provider";
10
+
11
+ function getInitialCollapsed(): boolean {
12
+ if (typeof window === "undefined") return false;
13
+ return localStorage.getItem("station-sidebar") === "collapsed";
14
+ }
15
+
16
+ /* ── Sidebar Icons ─────────────────────────────────────────
17
+ All icons use currentColor and a consistent 14x14 viewport
18
+ so they inherit text color and align with monospace labels. */
19
+
20
+ function IconTower({ size = 20 }: { size?: number }) {
21
+ return (
22
+ <svg width={size} height={size} viewBox="0 0 100 100" fill="none" aria-hidden="true">
23
+ <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" />
24
+ <line x1="50" y1="2" x2="50" y2="88" stroke="currentColor" strokeWidth="1" />
25
+ <line x1="16" y1="70" x2="84" y2="70" stroke="currentColor" strokeWidth="1.2" />
26
+ <line x1="39" y1="25" x2="61" y2="25" stroke="currentColor" strokeWidth="0.8" />
27
+ <line x1="27" y1="50" x2="73" y2="50" stroke="currentColor" strokeWidth="0.8" />
28
+ <rect x="43" y="88" width="14" height="5" fill="currentColor" opacity="0.5" />
29
+ <circle cx="50" cy="2" r="1.5" fill="currentColor" />
30
+ </svg>
31
+ );
32
+ }
33
+
34
+ function IconOverview() {
35
+ /* 2x2 grid — universal dashboard icon */
36
+ return (
37
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
38
+ <rect x="1" y="1" width="5" height="5" rx="1" />
39
+ <rect x="8" y="1" width="5" height="5" rx="1" />
40
+ <rect x="1" y="8" width="5" height="5" rx="1" />
41
+ <rect x="8" y="8" width="5" height="5" rx="1" />
42
+ </svg>
43
+ );
44
+ }
45
+
46
+ function IconSignals() {
47
+ /* Signal arcs radiating from a point — antenna broadcast */
48
+ return (
49
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" aria-hidden="true">
50
+ <circle cx="7" cy="12" r="1" fill="currentColor" stroke="none" />
51
+ <path d="M4.5 10a3.5 3.5 0 0 1 5 0" />
52
+ <path d="M2.5 8a6 6 0 0 1 9 0" />
53
+ <path d="M0.5 6a8.5 8.5 0 0 1 13 0" />
54
+ </svg>
55
+ );
56
+ }
57
+
58
+ function IconBroadcasts() {
59
+ /* DAG: one root splitting to two children — workflow graph */
60
+ return (
61
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2" aria-hidden="true">
62
+ <circle cx="7" cy="2.5" r="1.5" />
63
+ <circle cx="3" cy="11.5" r="1.5" />
64
+ <circle cx="11" cy="11.5" r="1.5" />
65
+ <line x1="6.2" y1="3.8" x2="3.8" y2="10" />
66
+ <line x1="7.8" y1="3.8" x2="10.2" y2="10" />
67
+ </svg>
68
+ );
69
+ }
70
+
71
+ function NavLink({
72
+ href,
73
+ label,
74
+ section,
75
+ icon,
76
+ }: {
77
+ href: string;
78
+ label: string;
79
+ section: string;
80
+ icon: React.ReactNode;
81
+ }) {
82
+ const pathname = usePathname();
83
+ const { activeSection } = useBreadcrumbContext();
84
+
85
+ const isActive = activeSection
86
+ ? activeSection === section
87
+ : href === "/"
88
+ ? pathname === "/"
89
+ : pathname.startsWith(href);
90
+
91
+ return (
92
+ <Link href={href} className={isActive ? "active" : ""}>
93
+ {icon}
94
+ <span className="nav-label">{label}</span>
95
+ </Link>
96
+ );
97
+ }
98
+
99
+ export function Shell({ children }: { children: React.ReactNode }) {
100
+ const { connected } = useStation();
101
+ const { segments } = useBreadcrumbContext();
102
+ const { theme, toggle } = useTheme();
103
+ const { logout } = useAuth();
104
+ const [collapsed, setCollapsed] = useState(getInitialCollapsed);
105
+
106
+ const toggleSidebar = useCallback(() => {
107
+ setCollapsed((prev) => {
108
+ const next = !prev;
109
+ localStorage.setItem("station-sidebar", next ? "collapsed" : "expanded");
110
+ return next;
111
+ });
112
+ }, []);
113
+
114
+ return (
115
+ <div className="station-layout" data-collapsed={collapsed}>
116
+ <aside className="station-sidebar">
117
+ <div className="station-sidebar-logo">
118
+ <div className="station-sidebar-mark">
119
+ <IconTower size={20} />
120
+ <h1>Station</h1>
121
+ </div>
122
+ <span>station-signal</span>
123
+ </div>
124
+ <nav className="station-sidebar-nav">
125
+ <div className="station-sidebar-nav-label">Nav</div>
126
+ <NavLink href="/" label="Overview" section="overview" icon={<IconOverview />} />
127
+ <NavLink href="/signals" label="Signals" section="signals" icon={<IconSignals />} />
128
+ <NavLink href="/broadcasts" label="Broadcasts" section="broadcasts" icon={<IconBroadcasts />} />
129
+ </nav>
130
+ <button
131
+ className="sidebar-collapse-btn"
132
+ onClick={toggleSidebar}
133
+ title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
134
+ aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
135
+ >
136
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
137
+ {collapsed ? (
138
+ <polyline points="5 3 9 7 5 11" />
139
+ ) : (
140
+ <polyline points="9 3 5 7 9 11" />
141
+ )}
142
+ </svg>
143
+ </button>
144
+ </aside>
145
+ <div className="station-main">
146
+ <header className="station-header">
147
+ <nav className="breadcrumb" aria-label="Breadcrumb">
148
+ {segments.length === 0 ? (
149
+ <span className="breadcrumb-segment">overview</span>
150
+ ) : (
151
+ segments.map((seg, i) => (
152
+ <span key={i} className="breadcrumb-item">
153
+ {i > 0 && <span className="breadcrumb-sep" aria-hidden="true">/</span>}
154
+ {seg.href ? (
155
+ <Link href={seg.href} className="breadcrumb-link">{seg.label}</Link>
156
+ ) : (
157
+ <span className="breadcrumb-segment breadcrumb-segment--current" aria-current="page">{seg.label}</span>
158
+ )}
159
+ </span>
160
+ ))
161
+ )}
162
+ </nav>
163
+ <div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
164
+ <button
165
+ onClick={toggle}
166
+ className="theme-toggle"
167
+ title={theme === "light" ? "Switch to dark mode" : "Switch to light mode"}
168
+ aria-label={theme === "light" ? "Switch to dark mode" : "Switch to light mode"}
169
+ >
170
+ {theme === "light" ? (
171
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
172
+ <path d="M13.5 8.5a5.5 5.5 0 0 1-7-7A5.5 5.5 0 1 0 13.5 8.5Z" />
173
+ </svg>
174
+ ) : (
175
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
176
+ <circle cx="8" cy="8" r="3" />
177
+ <path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.05 3.05l1.41 1.41M11.54 11.54l1.41 1.41M3.05 12.95l1.41-1.41M11.54 4.46l1.41-1.41" />
178
+ </svg>
179
+ )}
180
+ </button>
181
+ <button
182
+ onClick={logout}
183
+ className="logout-btn"
184
+ title="Sign out"
185
+ aria-label="Sign out"
186
+ >
187
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
188
+ <path d="M6 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3" />
189
+ <path d="M10 12l4-4-4-4" />
190
+ <line x1="14" y1="8" x2="6" y2="8" />
191
+ </svg>
192
+ </button>
193
+ <div
194
+ className={`pulse-dot ${connected ? "" : "pulse-dot--disconnected"}`}
195
+ title={connected ? "Connected" : "Disconnected"}
196
+ />
197
+ </div>
198
+ </header>
199
+ <main className="station-content">{children}</main>
200
+ </div>
201
+ </div>
202
+ );
203
+ }
@@ -0,0 +1,10 @@
1
+ type Status = "pending" | "running" | "completed" | "failed" | "cancelled" | "skipped";
2
+
3
+ export function StatusBadge({ status }: { status: Status }) {
4
+ return (
5
+ <span className={`status-badge status-badge--${status}`}>
6
+ <span className="status-badge-dot" />
7
+ {status}
8
+ </span>
9
+ );
10
+ }
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { JsonViewer } from "./json-viewer";
5
+
6
+ interface Step {
7
+ id: string;
8
+ runId: string;
9
+ name: string;
10
+ status: string;
11
+ input?: string;
12
+ output?: string;
13
+ error?: string;
14
+ startedAt?: string;
15
+ completedAt?: string;
16
+ }
17
+
18
+ function duration(startedAt?: string, completedAt?: string): string {
19
+ if (!startedAt) return "";
20
+ const start = new Date(startedAt).getTime();
21
+ const end = completedAt ? new Date(completedAt).getTime() : Date.now();
22
+ const ms = end - start;
23
+ if (ms < 1000) return `${ms}ms`;
24
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
25
+ return `${(ms / 60_000).toFixed(1)}m`;
26
+ }
27
+
28
+ function StepDetail({ step }: { step: Step }) {
29
+ const [inputExpanded, setInputExpanded] = useState(false);
30
+ const [outputExpanded, setOutputExpanded] = useState(false);
31
+
32
+ const hasInput = Boolean(step.input);
33
+ const hasOutput = Boolean(step.output);
34
+ const hasError = Boolean(step.error);
35
+
36
+ if (!hasInput && !hasOutput && !hasError) return null;
37
+
38
+ return (
39
+ <div style={{ marginTop: "0.5rem" }}>
40
+ {hasError && (
41
+ <div className="error-block" style={{ marginBottom: hasInput || hasOutput ? "0.5rem" : 0 }}>
42
+ {step.error}
43
+ </div>
44
+ )}
45
+ {hasInput && (
46
+ <div style={{ marginBottom: hasOutput ? "0.375rem" : 0 }}>
47
+ <button
48
+ onClick={() => setInputExpanded(!inputExpanded)}
49
+ style={{
50
+ background: "none",
51
+ border: "none",
52
+ cursor: "pointer",
53
+ fontFamily: "var(--font-mono)",
54
+ fontSize: "0.6875rem",
55
+ color: "var(--muted)",
56
+ padding: "0.125rem 0",
57
+ textTransform: "uppercase",
58
+ letterSpacing: "0.1em",
59
+ }}
60
+ >
61
+ {inputExpanded ? "- " : "+ "}input
62
+ </button>
63
+ {inputExpanded && <JsonViewer data={step.input} />}
64
+ </div>
65
+ )}
66
+ {hasOutput && (
67
+ <div>
68
+ <button
69
+ onClick={() => setOutputExpanded(!outputExpanded)}
70
+ style={{
71
+ background: "none",
72
+ border: "none",
73
+ cursor: "pointer",
74
+ fontFamily: "var(--font-mono)",
75
+ fontSize: "0.6875rem",
76
+ color: "var(--muted)",
77
+ padding: "0.125rem 0",
78
+ textTransform: "uppercase",
79
+ letterSpacing: "0.1em",
80
+ }}
81
+ >
82
+ {outputExpanded ? "- " : "+ "}output
83
+ </button>
84
+ {outputExpanded && <JsonViewer data={step.output} />}
85
+ </div>
86
+ )}
87
+ </div>
88
+ );
89
+ }
90
+
91
+ export function StepTimeline({ steps }: { steps: Step[] }) {
92
+ if (steps.length === 0) return null;
93
+
94
+ return (
95
+ <div className="step-timeline">
96
+ {steps.map((step, i) => {
97
+ const dur = duration(step.startedAt, step.completedAt);
98
+ return (
99
+ <div
100
+ key={step.id}
101
+ className="step-timeline-item reveal-item"
102
+ style={{ animationDelay: `${i * 80}ms` }}
103
+ >
104
+ <div className={`step-timeline-dot step-timeline-dot--${step.status}`} />
105
+ <div className="step-timeline-name">
106
+ {step.name}
107
+ {dur && (
108
+ <span
109
+ style={{
110
+ marginLeft: "0.5rem",
111
+ fontFamily: "var(--font-mono)",
112
+ fontSize: "0.6875rem",
113
+ color: "var(--muted)",
114
+ }}
115
+ >
116
+ {dur}
117
+ </span>
118
+ )}
119
+ </div>
120
+ <div className="step-timeline-meta">
121
+ {step.status}
122
+ {step.startedAt && (
123
+ <span style={{ marginLeft: "0.375rem", color: "var(--muted-light)" }}>
124
+ {new Date(step.startedAt).toLocaleTimeString()}
125
+ </span>
126
+ )}
127
+ </div>
128
+ <StepDetail step={step} />
129
+ </div>
130
+ );
131
+ })}
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,45 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from "react";
4
+
5
+ type Theme = "light" | "dark";
6
+
7
+ interface ThemeContextValue {
8
+ theme: Theme;
9
+ toggle: () => void;
10
+ }
11
+
12
+ const ThemeContext = createContext<ThemeContextValue>({
13
+ theme: "light",
14
+ toggle: () => {},
15
+ });
16
+
17
+ export function useTheme() {
18
+ return useContext(ThemeContext);
19
+ }
20
+
21
+ function getInitialTheme(): Theme {
22
+ if (typeof window === "undefined") return "light";
23
+ const stored = localStorage.getItem("station-theme");
24
+ if (stored === "light" || stored === "dark") return stored;
25
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
26
+ }
27
+
28
+ export function ThemeProvider({ children }: { children: ReactNode }) {
29
+ const [theme, setTheme] = useState<Theme>(getInitialTheme);
30
+
31
+ useEffect(() => {
32
+ document.documentElement.setAttribute("data-theme", theme);
33
+ localStorage.setItem("station-theme", theme);
34
+ }, [theme]);
35
+
36
+ const toggle = useCallback(() => {
37
+ setTheme((prev) => (prev === "light" ? "dark" : "light"));
38
+ }, []);
39
+
40
+ return (
41
+ <ThemeContext.Provider value={{ theme, toggle }}>
42
+ {children}
43
+ </ThemeContext.Provider>
44
+ );
45
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import { useStatusColors } from "./dag-view";
4
+ import { useTheme } from "./theme-provider";
5
+
6
+ interface SidebarNode {
7
+ nodeName: string;
8
+ signalName: string;
9
+ status: string;
10
+ startedAt?: string;
11
+ completedAt?: string;
12
+ tier: number;
13
+ }
14
+
15
+ interface WorkflowNodeSidebarProps {
16
+ nodes: SidebarNode[];
17
+ selectedNode: string | null;
18
+ onSelectNode: (name: string) => void;
19
+ }
20
+
21
+ const LIGHT_DOT_COLOR = "#D4CEBF";
22
+ const DARK_DOT_COLOR = "#4A4A4C";
23
+
24
+ function formatDuration(startedAt?: string, completedAt?: string): string | null {
25
+ if (!startedAt) return null;
26
+ const start = new Date(startedAt).getTime();
27
+ const end = completedAt ? new Date(completedAt).getTime() : Date.now();
28
+ const ms = end - start;
29
+ if (ms < 1000) return `${ms}ms`;
30
+ if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
31
+ return `${(ms / 60_000).toFixed(1)}m`;
32
+ }
33
+
34
+ export function WorkflowNodeSidebar({ nodes, selectedNode, onSelectNode }: WorkflowNodeSidebarProps) {
35
+ const statusColors = useStatusColors();
36
+ const { theme } = useTheme();
37
+ const defaultDot = theme === "dark" ? DARK_DOT_COLOR : LIGHT_DOT_COLOR;
38
+
39
+ return (
40
+ <div className="workflow-sidebar">
41
+ <div className="workflow-sidebar-label">Nodes</div>
42
+ {nodes.map((node) => {
43
+ const isActive = selectedNode === node.nodeName;
44
+ const colors = statusColors[node.status];
45
+ const dotColor = colors?.bar ?? defaultDot;
46
+ const dur = formatDuration(node.startedAt, node.completedAt);
47
+ const isRunning = node.status === "running";
48
+ const isFailed = node.status === "failed";
49
+
50
+ return (
51
+ <button
52
+ key={node.nodeName}
53
+ className={`workflow-node-item${isActive ? " workflow-node-item--active" : ""}${isFailed ? " workflow-node-item--failed" : ""}`}
54
+ onClick={() => onSelectNode(node.nodeName)}
55
+ style={{ paddingLeft: `${0.75 + node.tier * 0.75}rem` }}
56
+ >
57
+ <span
58
+ className={`workflow-node-dot${isRunning ? " workflow-node-dot--running" : ""}`}
59
+ style={{ backgroundColor: dotColor }}
60
+ />
61
+ <span className="workflow-node-name">{node.nodeName}</span>
62
+ {dur && <span className="workflow-node-duration">{dur}</span>}
63
+ </button>
64
+ );
65
+ })}
66
+ </div>
67
+ );
68
+ }