station-kit 1.1.0 → 2.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 (177) hide show
  1. package/.next/standalone/packages/station-kit/.next/BUILD_ID +1 -1
  2. package/.next/standalone/packages/station-kit/.next/app-build-manifest.json +55 -34
  3. package/.next/standalone/packages/station-kit/.next/app-path-routes-manifest.json +11 -8
  4. package/.next/standalone/packages/station-kit/.next/build-manifest.json +2 -2
  5. package/.next/standalone/packages/station-kit/.next/prerender-manifest.json +73 -25
  6. package/.next/standalone/packages/station-kit/.next/routes-manifest.json +20 -0
  7. package/.next/standalone/packages/station-kit/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.html +1 -1
  9. package/.next/standalone/packages/station-kit/.next/server/app/_not-found.rsc +8 -8
  10. package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js +2 -0
  11. package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page.js.nft.json +1 -0
  12. package/.next/standalone/packages/station-kit/.next/server/app/beacons/[name]/page_client-reference-manifest.js +1 -0
  13. package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js +2 -0
  14. package/.next/standalone/packages/station-kit/.next/server/app/beacons/page.js.nft.json +1 -0
  15. package/.next/standalone/packages/station-kit/.next/server/app/beacons/page_client-reference-manifest.js +1 -0
  16. package/.next/standalone/packages/station-kit/.next/server/app/beacons.html +1 -0
  17. package/.next/standalone/packages/station-kit/.next/server/app/beacons.meta +7 -0
  18. package/.next/standalone/packages/station-kit/.next/server/app/beacons.rsc +25 -0
  19. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/[id]/page_client-reference-manifest.js +1 -1
  20. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/page_client-reference-manifest.js +1 -1
  21. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/dyn/[name]/v/[n]/page_client-reference-manifest.js +1 -1
  22. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new/page_client-reference-manifest.js +1 -1
  23. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.html +1 -1
  24. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/new.rsc +9 -9
  25. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts/page_client-reference-manifest.js +1 -1
  26. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.html +1 -1
  27. package/.next/standalone/packages/station-kit/.next/server/app/broadcasts.rsc +9 -9
  28. package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js +2 -0
  29. package/.next/standalone/packages/station-kit/.next/server/app/environment/page.js.nft.json +1 -0
  30. package/.next/standalone/packages/station-kit/.next/server/app/environment/page_client-reference-manifest.js +1 -0
  31. package/.next/standalone/packages/station-kit/.next/server/app/environment.html +1 -0
  32. package/.next/standalone/packages/station-kit/.next/server/app/environment.meta +7 -0
  33. package/.next/standalone/packages/station-kit/.next/server/app/environment.rsc +25 -0
  34. package/.next/standalone/packages/station-kit/.next/server/app/index.html +1 -1
  35. package/.next/standalone/packages/station-kit/.next/server/app/index.rsc +9 -9
  36. package/.next/standalone/packages/station-kit/.next/server/app/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression/page_client-reference-manifest.js +1 -1
  38. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.html +1 -1
  39. package/.next/standalone/packages/station-kit/.next/server/app/playground/expression.rsc +9 -9
  40. package/.next/standalone/packages/station-kit/.next/server/app/runs/[id]/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/packages/station-kit/.next/server/app/schedules/[id]/page_client-reference-manifest.js +1 -1
  42. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new/page_client-reference-manifest.js +1 -1
  43. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.html +1 -1
  44. package/.next/standalone/packages/station-kit/.next/server/app/schedules/new.rsc +9 -9
  45. package/.next/standalone/packages/station-kit/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  46. package/.next/standalone/packages/station-kit/.next/server/app/schedules.html +1 -1
  47. package/.next/standalone/packages/station-kit/.next/server/app/schedules.rsc +9 -9
  48. package/.next/standalone/packages/station-kit/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  49. package/.next/standalone/packages/station-kit/.next/server/app/settings.html +1 -1
  50. package/.next/standalone/packages/station-kit/.next/server/app/settings.rsc +9 -9
  51. package/.next/standalone/packages/station-kit/.next/server/app/signals/[name]/page_client-reference-manifest.js +1 -1
  52. package/.next/standalone/packages/station-kit/.next/server/app/signals/page_client-reference-manifest.js +1 -1
  53. package/.next/standalone/packages/station-kit/.next/server/app/signals.html +1 -1
  54. package/.next/standalone/packages/station-kit/.next/server/app/signals.rsc +9 -9
  55. package/.next/standalone/packages/station-kit/.next/server/app-paths-manifest.json +11 -8
  56. package/.next/standalone/packages/station-kit/.next/server/chunks/102.js +1 -1
  57. package/.next/standalone/packages/station-kit/.next/server/pages/404.html +1 -1
  58. package/.next/standalone/packages/station-kit/.next/server/pages/500.html +1 -1
  59. package/.next/standalone/packages/station-kit/.next/server/pages-manifest.json +1 -1
  60. package/.next/standalone/packages/station-kit/.next/server/server-reference-manifest.json +1 -1
  61. package/.next/standalone/packages/station-kit/.next/static/chunks/285-583f23ee7827983f.js +1 -0
  62. package/.next/standalone/packages/station-kit/.next/static/chunks/561-05a7ef9669e3921a.js +1 -0
  63. package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/[name]/page-eb1f82847734e145.js +1 -0
  64. package/.next/standalone/packages/station-kit/.next/static/chunks/app/beacons/page-64e296346197e44b.js +1 -0
  65. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-15edf07ab26241c9.js +1 -0
  66. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-b23a03097c8f75c4.js +1 -0
  67. package/.next/standalone/packages/station-kit/.next/static/chunks/app/environment/page-111242bec067fdee.js +1 -0
  68. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e9ac56fcaf7a5ea7.js +1 -0
  69. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-ef78692958c2fc01.js +1 -0
  70. package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-29294a705dd8f471.js +1 -0
  71. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-287bbb8f6dec2e23.js +1 -0
  72. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-c869218da203e3b4.js +1 -0
  73. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-05cf43b6ae6b3187.js +1 -0
  74. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4027803be00c695a.js +1 -0
  75. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-8e076fc5d0188d72.js +1 -0
  76. package/.next/standalone/packages/station-kit/.next/static/css/ddb1646dc25ed100.css +1 -0
  77. package/.next/standalone/packages/station-kit/.next/static/vTGEzSqxhR_E57YAFn9DC/_buildManifest.js +1 -0
  78. package/.next/standalone/packages/station-kit/package.json +4 -1
  79. package/dist/config/schema.d.ts +18 -0
  80. package/dist/config/schema.d.ts.map +1 -1
  81. package/dist/config/schema.js +3 -0
  82. package/dist/config/schema.js.map +1 -1
  83. package/dist/index.d.ts +1 -0
  84. package/dist/index.d.ts.map +1 -1
  85. package/dist/index.js +3 -0
  86. package/dist/index.js.map +1 -1
  87. package/dist/server/auth/keys.d.ts +18 -1
  88. package/dist/server/auth/keys.d.ts.map +1 -1
  89. package/dist/server/auth/keys.js +38 -6
  90. package/dist/server/auth/keys.js.map +1 -1
  91. package/dist/server/auth/session.d.ts.map +1 -1
  92. package/dist/server/auth/session.js +34 -11
  93. package/dist/server/auth/session.js.map +1 -1
  94. package/dist/server/index.d.ts.map +1 -1
  95. package/dist/server/index.js +135 -18
  96. package/dist/server/index.js.map +1 -1
  97. package/dist/server/metadata.d.ts +2 -0
  98. package/dist/server/metadata.d.ts.map +1 -1
  99. package/dist/server/metadata.js.map +1 -1
  100. package/dist/server/middleware/rate-limit.d.ts.map +1 -1
  101. package/dist/server/middleware/rate-limit.js +22 -1
  102. package/dist/server/middleware/rate-limit.js.map +1 -1
  103. package/dist/server/routes/beacons.d.ts +10 -0
  104. package/dist/server/routes/beacons.d.ts.map +1 -0
  105. package/dist/server/routes/beacons.js +98 -0
  106. package/dist/server/routes/beacons.js.map +1 -0
  107. package/dist/server/routes/broadcasts.d.ts.map +1 -1
  108. package/dist/server/routes/broadcasts.js +6 -2
  109. package/dist/server/routes/broadcasts.js.map +1 -1
  110. package/dist/server/routes/runs.d.ts.map +1 -1
  111. package/dist/server/routes/runs.js +19 -59
  112. package/dist/server/routes/runs.js.map +1 -1
  113. package/dist/server/routes/signals.d.ts.map +1 -1
  114. package/dist/server/routes/signals.js +17 -13
  115. package/dist/server/routes/signals.js.map +1 -1
  116. package/dist/server/routes/v1/env.d.ts +10 -0
  117. package/dist/server/routes/v1/env.d.ts.map +1 -0
  118. package/dist/server/routes/v1/env.js +132 -0
  119. package/dist/server/routes/v1/env.js.map +1 -0
  120. package/dist/server/routes/v1/events.js +2 -2
  121. package/dist/server/routes/v1/events.js.map +1 -1
  122. package/dist/server/routes/v1/runs.d.ts.map +1 -1
  123. package/dist/server/routes/v1/runs.js +10 -31
  124. package/dist/server/routes/v1/runs.js.map +1 -1
  125. package/dist/server/sse.d.ts +2 -1
  126. package/dist/server/sse.d.ts.map +1 -1
  127. package/dist/server/sse.js +5 -1
  128. package/dist/server/sse.js.map +1 -1
  129. package/dist/server/subscriber.d.ts +56 -0
  130. package/dist/server/subscriber.d.ts.map +1 -1
  131. package/dist/server/subscriber.js +94 -0
  132. package/dist/server/subscriber.js.map +1 -1
  133. package/dist/server/ws.d.ts +8 -2
  134. package/dist/server/ws.d.ts.map +1 -1
  135. package/dist/server/ws.js +28 -3
  136. package/dist/server/ws.js.map +1 -1
  137. package/package.json +11 -8
  138. package/src/app/beacons/[name]/beacon-detail.tsx +253 -0
  139. package/src/app/beacons/[name]/page.tsx +6 -0
  140. package/src/app/beacons/page.tsx +98 -0
  141. package/src/app/components/shell.tsx +24 -0
  142. package/src/app/components/status-badge.tsx +13 -1
  143. package/src/app/environment/page.tsx +415 -0
  144. package/src/app/globals.css +48 -0
  145. package/src/app/hooks/use-api.ts +80 -0
  146. package/src/config/schema.ts +21 -0
  147. package/src/index.ts +12 -0
  148. package/src/server/auth/keys.ts +50 -5
  149. package/src/server/auth/session.ts +38 -11
  150. package/src/server/index.ts +144 -18
  151. package/src/server/metadata.ts +2 -0
  152. package/src/server/middleware/rate-limit.ts +20 -1
  153. package/src/server/routes/beacons.ts +112 -0
  154. package/src/server/routes/broadcasts.ts +6 -2
  155. package/src/server/routes/runs.ts +20 -67
  156. package/src/server/routes/signals.ts +17 -13
  157. package/src/server/routes/v1/env.ts +144 -0
  158. package/src/server/routes/v1/events.ts +2 -2
  159. package/src/server/routes/v1/runs.ts +11 -35
  160. package/src/server/sse.ts +7 -2
  161. package/src/server/subscriber.ts +111 -0
  162. package/src/server/ws.ts +38 -4
  163. package/.next/standalone/packages/station-kit/.next/static/THKSkCipW_pj0F6DRXYEG/_buildManifest.js +0 -1
  164. package/.next/standalone/packages/station-kit/.next/static/chunks/285-ff198f0a909c4fdd.js +0 -1
  165. package/.next/standalone/packages/station-kit/.next/static/chunks/561-33d912169940283e.js +0 -1
  166. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/dyn/[name]/v/[n]/page-5eac0507f49a00ec.js +0 -1
  167. package/.next/standalone/packages/station-kit/.next/static/chunks/app/broadcasts/page-dee500ccc01f0821.js +0 -1
  168. package/.next/standalone/packages/station-kit/.next/static/chunks/app/layout-e14e14f3e5b0b8a9.js +0 -1
  169. package/.next/standalone/packages/station-kit/.next/static/chunks/app/page-aac41ef7a470daab.js +0 -1
  170. package/.next/standalone/packages/station-kit/.next/static/chunks/app/playground/expression/page-dc9d91f3f50f4716.js +0 -1
  171. package/.next/standalone/packages/station-kit/.next/static/chunks/app/runs/[id]/page-9e4c4f751a4bea72.js +0 -1
  172. package/.next/standalone/packages/station-kit/.next/static/chunks/app/schedules/page-738d98dc0b63166e.js +0 -1
  173. package/.next/standalone/packages/station-kit/.next/static/chunks/app/settings/page-fc5654b31f57ac21.js +0 -1
  174. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/[name]/page-4b1c09a539a1ebcd.js +0 -1
  175. package/.next/standalone/packages/station-kit/.next/static/chunks/app/signals/page-d2f2403dfede87cc.js +0 -1
  176. package/.next/standalone/packages/station-kit/.next/static/css/0be0e65a5f561f37.css +0 -1
  177. /package/.next/standalone/packages/station-kit/.next/static/{THKSkCipW_pj0F6DRXYEG → vTGEzSqxhR_E57YAFn9DC}/_ssgManifest.js +0 -0
