spinuptui 1.3.0-alpha.4 → 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 (44) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +8 -2
  3. package/package.json +1 -1
  4. package/src/config.ts +29 -0
  5. package/src/index.tsx +8 -0
  6. package/src/lib/beacon.test.ts +14 -0
  7. package/src/lib/beacon.ts +4 -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 +30 -1
  11. package/src/lib/beaconListen.ts +40 -17
  12. package/src/lib/beaconListenInstall.ts +13 -4
  13. package/src/lib/cliBeacon.ts +25 -1
  14. package/src/lib/cliIncidents.ts +13 -6
  15. package/src/lib/cliSentinel.ts +451 -0
  16. package/src/lib/heartbeat.ts +5 -0
  17. package/src/lib/schedule.ts +4 -1
  18. package/src/lib/scheduler.test.ts +88 -1
  19. package/src/lib/schedulerJobs.ts +4 -2
  20. package/src/lib/schedulerRun.ts +61 -15
  21. package/src/lib/schedulerTick.ts +35 -13
  22. package/src/lib/sentinelAttach.test.ts +45 -0
  23. package/src/lib/sentinelAttach.ts +98 -0
  24. package/src/lib/sentinelDigest.test.ts +82 -0
  25. package/src/lib/sentinelDigest.ts +261 -0
  26. package/src/lib/sentinelExpect.test.ts +82 -0
  27. package/src/lib/sentinelExpect.ts +197 -0
  28. package/src/lib/sentinelIncidents.test.ts +145 -0
  29. package/src/lib/sentinelIncidents.ts +194 -0
  30. package/src/lib/sentinelPrompt.ts +89 -0
  31. package/src/lib/sentinelReply.ts +36 -0
  32. package/src/lib/sentinelState.ts +246 -0
  33. package/src/lib/sentinelTick.test.ts +214 -0
  34. package/src/lib/sentinelTick.ts +265 -0
  35. package/src/lib/sentinelWorker.test.ts +170 -0
  36. package/src/lib/sentinelWorker.ts +291 -0
  37. package/src/modules/beacon.ts +3 -0
  38. package/src/modules/index.ts +2 -7
  39. package/src/modules/scheduler.ts +31 -8
  40. package/src/modules/sentinel.ts +90 -0
  41. package/src/ui/App.tsx +4 -0
  42. package/src/ui/store.tsx +5 -0
  43. package/src/ui/views/Modules.tsx +56 -3
  44. package/src/ui/views/SentinelSetup.tsx +171 -0
@@ -18,6 +18,8 @@ import { beaconFromConfig, beaconSend, testMessage } from "../../lib/beacon.ts"
18
18
  import { loadConfig } from "../../config.ts"
19
19
  import { listenerInstalled } from "../../lib/beaconListenInstall.ts"
20
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"
21
23
  import { describeShare, homeStatus, tilde, type HomeStatus } from "../../lib/home.ts"
22
24
 
23
25
  interface Card {
@@ -45,7 +47,7 @@ function stateLabel(card: Card, status: ModuleStatus | undefined): { dot: string
45
47
 
46
48
  export function Modules({ rows }: { rows: number }) {
47
49
  const store = useStore()
48
- const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen, schedulerInstallOpen, setSchedulerInstallOpen, homeSyncOpen, setHomeSyncOpen, heartbeatSetupOpen, setHeartbeatSetupOpen, listenerSetupOpen, setListenerSetupOpen } = store
50
+ const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen, schedulerInstallOpen, setSchedulerInstallOpen, homeSyncOpen, setHomeSyncOpen, heartbeatSetupOpen, setHeartbeatSetupOpen, listenerSetupOpen, setListenerSetupOpen, sentinelOverlay, setSentinelOverlay } = store
49
51
  const [selected, setSelected] = useState(0)
50
52
  const [statuses, setStatuses] = useState<Map<ModuleId, ModuleStatus>>(new Map())
51
53
  const [testing, setTesting] = useState(false)
@@ -54,7 +56,7 @@ export function Modules({ rows }: { rows: number }) {
54
56
  // the config). status() is cheap and never sends anything.
55
57
  const [tick, setTick] = useState(0)
56
58
  useEffect(() => {
57
- if (beaconSetupOpen || schedulerInstallOpen || homeSyncOpen || heartbeatSetupOpen || listenerSetupOpen) return
59
+ if (beaconSetupOpen || schedulerInstallOpen || homeSyncOpen || heartbeatSetupOpen || listenerSetupOpen || sentinelOverlay) return
58
60
  let live = true
59
61
  void Promise.all(MODULES.map((m) => m.status())).then((list) => {
60
62
  if (live) setStatuses(new Map(list.map((s) => [s.id, s])))
@@ -62,7 +64,7 @@ export function Modules({ rows }: { rows: number }) {
62
64
  return () => {
63
65
  live = false
64
66
  }
65
- }, [beaconSetupOpen, schedulerInstallOpen, homeSyncOpen, heartbeatSetupOpen, listenerSetupOpen, tick])
67
+ }, [beaconSetupOpen, schedulerInstallOpen, homeSyncOpen, heartbeatSetupOpen, listenerSetupOpen, sentinelOverlay, tick])
66
68
 
67
69
  const isActive = route === "modules" && !inputMode && !overlayOpen
68
70
  const card = CARDS[selected]!
@@ -79,6 +81,19 @@ export function Modules({ rows }: { rows: number }) {
79
81
  })
80
82
  }
81
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
+
82
97
  useKeyboard((key) => {
83
98
  if (!isActive) return
84
99
  switch (key.name) {
@@ -92,6 +107,7 @@ export function Modules({ rows }: { rows: number }) {
92
107
  case "s":
93
108
  if (card.id === "beacon") return setBeaconSetupOpen(true)
94
109
  if (card.id === "scheduler") return setSchedulerInstallOpen(true)
110
+ if (card.id === "sentinel") return setSentinelOverlay("setup")
95
111
  return
96
112
  case "f":
97
113
  if (card.id === "scheduler") return setHomeSyncOpen(true)
@@ -104,6 +120,10 @@ export function Modules({ rows }: { rows: number }) {
104
120
  return
105
121
  case "t":
106
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()
107
127
  return
108
128
  }
109
129
  })
@@ -161,6 +181,12 @@ export function Modules({ rows }: { rows: number }) {
161
181
  ]
162
182
  : []),
163
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
+ : []),
164
190
  ]}
165
191
  />
166
192
  </box>
@@ -219,11 +245,38 @@ function ModuleDetail({ card, status }: { card: Card; status: ModuleStatus | und
219
245
  <text content="⏎ reconfigure the pager · t send a test page · CLI: spinuptui beacon status" fg={theme.textFaint} />
220
246
  </>
221
247
  )}
248
+ {card.id === "sentinel" && <SentinelDetail ready={status.state !== "not_configured"} />}
222
249
  {card.id === "scheduler" && <SchedulerDetail installed={status.state !== "not_configured"} refreshKey={status} />}
223
250
  </box>
224
251
  )
225
252
  }
226
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
+
227
280
  function SchedulerDetail({ installed, refreshKey }: { installed: boolean; refreshKey: unknown }) {
228
281
  const [runs] = useState(() => upcomingRuns())
229
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
+ }