spinuptui 1.3.0-alpha.3 → 1.3.0-alpha.5

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 (50) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +12 -3
  3. package/package.json +1 -1
  4. package/src/config.ts +32 -0
  5. package/src/index.tsx +11 -0
  6. package/src/lib/beacon.test.ts +14 -0
  7. package/src/lib/beacon.ts +14 -1
  8. package/src/lib/beaconBoard.test.ts +69 -0
  9. package/src/lib/beaconBoard.ts +197 -0
  10. package/src/lib/beaconListen.test.ts +190 -0
  11. package/src/lib/beaconListen.ts +355 -0
  12. package/src/lib/beaconListenInstall.ts +251 -0
  13. package/src/lib/cliBeacon.ts +115 -3
  14. package/src/lib/cliIncidents.ts +13 -6
  15. package/src/lib/cliScheduler.ts +30 -1
  16. package/src/lib/cliSentinel.ts +451 -0
  17. package/src/lib/heartbeat.ts +5 -0
  18. package/src/lib/schedule.ts +4 -1
  19. package/src/lib/scheduler.test.ts +189 -1
  20. package/src/lib/schedulerExtend.ts +105 -0
  21. package/src/lib/schedulerInstall.ts +36 -15
  22. package/src/lib/schedulerJobs.ts +4 -2
  23. package/src/lib/schedulerRun.ts +61 -15
  24. package/src/lib/schedulerTick.ts +35 -13
  25. package/src/lib/sentinelAttach.test.ts +45 -0
  26. package/src/lib/sentinelAttach.ts +98 -0
  27. package/src/lib/sentinelDigest.test.ts +82 -0
  28. package/src/lib/sentinelDigest.ts +261 -0
  29. package/src/lib/sentinelExpect.test.ts +82 -0
  30. package/src/lib/sentinelExpect.ts +197 -0
  31. package/src/lib/sentinelIncidents.test.ts +145 -0
  32. package/src/lib/sentinelIncidents.ts +194 -0
  33. package/src/lib/sentinelPrompt.ts +89 -0
  34. package/src/lib/sentinelReply.ts +36 -0
  35. package/src/lib/sentinelState.ts +246 -0
  36. package/src/lib/sentinelTick.test.ts +214 -0
  37. package/src/lib/sentinelTick.ts +265 -0
  38. package/src/lib/sentinelWorker.test.ts +170 -0
  39. package/src/lib/sentinelWorker.ts +291 -0
  40. package/src/modules/beacon.ts +9 -1
  41. package/src/modules/contract.ts +34 -0
  42. package/src/modules/index.ts +2 -7
  43. package/src/modules/scheduler.ts +147 -6
  44. package/src/modules/sentinel.ts +90 -0
  45. package/src/ui/App.tsx +8 -0
  46. package/src/ui/store.tsx +11 -0
  47. package/src/ui/views/BeaconSetup.tsx +11 -7
  48. package/src/ui/views/ListenerSetup.tsx +205 -0
  49. package/src/ui/views/Modules.tsx +70 -5
  50. package/src/ui/views/SentinelSetup.tsx +171 -0
package/src/ui/App.tsx CHANGED
@@ -43,10 +43,12 @@ import { DnsRecords } from "./views/DnsRecords.tsx"
43
43
  import { KumaSite } from "./views/KumaSite.tsx"
44
44
  import { Accounts } from "./views/Accounts.tsx"
45
45
  import { Modules } from "./views/Modules.tsx"
46
+ import { SentinelOverlay } from "./views/SentinelSetup.tsx"
46
47
  import { BeaconSetup } from "./views/BeaconSetup.tsx"
47
48
  import { SchedulerInstall } from "./views/SchedulerInstall.tsx"
48
49
  import { HomeSync } from "./views/HomeSync.tsx"
49
50
  import { HeartbeatSetup } from "./views/HeartbeatSetup.tsx"
51
+ import { ListenerSetup } from "./views/ListenerSetup.tsx"
50
52
 
51
53
  const MIN_SPLASH_MS = 1200
52
54
 