@@ -0,0 +1,98 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { useApi, type BeaconListItem } from "../hooks/use-api";
6
+ import { useStation } from "../hooks/use-station";
7
+ import { useBreadcrumb } from "../hooks/use-breadcrumb";
8
+ import { StatusBadge } from "../components/status-badge";
9
+
10
+ export default function BeaconsPage() {
11
+ const api = useApi();
12
+ const router = useRouter();
13
+ const { events } = useStation();
14
+ const [beacons, setBeacons] = useState<BeaconListItem[]>([]);
15
+ const [loading, setLoading] = useState(true);
16
+
17
+ useBreadcrumb([{ label: "Beacons" }], "beacons");
18
+
19
+ useEffect(() => {
20
+ api.getBeacons()
21
+ .then((res) => setBeacons(res.data))
22
+ .catch((err: unknown) => {
23
+ if (err instanceof Error) console.error("Failed to load beacons:", err.message);
24
+ })
25
+ .finally(() => setLoading(false));
26
+ }, []);
27
+
28
+ // Live-refresh the list on any beacon lifecycle event.
29
+ useEffect(() => {
30
+ if (events.length === 0) return;
31
+ if (!events[0].type.startsWith("beacon:")) return;
32
+ api.getBeacons().then((res) => setBeacons(res.data)).catch(() => {});
33
+ }, [events.length]);
34
+
35
+ if (loading) {
36
+ return (
37
+ <div>
38
+ <h1 className="page-title">Beacons</h1>
39
+ <div className="loading-bar"><div className="loading-bar-fill" /></div>
40
+ </div>
41
+ );
42
+ }
43
+
44
+ if (beacons.length === 0) {
45
+ return (
46
+ <div>
47
+ <h1 className="page-title">Beacons</h1>
48
+ <div className="empty-state">
49
+ <p className="empty-state-text">No beacons discovered.</p>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
54
+
55
+ return (
56
+ <div>
57
+ <h1 className="page-title">Beacons</h1>
58
+
59
+ <table className="station-table">
60
+ <thead>
61
+ <tr>
62
+ <th>Status</th>
63
+ <th>Name</th>
64
+ <th>Kind</th>
65
+ <th>Desired</th>
66
+ <th>Incarnation</th>
67
+ <th>Restarts</th>
68
+ <th>PID</th>
69
+ </tr>
70
+ </thead>
71
+ <tbody>
72
+ {beacons.map((b, i) => (
73
+ <tr
74
+ key={b.name}
75
+ className="reveal-item clickable-row"
76
+ style={{ animationDelay: `${i * 40}ms` }}
77
+ onClick={() => router.push(`/beacons/${encodeURIComponent(b.name)}`)}
78
+ >
79
+ <td>
80
+ {b.instance ? <StatusBadge status={b.instance.status} /> : <span style={{ color: "var(--muted)" }}>{"—"}</span>}
81
+ </td>
82
+ <td className="mono">{b.name}</td>
83
+ <td style={{ color: "var(--muted)", fontSize: "0.8125rem" }}>{b.mode}</td>
84
+ <td className="mono" style={{ color: "var(--muted)", fontSize: "0.8125rem" }}>
85
+ {b.instance?.desiredState ?? "—"}
86
+ </td>
87
+ <td className="mono" style={{ fontSize: "0.8125rem" }}>{b.instance?.incarnation ?? 0}</td>
88
+ <td className="mono" style={{ fontSize: "0.8125rem" }}>{b.instance?.restartCount ?? 0}</td>
89
+ <td className="mono" style={{ fontSize: "0.8125rem", color: "var(--muted)" }}>
90
+ {b.instance?.pid ?? "—"}
91
+ </td>
92
+ </tr>
93
+ ))}
94
+ </tbody>
95
+ </table>
96
+ </div>
97
+ );
98
+ }
@@ -68,6 +68,18 @@ function IconBroadcasts() {
68
68
  );
69
69
  }
70
70
 
71
+ function IconBeacon() {
72
+ /* Beacon: a mast emitting — long-running supervised process */
73
+ return (
74
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
75
+ <circle cx="7" cy="2.5" r="1.25" fill="currentColor" stroke="none" />
76
+ <path d="M7 3.75V11" />
77
+ <path d="M4.5 11 7 7.5 9.5 11" />
78
+ <path d="M3.6 2.6a3 3 0 0 0 0 3.2M10.4 2.6a3 3 0 0 1 0 3.2" opacity="0.6" />
79
+ </svg>
80
+ );
81
+ }
82
+
71
83
  function IconSettings() {
72
84
  return (
73
85
  <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" aria-hidden="true">
@@ -87,6 +99,16 @@ function IconSchedules() {
87
99
  );
88
100
  }
89
101
 
102
+ function IconEnvironment() {
103
+ /* Key — environment variables / secrets */
104
+ return (
105
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
106
+ <circle cx="4.5" cy="4.5" r="2.5" />
107
+ <path d="M6.3 6.3 12 12M9.5 9.5l1.2-1.2M11 11l1.2-1.2" />
108
+ </svg>
109
+ );
110
+ }
111
+
90
112
  function IconPlayground() {
91
113
  /* Beaker — experiment / playground */
92
114
  return (
@@ -155,7 +177,9 @@ export function Shell({ children }: { children: React.ReactNode }) {
155
177
  <NavLink href="/" label="Overview" section="overview" icon={<IconOverview />} />
156
178
  <NavLink href="/signals" label="Signals" section="signals" icon={<IconSignals />} />
157
179
  <NavLink href="/broadcasts" label="Broadcasts" section="broadcasts" icon={<IconBroadcasts />} />
180
+ <NavLink href="/beacons" label="Beacons" section="beacons" icon={<IconBeacon />} />
158
181
  <NavLink href="/schedules" label="Schedules" section="schedules" icon={<IconSchedules />} />
182
+ <NavLink href="/environment" label="Environment" section="environment" icon={<IconEnvironment />} />
159
183
  <div className="station-sidebar-nav-label" style={{ marginTop: "1rem" }}>Tools</div>
160
184
  <NavLink href="/playground/expression" label="Expressions" section="playground" icon={<IconPlayground />} />
161
185
  <div className="station-sidebar-nav-label" style={{ marginTop: "1rem" }}>Admin</div>
@@ -1,4 +1,16 @@
1
- type Status = "pending" | "running" | "completed" | "failed" | "cancelled" | "skipped";
1
+ type Status =
2
+ | "pending"
3
+ | "running"
4
+ | "completed"
5
+ | "failed"
6
+ | "cancelled"
7
+ | "skipped"
8
+ // beacon lifecycle statuses
9
+ | "stopped"
10
+ | "starting"
11
+ | "stopping"
12
+ | "backoff"
13
+ | "errored";
2
14
 
3
15
  export function StatusBadge({ status }: { status: Status }) {
4
16
  return (
@@ -0,0 +1,415 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, useCallback, useMemo } from "react";
4
+ import {
5
+ useApi,
6
+ type EnvVar,
7
+ type EnvTarget,
8
+ type EnvTargetKind,
9
+ type SignalMeta,
10
+ type BeaconListItem,
11
+ } from "../hooks/use-api";
12
+ import { useBreadcrumb } from "../hooks/use-breadcrumb";
13
+
14
+ const inputStyle: React.CSSProperties = {
15
+ width: "100%",
16
+ padding: "0.5rem 0.625rem",
17
+ fontFamily: "var(--font-body)",
18
+ fontSize: "0.875rem",
19
+ border: "1px solid var(--concrete-dark)",
20
+ borderRadius: "4px",
21
+ background: "var(--surface)",
22
+ color: "var(--charcoal)",
23
+ };
24
+
25
+ const labelStyle: React.CSSProperties = {
26
+ display: "block",
27
+ fontSize: "0.75rem",
28
+ color: "var(--muted)",
29
+ marginBottom: "0.25rem",
30
+ fontFamily: "var(--font-mono)",
31
+ textTransform: "uppercase",
32
+ letterSpacing: "0.08em",
33
+ };
34
+
35
+ function formatDate(iso: string): string {
36
+ const d = new Date(iso);
37
+ return d.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
38
+ }
39
+
40
+ function targetKey(t: EnvTarget): string {
41
+ return `${t.kind}:${t.name}`;
42
+ }
43
+
44
+ function describeScope(targets: EnvTarget[]): string {
45
+ if (targets.length === 0) return "Global";
46
+ return targets.map((t) => `${t.name}`).join(", ");
47
+ }
48
+
49
+ interface EnvFormState {
50
+ key: string;
51
+ value: string;
52
+ secret: boolean;
53
+ scope: "global" | "scoped";
54
+ targets: EnvTarget[];
55
+ }
56
+
57
+ const EMPTY_FORM: EnvFormState = { key: "", value: "", secret: false, scope: "global", targets: [] };
58
+
59
+ export default function EnvironmentPage() {
60
+ const api = useApi();
61
+ const [vars, setVars] = useState<EnvVar[]>([]);
62
+ const [signals, setSignals] = useState<SignalMeta[]>([]);
63
+ const [beacons, setBeacons] = useState<BeaconListItem[]>([]);
64
+ const [loading, setLoading] = useState(true);
65
+ const [error, setError] = useState<string | null>(null);
66
+
67
+ const [showCreate, setShowCreate] = useState(false);
68
+ const [form, setForm] = useState<EnvFormState>(EMPTY_FORM);
69
+ const [saving, setSaving] = useState(false);
70
+
71
+ const [editingId, setEditingId] = useState<string | null>(null);
72
+ const [editValue, setEditValue] = useState("");
73
+ const [deleting, setDeleting] = useState<string | null>(null);
74
+
75
+ useBreadcrumb([{ label: "Environment" }], "environment");
76
+
77
+ const load = useCallback(() => {
78
+ Promise.all([api.getEnvVars(), api.getSignals(), api.getBeacons()])
79
+ .then(([v, s, b]) => {
80
+ setVars(v.data);
81
+ setSignals(s.data);
82
+ setBeacons(b.data);
83
+ setError(null);
84
+ })
85
+ .catch((err: unknown) => {
86
+ if (err instanceof Error) setError(err.message);
87
+ })
88
+ .finally(() => setLoading(false));
89
+ }, []);
90
+
91
+ useEffect(() => { load(); }, [load]);
92
+
93
+ // Which keys each signal/beacon declares via `.env()` — surfaced so the
94
+ // operator can see, at a glance, whether a required var is defined yet.
95
+ const requiredByTarget = useMemo(() => {
96
+ const map = new Map<string, string[]>();
97
+ for (const s of signals) {
98
+ if (s.requiredEnv && s.requiredEnv.length > 0) map.set(`signal:${s.name}`, s.requiredEnv);
99
+ }
100
+ for (const b of beacons) {
101
+ if (b.requiredEnv && b.requiredEnv.length > 0) map.set(`beacon:${b.name}`, b.requiredEnv);
102
+ }
103
+ return map;
104
+ }, [signals, beacons]);
105
+
106
+ // Keys satisfied by a global var or a var scoped to that exact target.
107
+ const providedByTarget = useMemo(() => {
108
+ const map = new Map<string, Set<string>>();
109
+ const ensure = (k: string) => { if (!map.has(k)) map.set(k, new Set()); return map.get(k)!; };
110
+ for (const v of vars) {
111
+ if (v.targets.length === 0) {
112
+ for (const [tk] of requiredByTarget) ensure(tk).add(v.key);
113
+ } else {
114
+ for (const t of v.targets) ensure(targetKey(t)).add(v.key);
115
+ }
116
+ }
117
+ return map;
118
+ }, [vars, requiredByTarget]);
119
+
120
+ const missingRequirements = useMemo(() => {
121
+ const out: Array<{ target: string; keys: string[] }> = [];
122
+ for (const [tk, keys] of requiredByTarget) {
123
+ const provided = providedByTarget.get(tk) ?? new Set();
124
+ const missing = keys.filter((k) => !provided.has(k));
125
+ if (missing.length > 0) out.push({ target: tk, keys: missing });
126
+ }
127
+ return out;
128
+ }, [requiredByTarget, providedByTarget]);
129
+
130
+ function toggleTarget(kind: EnvTargetKind, name: string) {
131
+ setForm((prev) => {
132
+ const exists = prev.targets.some((t) => t.kind === kind && t.name === name);
133
+ return {
134
+ ...prev,
135
+ targets: exists
136
+ ? prev.targets.filter((t) => !(t.kind === kind && t.name === name))
137
+ : [...prev.targets, { kind, name }],
138
+ };
139
+ });
140
+ }
141
+
142
+ async function handleCreate() {
143
+ if (!form.key.trim()) return;
144
+ setSaving(true);
145
+ try {
146
+ await api.createEnvVar({
147
+ key: form.key.trim(),
148
+ value: form.value,
149
+ secret: form.secret,
150
+ targets: form.scope === "global" ? [] : form.targets,
151
+ });
152
+ setForm(EMPTY_FORM);
153
+ setShowCreate(false);
154
+ setError(null);
155
+ load();
156
+ } catch (err: unknown) {
157
+ if (err instanceof Error) setError(err.message);
158
+ }
159
+ setSaving(false);
160
+ }
161
+
162
+ async function handleSaveEdit(id: string) {
163
+ const target = vars.find((x) => x.id === id);
164
+ // A secret's value is redacted, so the edit field starts blank. Saving it
165
+ // blank would overwrite the stored secret with an empty string — treat a
166
+ // blank secret edit as "no change" and just close the editor.
167
+ if (target?.secret && editValue === "") {
168
+ setEditingId(null);
169
+ setEditValue("");
170
+ return;
171
+ }
172
+ try {
173
+ await api.updateEnvVar(id, { value: editValue });
174
+ setEditingId(null);
175
+ setEditValue("");
176
+ setError(null);
177
+ load();
178
+ } catch (err: unknown) {
179
+ if (err instanceof Error) setError(err.message);
180
+ }
181
+ }
182
+
183
+ async function handleDelete(id: string) {
184
+ try {
185
+ await api.deleteEnvVar(id);
186
+ setDeleting(null);
187
+ load();
188
+ } catch (err: unknown) {
189
+ if (err instanceof Error) setError(err.message);
190
+ }
191
+ }
192
+
193
+ if (loading) {
194
+ return (
195
+ <div>
196
+ <h1 className="page-title">Environment</h1>
197
+ <div className="loading-bar"><div className="loading-bar-fill" /></div>
198
+ </div>
199
+ );
200
+ }
201
+
202
+ return (
203
+ <div>
204
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "1rem" }}>
205
+ <h1 className="page-title" style={{ margin: 0 }}>Environment</h1>
206
+ <button className="btn btn--primary" onClick={() => setShowCreate(!showCreate)}>
207
+ {showCreate ? "Cancel" : "+ New variable"}
208
+ </button>
209
+ </div>
210
+
211
+ <p style={{ fontSize: "0.8125rem", color: "var(--muted)", marginTop: 0, marginBottom: "1.25rem", maxWidth: "44rem" }}>
212
+ Variables are injected into runs as <code className="mono">process.env</code>. Leave a variable
213
+ global to feed every signal and beacon, or scope it to specific targets — a scoped variable
214
+ overrides a global one with the same key. Signals and beacons can require a variable via
215
+ <code className="mono"> .env(&quot;KEY&quot;)</code>; a run won&apos;t start until the value exists.
216
+ </p>
217
+
218
+ {error && (
219
+ <div className="detail-section" style={{ color: "var(--rust)", fontSize: "0.8125rem" }}>
220
+ {error}
221
+ </div>
222
+ )}
223
+
224
+ {missingRequirements.length > 0 && (
225
+ <div className="detail-section" style={{
226
+ borderLeft: "3px solid var(--rust)",
227
+ background: "var(--concrete)",
228
+ marginBottom: "1.25rem",
229
+ }}>
230
+ <div className="detail-section-label" style={{ color: "var(--rust)" }}>Required variables not in the env store</div>
231
+ {missingRequirements.map((m) => (
232
+ <div key={m.target} style={{ fontSize: "0.8125rem", marginTop: "0.375rem" }}>
233
+ <span className="mono">{m.target}</span>
234
+ {" requires "}
235
+ {m.keys.map((k) => <span key={k} className="mono" style={{ color: "var(--rust)" }}>{k} </span>)}
236
+ </div>
237
+ ))}
238
+ <div style={{ fontSize: "0.75rem", color: "var(--muted)", marginTop: "0.5rem" }}>
239
+ These aren&apos;t defined here. A value set in the Station host environment also satisfies the requirement — this list can&apos;t see host env, so it only reflects the store.
240
+ </div>
241
+ </div>
242
+ )}
243
+
244
+ {showCreate && (
245
+ <div className="detail-section" style={{ border: "1px solid var(--concrete-dark)", borderRadius: "6px", marginBottom: "1.25rem" }}>
246
+ <div style={{ display: "flex", gap: "0.75rem", marginBottom: "0.75rem" }}>
247
+ <div style={{ flex: "0 0 40%" }}>
248
+ <label style={labelStyle}>Key</label>
249
+ <input
250
+ type="text"
251
+ value={form.key}
252
+ onChange={(e) => setForm({ ...form, key: e.target.value })}
253
+ placeholder="STRIPE_API_KEY"
254
+ className="mono"
255
+ style={inputStyle}
256
+ />
257
+ </div>
258
+ <div style={{ flex: 1 }}>
259
+ <label style={labelStyle}>Value</label>
260
+ <input
261
+ type={form.secret ? "password" : "text"}
262
+ value={form.value}
263
+ onChange={(e) => setForm({ ...form, value: e.target.value })}
264
+ placeholder="value"
265
+ className="mono"
266
+ style={inputStyle}
267
+ />
268
+ </div>
269
+ </div>
270
+
271
+ <label style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "0.8125rem", marginBottom: "0.75rem", cursor: "pointer" }}>
272
+ <input type="checkbox" checked={form.secret} onChange={(e) => setForm({ ...form, secret: e.target.checked })} />
273
+ Secret (value is write-only — hidden after saving)
274
+ </label>
275
+
276
+ <div style={{ marginBottom: "0.75rem" }}>
277
+ <label style={labelStyle}>Scope</label>
278
+ <div style={{ display: "flex", gap: "0.5rem" }}>
279
+ <button
280
+ className={`filter-btn${form.scope === "global" ? " filter-btn--active" : ""}`}
281
+ onClick={() => setForm({ ...form, scope: "global" })}
282
+ >
283
+ Global
284
+ </button>
285
+ <button
286
+ className={`filter-btn${form.scope === "scoped" ? " filter-btn--active" : ""}`}
287
+ onClick={() => setForm({ ...form, scope: "scoped" })}
288
+ >
289
+ Specific targets
290
+ </button>
291
+ </div>
292
+ </div>
293
+
294
+ {form.scope === "scoped" && (
295
+ <div style={{ marginBottom: "0.75rem" }}>
296
+ {signals.length > 0 && (
297
+ <>
298
+ <label style={labelStyle}>Signals</label>
299
+ <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", marginBottom: "0.75rem" }}>
300
+ {signals.map((s) => (
301
+ <button
302
+ key={s.name}
303
+ onClick={() => toggleTarget("signal", s.name)}
304
+ className={`filter-btn${form.targets.some((t) => t.kind === "signal" && t.name === s.name) ? " filter-btn--active" : ""}`}
305
+ >
306
+ {s.name}
307
+ </button>
308
+ ))}
309
+ </div>
310
+ </>
311
+ )}
312
+ {beacons.length > 0 && (
313
+ <>
314
+ <label style={labelStyle}>Beacons</label>
315
+ <div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
316
+ {beacons.map((b) => (
317
+ <button
318
+ key={b.name}
319
+ onClick={() => toggleTarget("beacon", b.name)}
320
+ className={`filter-btn${form.targets.some((t) => t.kind === "beacon" && t.name === b.name) ? " filter-btn--active" : ""}`}
321
+ >
322
+ {b.name}
323
+ </button>
324
+ ))}
325
+ </div>
326
+ </>
327
+ )}
328
+ {signals.length === 0 && beacons.length === 0 && (
329
+ <p style={{ fontSize: "0.8125rem", color: "var(--muted)" }}>No signals or beacons discovered.</p>
330
+ )}
331
+ </div>
332
+ )}
333
+
334
+ <button
335
+ className="btn btn--primary"
336
+ onClick={handleCreate}
337
+ disabled={saving || !form.key.trim() || (form.scope === "scoped" && form.targets.length === 0)}
338
+ >
339
+ {saving ? "Saving..." : "Save variable"}
340
+ </button>
341
+ </div>
342
+ )}
343
+
344
+ {vars.length === 0 ? (
345
+ <div className="empty-state">
346
+ <p className="empty-state-text">No environment variables. Add one to feed values into your signals and beacons.</p>
347
+ </div>
348
+ ) : (
349
+ <table className="station-table">
350
+ <thead>
351
+ <tr>
352
+ <th>Key</th>
353
+ <th>Value</th>
354
+ <th>Scope</th>
355
+ <th>Updated</th>
356
+ <th></th>
357
+ </tr>
358
+ </thead>
359
+ <tbody>
360
+ {vars.map((v, i) => (
361
+ <tr key={v.id} className="reveal-item" style={{ animationDelay: `${i * 40}ms` }}>
362
+ <td className="mono" style={{ fontWeight: 500 }}>{v.key}</td>
363
+ <td className="mono" style={{ color: "var(--muted)", fontSize: "0.8125rem", maxWidth: "18rem" }}>
364
+ {editingId === v.id ? (
365
+ <input
366
+ type={v.secret ? "password" : "text"}
367
+ value={editValue}
368
+ onChange={(e) => setEditValue(e.target.value)}
369
+ placeholder={v.secret ? "Enter new value to replace" : ""}
370
+ className="mono"
371
+ style={{ ...inputStyle, padding: "0.25rem 0.5rem" }}
372
+ autoFocus
373
+ />
374
+ ) : v.secret ? (
375
+ <span style={{ letterSpacing: "0.15em" }}>••••••••</span>
376
+ ) : (
377
+ <span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", display: "inline-block", maxWidth: "18rem", verticalAlign: "bottom" }}>
378
+ {v.value}
379
+ </span>
380
+ )}
381
+ </td>
382
+ <td style={{ fontSize: "0.8125rem" }}>
383
+ {v.targets.length === 0 ? (
384
+ <span style={{ fontSize: "0.6875rem", fontFamily: "var(--font-mono)", padding: "0.125rem 0.375rem", borderRadius: "3px", background: "var(--patina)", color: "#fff" }}>global</span>
385
+ ) : (
386
+ <span style={{ color: "var(--muted)" }}>{describeScope(v.targets)}</span>
387
+ )}
388
+ </td>
389
+ <td style={{ fontSize: "0.8125rem", color: "var(--muted)" }}>{formatDate(v.updatedAt)}</td>
390
+ <td>
391
+ {editingId === v.id ? (
392
+ <div style={{ display: "flex", gap: "0.5rem" }}>
393
+ <button className="btn" onClick={() => handleSaveEdit(v.id)} style={{ fontSize: "0.75rem", color: "var(--patina)" }}>Save</button>
394
+ <button className="btn" onClick={() => { setEditingId(null); setEditValue(""); }} style={{ fontSize: "0.75rem" }}>Cancel</button>
395
+ </div>
396
+ ) : deleting === v.id ? (
397
+ <div style={{ display: "flex", gap: "0.5rem" }}>
398
+ <button className="btn" onClick={() => handleDelete(v.id)} style={{ fontSize: "0.75rem", color: "var(--rust)", borderColor: "var(--rust)" }}>Confirm</button>
399
+ <button className="btn" onClick={() => setDeleting(null)} style={{ fontSize: "0.75rem" }}>Cancel</button>
400
+ </div>
401
+ ) : (
402
+ <div style={{ display: "flex", gap: "0.5rem" }}>
403
+ <button className="btn" onClick={() => { setEditingId(v.id); setEditValue(v.secret ? "" : (v.value ?? "")); }} style={{ fontSize: "0.75rem", color: "var(--muted)" }}>Edit</button>
404
+ <button className="btn" onClick={() => setDeleting(v.id)} style={{ fontSize: "0.75rem", color: "var(--muted)" }}>Delete</button>
405
+ </div>
406
+ )}
407
+ </td>
408
+ </tr>
409
+ ))}
410
+ </tbody>
411
+ </table>
412
+ )}
413
+ </div>
414
+ );
415
+ }
@@ -522,6 +522,54 @@ a:hover {
522
522
  background: var(--muted-light);
523
523
  }
524
524
 
525
+ /* Beacon lifecycle statuses */
526
+ .status-badge--stopped {
527
+ background: rgba(58, 58, 60, 0.08);
528
+ color: var(--muted);
529
+ }
530
+
531
+ .status-badge--stopped .status-badge-dot {
532
+ background: var(--wire);
533
+ }
534
+
535
+ .status-badge--starting {
536
+ background: rgba(139, 90, 43, 0.1);
537
+ color: var(--rust);
538
+ }
539
+
540
+ .status-badge--starting .status-badge-dot {
541
+ background: var(--rust-pale);
542
+ animation: status-blink var(--duration-blink) ease-in-out infinite;
543
+ }
544
+
545
+ .status-badge--stopping {
546
+ background: rgba(139, 90, 43, 0.08);
547
+ color: var(--rust-light);
548
+ }
549
+
550
+ .status-badge--stopping .status-badge-dot {
551
+ background: var(--rust-pale);
552
+ animation: status-blink var(--duration-blink) ease-in-out infinite;
553
+ }
554
+
555
+ .status-badge--backoff {
556
+ background: rgba(139, 90, 43, 0.08);
557
+ color: var(--rust-light);
558
+ }
559
+
560
+ .status-badge--backoff .status-badge-dot {
561
+ background: var(--rust-pale);
562
+ }
563
+
564
+ .status-badge--errored {
565
+ background: rgba(139, 90, 43, 0.1);
566
+ color: var(--rust);
567
+ }
568
+
569
+ .status-badge--errored .status-badge-dot {
570
+ background: var(--rust);
571
+ }
572
+
525
573
  /* ─── Tables ──────────────────────────────────────────────── */
526
574
 
527
575
  .station-table {