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.
- package/CHANGELOG.md +20 -1
- package/README.md +8 -2
- package/package.json +1 -1
- package/src/config.ts +29 -0
- package/src/index.tsx +8 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +4 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +30 -1
- package/src/lib/beaconListen.ts +40 -17
- package/src/lib/beaconListenInstall.ts +13 -4
- package/src/lib/cliBeacon.ts +25 -1
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliSentinel.ts +451 -0
- package/src/lib/heartbeat.ts +5 -0
- package/src/lib/schedule.ts +4 -1
- package/src/lib/scheduler.test.ts +88 -1
- package/src/lib/schedulerJobs.ts +4 -2
- package/src/lib/schedulerRun.ts +61 -15
- package/src/lib/schedulerTick.ts +35 -13
- package/src/lib/sentinelAttach.test.ts +45 -0
- package/src/lib/sentinelAttach.ts +98 -0
- package/src/lib/sentinelDigest.test.ts +82 -0
- package/src/lib/sentinelDigest.ts +261 -0
- package/src/lib/sentinelExpect.test.ts +82 -0
- package/src/lib/sentinelExpect.ts +197 -0
- package/src/lib/sentinelIncidents.test.ts +145 -0
- package/src/lib/sentinelIncidents.ts +194 -0
- package/src/lib/sentinelPrompt.ts +89 -0
- package/src/lib/sentinelReply.ts +36 -0
- package/src/lib/sentinelState.ts +246 -0
- package/src/lib/sentinelTick.test.ts +214 -0
- package/src/lib/sentinelTick.ts +265 -0
- package/src/lib/sentinelWorker.test.ts +170 -0
- package/src/lib/sentinelWorker.ts +291 -0
- package/src/modules/beacon.ts +3 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +31 -8
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +4 -0
- package/src/ui/store.tsx +5 -0
- package/src/ui/views/Modules.tsx +56 -3
- package/src/ui/views/SentinelSetup.tsx +171 -0
package/src/ui/views/Modules.tsx
CHANGED
|
@@ -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
|
+
}
|