@@ -98,8 +100,10 @@ export function App() {
98
100
  store.kumaSite !== null ||
99
101
  store.beaconSetupOpen ||
100
102
  store.schedulerInstallOpen ||
103
+ store.sentinelOverlay !== null ||
101
104
  store.homeSyncOpen ||
102
105
  store.heartbeatSetupOpen ||
106
+ store.listenerSetupOpen ||
103
107
  store.slugPrompt !== null
104
108
  useEffect(() => {
105
109
  store.setOverlayOpen(overlayActive)
@@ -222,8 +226,10 @@ export function App() {
222
226
  // Beacon's guided setup (Modules tab) owns the keyboard while open.
223
227
  if (store.beaconSetupOpen) return
224
228
  if (store.schedulerInstallOpen) return
229
+ if (store.sentinelOverlay) return
225
230
  if (store.homeSyncOpen) return
226
231
  if (store.heartbeatSetupOpen) return
232
+ if (store.listenerSetupOpen) return
227
233
 
228
234
  // A (Shift+a): the Accounts overlay — the header advertises it from every tab.
229
235
  if (key.shift && key.name === "a") return setShowAccounts(true)
@@ -314,8 +320,10 @@ export function App() {
314
320
  {store.kumaSite && <KumaSite />}
315
321
  {store.beaconSetupOpen && <BeaconSetup />}
316
322
  {store.schedulerInstallOpen && <SchedulerInstall />}
323
+ {store.sentinelOverlay && <SentinelOverlay />}
317
324
  {store.homeSyncOpen && <HomeSync />}
318
325
  {store.heartbeatSetupOpen && <HeartbeatSetup />}
326
+ {store.listenerSetupOpen && <ListenerSetup />}
319
327
  {showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
320
328
  {store.slugPrompt && <AccountSlugPrompt />}
321
329
  <ToastStack />
package/src/ui/store.tsx CHANGED
@@ -751,12 +751,17 @@ interface StoreValue extends DataState {
751
751
  // Scheduler's install plan + confirm overlay, opened from the Modules tab.
752
752
  schedulerInstallOpen: boolean
753
753
  setSchedulerInstallOpen: (v: boolean) => void
754
+ sentinelOverlay: "setup" | "digest" | null
755
+ setSentinelOverlay: (v: "setup" | "digest" | null) => void
754
756
  // The SpinupTUI folder: where it is, and sharing it across machines (Modules tab).
755
757
  homeSyncOpen: boolean
756
758
  setHomeSyncOpen: (v: boolean) => void
757
759
  // The station heartbeat (Kuma push monitor) overlay, from the Modules tab.
758
760
  heartbeatSetupOpen: boolean
759
761
  setHeartbeatSetupOpen: (v: boolean) => void
762
+ // Beacon's listener (install / status / remove) overlay, from the Modules tab.
763
+ listenerSetupOpen: boolean
764
+ setListenerSetupOpen: (v: boolean) => void
760
765
  // Whether the "needs a local copy" (forgotten) report overlay is open, and an
761
766
  // optional stack filter (set from the selected Stacks group when opened).
762
767
  forgottenOpen: boolean
@@ -1154,8 +1159,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1154
1159
  const [discoverOpen, setDiscoverOpen] = useState(false)
1155
1160
  const [beaconSetupOpen, setBeaconSetupOpen] = useState(false)
1156
1161
  const [schedulerInstallOpen, setSchedulerInstallOpen] = useState(false)
1162
+ const [sentinelOverlay, setSentinelOverlay] = useState<"setup" | "digest" | null>(null)
1157
1163
  const [homeSyncOpen, setHomeSyncOpen] = useState(false)
1158
1164
  const [heartbeatSetupOpen, setHeartbeatSetupOpen] = useState(false)
1165
+ const [listenerSetupOpen, setListenerSetupOpen] = useState(false)
1159
1166
  const [forgottenOpen, setForgottenOpen] = useState(false)
1160
1167
  const [forgottenStack, setForgottenStack] = useState<Stack | null>(null)
1161
1168
  const [linkReturnToForgotten, setLinkReturnToForgotten] = useState(false)
@@ -4616,10 +4623,14 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4616
4623
  setBeaconSetupOpen,
4617
4624
  schedulerInstallOpen,
4618
4625
  setSchedulerInstallOpen,
4626
+ sentinelOverlay,
4627
+ setSentinelOverlay,
4619
4628
  homeSyncOpen,
4620
4629
  setHomeSyncOpen,
4621
4630
  heartbeatSetupOpen,
4622
4631
  setHeartbeatSetupOpen,
4632
+ listenerSetupOpen,
4633
+ setListenerSetupOpen,
4623
4634
  forgottenOpen,
4624
4635
  setForgottenOpen,
4625
4636
  forgottenStack,
@@ -10,6 +10,7 @@ import { Panel, Centered, Spinner, SecretInput } from "../components.tsx"
10
10
  import { StatusBar } from "../StatusBar.tsx"
11
11
  import { useStore } from "../store.tsx"
12
12
  import { openUrl } from "../../lib/open.ts"
13
+ import { pauseListener } from "../../lib/beaconListenInstall.ts"
13
14
  import {
14
15
  BOTFATHER_STEPS,
15
16
  BOTFATHER_URL,
@@ -65,13 +66,16 @@ export function BeaconSetup() {
65
66
  abort.current = ctrl
66
67
  setChat(null)
67
68
  setPhase("waiting")
68
- void discoverChat(tok, { signal: ctrl.signal }).then((r) => {
69
- if (ctrl.signal.aborted) return
70
- if (r.ok) {
71
- setChat(r.chat)
72
- setPhase("found")
73
- } else fail(r.message, "waiting")
74
- })
69
+ // Telegram gives a bot's messages to one reader: stop the listener meanwhile.
70
+ void pauseListener()
71
+ .then((resume) => discoverChat(tok, { signal: ctrl.signal }).finally(resume))
72
+ .then((r) => {
73
+ if (ctrl.signal.aborted) return
74
+ if (r.ok) {
75
+ setChat(r.chat)
76
+ setPhase("found")
77
+ } else fail(r.message, "waiting")
78
+ })
75
79
  }
76
80
 
77
81
  const submitToken = () => {
@@ -0,0 +1,205 @@
1
+ // Beacon's listener overlay (Modules tab, l on Beacon). Not installed: shows
2
+ // exactly what `spinuptui beacon listener install --plan` would write and run,
3
+ // and applies it on `y`; applying ends when the listener has started and said
4
+ // hello on the pager. Installed: shows whether it's answering, and removes it on
5
+ // `x` then `y`.
6
+
7
+ import { useEffect, useState } from "react"
8
+ import { useKeyboard } from "@opentui/react"
9
+ import { theme } from "../../lib/theme.ts"
10
+ import { truncate } from "../../lib/format.ts"
11
+ import { Panel, Spinner, Centered } from "../components.tsx"
12
+ import { StatusBar } from "../StatusBar.tsx"
13
+ import { useStore } from "../store.tsx"
14
+ import {
15
+ applyListenerInstall,
16
+ applyListenerUninstall,
17
+ binHasListener,
18
+ listenerHealth,
19
+ NO_LISTENER,
20
+ planListenerInstall,
21
+ planListenerUninstall,
22
+ type ListenerApplyResult,
23
+ type ListenerPlanResult,
24
+ } from "../../lib/beaconListenInstall.ts"
25
+
26
+ type Phase = "confirm" | "applying" | "done" | "error" | "installed" | "confirm-remove" | "removed"
27
+
28
+ export function ListenerSetup() {
29
+ const { setListenerSetupOpen } = useStore()
30
+ const [health] = useState(() => listenerHealth())
31
+ const [plan, setPlan] = useState<ListenerPlanResult>(() => planListenerInstall())
32
+ const [phase, setPhase] = useState<Phase>(health ? "installed" : plan.ok ? "confirm" : "error")
33
+ // Same refusal as `listener install --plan`, checked once on open.
34
+ useEffect(() => {
35
+ if (!plan.ok) return
36
+ void binHasListener(plan).then((has) => {
37
+ if (has) return
38
+ setPlan(NO_LISTENER)
39
+ setPhase((p) => (p === "confirm" ? "error" : p))
40
+ })
41
+ // eslint-disable-next-line react-hooks/exhaustive-deps
42
+ }, [])
43
+ const [stage, setStage] = useState("")
44
+ const [result, setResult] = useState<ListenerApplyResult | null>(null)
45
+ const [live, setLive] = useState(true)
46
+ useEffect(() => () => setLive(false), [])
47
+
48
+ const close = () => setListenerSetupOpen(false)
49
+
50
+ const apply = () => {
51
+ if (!plan.ok) return
52
+ setPhase("applying")
53
+ void applyListenerInstall(plan, (s) => live && setStage(s)).then((r) => {
54
+ setResult(r)
55
+ setPhase(r.ok ? "done" : "error")
56
+ })
57
+ }
58
+
59
+ const remove = () => {
60
+ setPhase("applying")
61
+ setStage("removing the listener")
62
+ void applyListenerUninstall(planListenerUninstall()).then(() => setPhase("removed"))
63
+ }
64
+
65
+ useKeyboard((key) => {
66
+ const name = key.name ?? ""
67
+ if (phase === "applying") return
68
+ if (name === "escape" || name === "q") return phase === "confirm-remove" ? setPhase("installed") : close()
69
+ if (phase === "confirm" && name === "y" && plan.ok) return apply()
70
+ if (phase === "installed" && name === "x") return setPhase("confirm-remove")
71
+ if (phase === "installed" && name === "r" && plan.ok) return setPhase("confirm")
72
+ if (phase === "confirm-remove" && name === "y") return remove()
73
+ if (phase === "error" && name === "r" && plan.ok) return setPhase("confirm")
74
+ })
75
+
76
+ return (
77
+ <box
78
+ style={{
79
+ position: "absolute",
80
+ top: 0,
81
+ left: 0,
82
+ width: "100%",
83
+ height: "100%",
84
+ flexDirection: "column",
85
+ backgroundColor: theme.bg,
86
+ zIndex: 210,
87
+ }}
88
+ >
89
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
90
+ <text content="Beacon " fg={theme.brand} style={{ flexShrink: 0 }} />
91
+ <text content="let the pager answer you" fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
92
+ </box>
93
+ <Centered>{body()}</Centered>
94
+ <StatusBar hints={hints()} showGlobal={false} />
95
+ </box>
96
+ )
97
+
98
+ function body() {
99
+ if (phase === "applying") {
100
+ return (
101
+ <box style={{ flexDirection: "column", alignItems: "center" }}>
102
+ <box style={{ flexDirection: "row" }}>
103
+ <Spinner />
104
+ <text content={` ${stage || "Installing…"}`} fg={theme.textDim} wrapMode="none" />
105
+ </box>
106
+ <box style={{ height: 1 }} />
107
+ <text content="Install finishes when the listener has started and said hello on your pager." fg={theme.textFaint} wrapMode="none" />
108
+ </box>
109
+ )
110
+ }
111
+ if (phase === "removed") {
112
+ return (
113
+ <Panel title=" Removed " active>
114
+ <box style={{ flexDirection: "column", width: 70, paddingTop: 1, paddingBottom: 1 }}>
115
+ <text content="✓ The listener is gone. The pager still sends; it just won't answer." fg={theme.good} />
116
+ <box style={{ height: 1 }} />
117
+ <text content="Esc to close" fg={theme.textFaint} />
118
+ </box>
119
+ </Panel>
120
+ )
121
+ }
122
+ if (phase === "installed" || phase === "confirm-remove") {
123
+ const h = health!
124
+ return (
125
+ <Panel title=" Beacon's listener " active>
126
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
127
+ <text content={`${h.ok ? "✓" : "✗"} ${truncate(h.detail, 72)}`} fg={h.ok ? theme.good : theme.bad} />
128
+ <box style={{ height: 1 }} />
129
+ <text content="Send these to your bot in Telegram:" fg={theme.textDim} />
130
+ <text content=" /jobs this station's scheduled jobs, with Extend on the ones expiring soon" fg={theme.accent} wrapMode="none" />
131
+ <text content=" /help everything it answers" fg={theme.accent} wrapMode="none" />
132
+ <box style={{ height: 1 }} />
133
+ {phase === "confirm-remove" ? (
134
+ <text content="Remove the listener? y to remove · Esc to keep it" fg={theme.warn} wrapMode="none" />
135
+ ) : (
136
+ <text content={`${plan.ok ? "r reinstall (repairs the unit) · " : ""}x remove · Esc to close`} fg={theme.textFaint} wrapMode="none" />
137
+ )}
138
+ </box>
139
+ </Panel>
140
+ )
141
+ }
142
+ if (phase === "done" && result?.ok) {
143
+ return (
144
+ <Panel title=" Installed " active>
145
+ <box style={{ flexDirection: "column", width: 70, paddingTop: 1, paddingBottom: 1 }}>
146
+ <text content={`✓ ${result.label} is listening on "${result.channel}".`} fg={theme.good} />
147
+ <box style={{ height: 1 }} />
148
+ <text content="Try it: send /jobs to your bot in Telegram." fg={theme.text} />
149
+ <box style={{ height: 1 }} />
150
+ {result.notes.map((n) => (
151
+ <text key={n} content={`Note: ${n}`} fg={theme.textFaint} />
152
+ ))}
153
+ <text content="Esc to close" fg={theme.textFaint} />
154
+ </box>
155
+ </Panel>
156
+ )
157
+ }
158
+ if (phase === "error" || !plan.ok) {
159
+ const r = !plan.ok ? plan : result && !result.ok ? result : null
160
+ return (
161
+ <Panel title=" Can't install " active>
162
+ <box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
163
+ <text content={`✕ ${r?.message ?? "Something went wrong."}`} fg={theme.bad} />
164
+ {r?.remedy && (
165
+ <>
166
+ <box style={{ height: 1 }} />
167
+ <text content={r.remedy} fg={theme.textDim} />
168
+ </>
169
+ )}
170
+ <box style={{ height: 1 }} />
171
+ <text content={plan.ok ? "Press r to see the plan again · Esc to close" : "Esc to close"} fg={theme.textFaint} />
172
+ </box>
173
+ </Panel>
174
+ )
175
+ }
176
+ return (
177
+ <Panel title=" Install Beacon's listener " active>
178
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
179
+ <text content="Kept running by launchd, so you can message the pager back:" fg={theme.text} />
180
+ <text content={` ${truncate(plan.programArguments.join(" "), 72)}`} fg={theme.accent} wrapMode="none" />
181
+ <box style={{ height: 1 }} />
182
+ <text content={`Unit: ${truncate(plan.unitPath, 68)}`} fg={theme.textDim} wrapMode="none" />
183
+ <box style={{ height: 1 }} />
184
+ {plan.commands.map((c) => (
185
+ <text key={c} content={`· ${truncate(c, 72)}`} fg={theme.textDim} wrapMode="none" />
186
+ ))}
187
+ <box style={{ height: 1 }} />
188
+ {plan.notes.map((n) => (
189
+ <text key={n} content={`Note: ${n}`} fg={theme.textFaint} />
190
+ ))}
191
+ <box style={{ height: 1 }} />
192
+ <text content="Press y to install · Esc to cancel" fg={theme.text} wrapMode="none" />
193
+ </box>
194
+ </Panel>
195
+ )
196
+ }
197
+
198
+ function hints() {
199
+ if (phase === "confirm" && plan.ok) return [{ key: "y", label: "install" }, { key: "esc", label: "cancel" }]
200
+ if (phase === "installed") return [...(plan.ok ? [{ key: "r", label: "reinstall" }] : []), { key: "x", label: "remove" }, { key: "esc", label: "close" }]
201
+ if (phase === "confirm-remove") return [{ key: "y", label: "remove" }, { key: "esc", label: "keep" }]
202
+ if (phase === "error" && plan.ok) return [{ key: "r", label: "back to plan" }, { key: "esc", label: "close" }]
203
+ return [{ key: "esc", label: "close" }]
204
+ }
205
+ }
@@ -16,7 +16,10 @@ import { MODULES, UPCOMING_MODULES } from "../../modules/index.ts"
16
16
  import type { ModuleId, ModuleStatus } from "../../modules/contract.ts"
17
17
  import { beaconFromConfig, beaconSend, testMessage } from "../../lib/beacon.ts"
18
18
  import { loadConfig } from "../../config.ts"
19
+ import { listenerInstalled } from "../../lib/beaconListenInstall.ts"
19
20
  import { upcomingRuns } from "../../modules/scheduler.ts"
21
+ import { clearPause, isWaiting, loadIncidents, loadPause, savePause } from "../../lib/sentinelState.ts"
22
+ import { describeIncident } from "../../lib/sentinelReply.ts"
20
23
  import { describeShare, homeStatus, tilde, type HomeStatus } from "../../lib/home.ts"
21
24
 
22
25
  interface Card {
@@ -44,7 +47,7 @@ function stateLabel(card: Card, status: ModuleStatus | undefined): { dot: string
44
47
 
45
48
  export function Modules({ rows }: { rows: number }) {
46
49
  const store = useStore()
47
- const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen, schedulerInstallOpen, setSchedulerInstallOpen, homeSyncOpen, setHomeSyncOpen, heartbeatSetupOpen, setHeartbeatSetupOpen } = store
50
+ const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen, schedulerInstallOpen, setSchedulerInstallOpen, homeSyncOpen, setHomeSyncOpen, heartbeatSetupOpen, setHeartbeatSetupOpen, listenerSetupOpen, setListenerSetupOpen, sentinelOverlay, setSentinelOverlay } = store
48
51
  const [selected, setSelected] = useState(0)
49
52
  const [statuses, setStatuses] = useState<Map<ModuleId, ModuleStatus>>(new Map())
50
53
  const [testing, setTesting] = useState(false)
@@ -53,7 +56,7 @@ export function Modules({ rows }: { rows: number }) {
53
56
  // the config). status() is cheap and never sends anything.
54
57
  const [tick, setTick] = useState(0)
55
58
  useEffect(() => {
56
- if (beaconSetupOpen || schedulerInstallOpen || homeSyncOpen || heartbeatSetupOpen) return
59
+ if (beaconSetupOpen || schedulerInstallOpen || homeSyncOpen || heartbeatSetupOpen || listenerSetupOpen || sentinelOverlay) return
57
60
  let live = true
58
61
  void Promise.all(MODULES.map((m) => m.status())).then((list) => {
59
62
  if (live) setStatuses(new Map(list.map((s) => [s.id, s])))
@@ -61,7 +64,7 @@ export function Modules({ rows }: { rows: number }) {
61
64
  return () => {
62
65
  live = false
63
66
  }
64
- }, [beaconSetupOpen, schedulerInstallOpen, homeSyncOpen, heartbeatSetupOpen, tick])
67
+ }, [beaconSetupOpen, schedulerInstallOpen, homeSyncOpen, heartbeatSetupOpen, listenerSetupOpen, sentinelOverlay, tick])
65
68
 
66
69
  const isActive = route === "modules" && !inputMode && !overlayOpen
67
70
  const card = CARDS[selected]!
@@ -78,6 +81,19 @@ export function Modules({ rows }: { rows: number }) {
78
81
  })
79
82
  }
80
83
 
84
+ const sentinelReady = card.id === "sentinel" && status !== undefined && status.state !== "not_configured"
85
+
86
+ const togglePause = () => {
87
+ if (loadPause()) {
88
+ clearPause()
89
+ toast.success("Sentinel is watching again")
90
+ } else {
91
+ savePause({ until: new Date(Date.now() + 2 * 3_600_000).toISOString() })
92
+ toast.success("Sentinel paused for 2 hours")
93
+ }
94
+ setTick((t) => t + 1)
95
+ }
96
+
81
97
  useKeyboard((key) => {
82
98
  if (!isActive) return
83
99
  switch (key.name) {
@@ -91,6 +107,7 @@ export function Modules({ rows }: { rows: number }) {
91
107
  case "s":
92
108
  if (card.id === "beacon") return setBeaconSetupOpen(true)
93
109
  if (card.id === "scheduler") return setSchedulerInstallOpen(true)
110
+ if (card.id === "sentinel") return setSentinelOverlay("setup")
94
111
  return
95
112
  case "f":
96
113
  if (card.id === "scheduler") return setHomeSyncOpen(true)
@@ -98,8 +115,15 @@ export function Modules({ rows }: { rows: number }) {
98
115
  case "h":
99
116
  if (card.id === "scheduler") return setHeartbeatSetupOpen(true)
100
117
  return
118
+ case "l":
119
+ if (card.id === "beacon" && status && status.state !== "not_configured") return setListenerSetupOpen(true)
120
+ return
101
121
  case "t":
102
122
  if (card.id === "beacon" && status && status.state !== "not_configured") return sendTest()
123
+ if (sentinelReady) return setSentinelOverlay("digest")
124
+ return
125
+ case "p":
126
+ if (sentinelReady) return togglePause()
103
127
  return
104
128
  }
105
129
  })
@@ -156,7 +180,13 @@ export function Modules({ rows }: { rows: number }) {
156
180
  { key: "h", label: "heartbeat" },
157
181
  ]
158
182
  : []),
159
- ...(beaconReady ? [{ key: "t", label: testing ? "sending…" : "test page" }] : []),
183
+ ...(beaconReady ? [{ key: "t", label: testing ? "sending…" : "test page" }, { key: "l", label: "listener" }] : []),
184
+ ...(card.id === "sentinel"
185
+ ? [
186
+ { key: "⏎", label: sentinelReady ? "reconfigure" : "set up" },
187
+ ...(sentinelReady ? [{ key: "t", label: "preview digest" }, { key: "p", label: loadPause() ? "resume" : "pause 2h" }] : []),
188
+ ]
189
+ : []),
160
190
  ]}
161
191
  />
162
192
  </box>
@@ -205,13 +235,48 @@ function ModuleDetail({ card, status }: { card: Card; status: ModuleStatus | und
205
235
  </>
206
236
  )}
207
237
  {card.id === "beacon" && status.state !== "not_configured" && (
208
- <text content="⏎ reconfigure the pager · t send a test page · CLI: spinuptui beacon status" fg={theme.textFaint} />
238
+ <>
239
+ <text
240
+ content={listenerInstalled() ? "l the listener: answers /jobs on your pager" : "l let the pager answer you: send /jobs to see and extend this station's jobs"}
241
+ fg={theme.accent}
242
+ wrapMode="none"
243
+ />
244
+ <box style={{ height: 1 }} />
245
+ <text content="⏎ reconfigure the pager · t send a test page · CLI: spinuptui beacon status" fg={theme.textFaint} />
246
+ </>
209
247
  )}
248
+ {card.id === "sentinel" && <SentinelDetail ready={status.state !== "not_configured"} />}
210
249
  {card.id === "scheduler" && <SchedulerDetail installed={status.state !== "not_configured"} refreshKey={status} />}
211
250
  </box>
212
251
  )
213
252
  }
214
253
 
254
+ function SentinelDetail({ ready }: { ready: boolean }) {
255
+ if (!ready) {
256
+ return (
257
+ <>
258
+ <text
259
+ content="When something goes down, it finds the site that really caused it and pages you the reason. A morning digest covers what monitoring can't see."
260
+ fg={theme.textDim}
261
+ />
262
+ <box style={{ height: 1 }} />
263
+ <text content="Press ⏎ to see what it would do, then confirm." fg={theme.accent} />
264
+ </>
265
+ )
266
+ }
267
+ const open = loadIncidents().filter((i) => !i.closedAt || isWaiting(i)).slice(-5)
268
+ return (
269
+ <>
270
+ <text content={open.length ? "Open:" : "No open incidents."} fg={theme.textDim} />
271
+ {open.map((i) => (
272
+ <text key={i.id} content={` ${truncate(describeIncident(i), 90)}`} fg={theme.text} wrapMode="none" />
273
+ ))}
274
+ <box style={{ height: 1 }} />
275
+ <text content="⏎ reconfigure · t preview digest · p pause · CLI: spinuptui sentinel status" fg={theme.textFaint} />
276
+ </>
277
+ )
278
+ }
279
+
215
280
  function SchedulerDetail({ installed, refreshKey }: { installed: boolean; refreshKey: unknown }) {
216
281
  const [runs] = useState(() => upcomingRuns())
217
282
  // Level 0 of the SpinupTUI folder: always say where the work lands.
@@ -0,0 +1,171 @@
1
+ // Sentinel's overlay (Modules tab): guided setup on ⏎, a digest preview on `t`.
2
+ // Setup shows what it would do and applies on `y`, the confirm-before-firing
3
+ // pattern; attach comes first, because Sentinel watches what's in the registry.
4
+ // Applying ends with one real tick, so "done" means it read Kuma.
5
+
6
+ import { useEffect, useState } from "react"
7
+ import { useKeyboard } from "@opentui/react"
8
+ import { theme } from "../../lib/theme.ts"
9
+ import { truncate } from "../../lib/format.ts"
10
+ import { Panel, Spinner, Centered } from "../components.tsx"
11
+ import { StatusBar } from "../StatusBar.tsx"
12
+ import { useStore } from "../store.tsx"
13
+ import { loadConfig } from "../../config.ts"
14
+ import { SpinupWPClient } from "../../api/client.ts"
15
+ import { applySetup, loadAttachPlan, planSetup, saveAttach } from "../../lib/cliSentinel.ts"
16
+ import { composeDigest, gatherDigest } from "../../lib/sentinelDigest.ts"
17
+ import { loadFlags, sentinelSettings } from "../../lib/sentinelState.ts"
18
+ import type { AttachPlan } from "../../lib/sentinelAttach.ts"
19
+ import type { KumaMonitor } from "../../lib/uptimeKuma.ts"
20
+
21
+ type Agent = "claude" | "codex" | "none"
22
+
23
+ export function SentinelOverlay() {
24
+ const { sentinelOverlay, setSentinelOverlay } = useStore()
25
+ const close = () => setSentinelOverlay(null)
26
+ return (
27
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
28
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
29
+ <text content="◉ Sentinel " fg={theme.brand} style={{ flexShrink: 0 }} />
30
+ <text content={sentinelOverlay === "digest" ? "today's digest, not sent" : "watch the fleet"} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
31
+ </box>
32
+ {sentinelOverlay === "digest" ? <DigestPreview close={close} /> : <Setup close={close} />}
33
+ </box>
34
+ )
35
+ }
36
+
37
+ function DigestPreview({ close }: { close: () => void }) {
38
+ const [text, setText] = useState<string | null>(null)
39
+ useEffect(() => {
40
+ let live = true
41
+ const cfg = loadConfig()
42
+ void gatherDigest(sentinelSettings(cfg), cfg, new SpinupWPClient(cfg), { dryRun: true })
43
+ .then((d) => live && setText(composeDigest(d, loadFlags()).text))
44
+ .catch((err: Error) => live && setText(`Couldn't build the digest: ${err.message}`))
45
+ return () => {
46
+ live = false
47
+ }
48
+ }, [])
49
+ useKeyboard((key) => {
50
+ if (key.name === "escape" || key.name === "q") close()
51
+ })
52
+ return (
53
+ <>
54
+ <Centered>
55
+ {text === null ? (
56
+ <box style={{ flexDirection: "row" }}>
57
+ <Spinner />
58
+ <text content=" Checking the fleet (this can take a minute)…" fg={theme.textDim} wrapMode="none" />
59
+ </box>
60
+ ) : (
61
+ <Panel title=" Digest preview " active>
62
+ <box style={{ flexDirection: "column", width: 84, paddingTop: 1, paddingBottom: 1 }}>
63
+ {text.split("\n").map((l, i) => (
64
+ <text key={i} content={truncate(l, 82) || " "} fg={theme.text} wrapMode="none" />
65
+ ))}
66
+ </box>
67
+ </Panel>
68
+ )}
69
+ </Centered>
70
+ <StatusBar hints={[{ key: "esc", label: "close" }]} showGlobal={false} />
71
+ </>
72
+ )
73
+ }
74
+
75
+ function Setup({ close }: { close: () => void }) {
76
+ const saved = loadConfig().sentinel?.diagnosis?.agent
77
+ const [agent, setAgent] = useState<Agent>(saved === null ? "none" : (saved ?? (Bun.which("claude") ? "claude" : Bun.which("codex") ? "codex" : "none")))
78
+ const [attach, setAttach] = useState<{ plan: AttachPlan; monitors: KumaMonitor[] } | null>(null)
79
+ const [attachError, setAttachError] = useState<string | null>(null)
80
+ const [likely, setLikely] = useState(false)
81
+ const [phase, setPhase] = useState<"confirm" | "applying" | "done" | "error">("confirm")
82
+ const [message, setMessage] = useState("")
83
+ const plan = planSetup({ agent })
84
+
85
+ useEffect(() => {
86
+ if (!loadConfig().uptimeKuma) return
87
+ let live = true
88
+ void loadAttachPlan()
89
+ .then((a) => live && setAttach(a))
90
+ .catch((err: Error) => live && setAttachError(err.message))
91
+ return () => {
92
+ live = false
93
+ }
94
+ }, [])
95
+
96
+ const apply = () => {
97
+ setPhase("applying")
98
+ void (async () => {
99
+ const attached = attach ? await saveAttach(attach.plan, attach.monitors, likely) : 0
100
+ const r = await applySetup(planSetup({ agent }).next)
101
+ setMessage(r.ok ? `${attached ? `Attached ${attached} monitor(s). ` : ""}Sentinel read Kuma and is watching from now.` : r.message)
102
+ setPhase(r.ok ? "done" : "error")
103
+ })().catch((err: Error) => {
104
+ setMessage(err.message)
105
+ setPhase("error")
106
+ })
107
+ }
108
+
109
+ useKeyboard((key) => {
110
+ const name = key.name ?? ""
111
+ if (name === "escape" || name === "q") return close()
112
+ if (phase !== "confirm") return
113
+ if (name === "a") return setAgent((a) => (a === "claude" ? "codex" : a === "codex" ? "none" : "claude"))
114
+ if (name === "l" && attach?.plan.likely.length) return setLikely((v) => !v)
115
+ if (name === "y" && plan.blockers.length === 0) apply()
116
+ })
117
+
118
+ const a = attach?.plan
119
+ return (
120
+ <>
121
+ <Centered>
122
+ {phase === "applying" ? (
123
+ <box style={{ flexDirection: "row" }}>
124
+ <Spinner />
125
+ <text content=" Attaching monitors and running a first tick…" fg={theme.textDim} wrapMode="none" />
126
+ </box>
127
+ ) : phase !== "confirm" ? (
128
+ <Panel title={phase === "done" ? " Sentinel is on " : " Couldn't finish "} active>
129
+ <box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
130
+ <text content={`${phase === "done" ? "✓" : "✕"} ${message}`} fg={phase === "done" ? theme.good : theme.bad} />
131
+ <box style={{ height: 1 }} />
132
+ <text content="Esc to close" fg={theme.textFaint} />
133
+ </box>
134
+ </Panel>
135
+ ) : (
136
+ <Panel title=" Set up Sentinel " active>
137
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
138
+ <text content="Runs as Scheduler jobs: a check every 5 minutes, a digest at 08:00." fg={theme.text} />
139
+ <box style={{ height: 1 }} />
140
+ <text content={`Agent: ${agent}`} fg={theme.accent} wrapMode="none" />
141
+ <text content={`Workspace: ${truncate(plan.next.workspace ?? "~/SpinupTUI/sentinel", 62)}`} fg={theme.textDim} wrapMode="none" />
142
+ <box style={{ height: 1 }} />
143
+ {!a && !attachError && loadConfig().uptimeKuma && <text content="Reading your Kuma monitors…" fg={theme.textDim} wrapMode="none" />}
144
+ {attachError && <text content={`Couldn't read Kuma: ${truncate(attachError, 58)}`} fg={theme.warn} wrapMode="none" />}
145
+ {a && (
146
+ <>
147
+ <text content={`Monitors: ${a.exact.length} to attach, ${a.already} already attached (nothing changes in Kuma)`} fg={theme.text} wrapMode="none" />
148
+ {a.likely.length > 0 && <text content={` ${a.likely.length} likely match(es): ${likely ? "will attach" : "skipped"} (l to change)`} fg={theme.textDim} wrapMode="none" />}
149
+ {a.unmonitored.length > 0 && <text content={` ${a.unmonitored.length} site(s) have no monitor`} fg={theme.textDim} wrapMode="none" />}
150
+ </>
151
+ )}
152
+ <box style={{ height: 1 }} />
153
+ {plan.notes.map((n) => (
154
+ <text key={n} content={`Note: ${n}`} fg={theme.textFaint} />
155
+ ))}
156
+ {plan.blockers.map((b) => (
157
+ <text key={b} content={`✕ ${b}`} fg={theme.bad} />
158
+ ))}
159
+ <box style={{ height: 1 }} />
160
+ <text content={plan.blockers.length ? "Fix the above first · Esc to close" : "Press y to turn it on · a change agent · Esc to cancel"} fg={plan.blockers.length ? theme.warn : theme.text} wrapMode="none" />
161
+ </box>
162
+ </Panel>
163
+ )}
164
+ </Centered>
165
+ <StatusBar
166
+ hints={phase === "confirm" && !plan.blockers.length ? [{ key: "y", label: "turn on" }, { key: "a", label: "agent" }, { key: "esc", label: "cancel" }] : [{ key: "esc", label: "close" }]}
167
+ showGlobal={false}
168
+ />
169
+ </>
170
+ )
171
+ }