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.
- package/CHANGELOG.md +32 -1
- package/README.md +12 -3
- package/package.json +1 -1
- package/src/config.ts +32 -0
- package/src/index.tsx +11 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +14 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +190 -0
- package/src/lib/beaconListen.ts +355 -0
- package/src/lib/beaconListenInstall.ts +251 -0
- package/src/lib/cliBeacon.ts +115 -3
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliScheduler.ts +30 -1
- 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 +189 -1
- package/src/lib/schedulerExtend.ts +105 -0
- package/src/lib/schedulerInstall.ts +36 -15
- 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 +9 -1
- package/src/modules/contract.ts +34 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +147 -6
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +8 -0
- package/src/ui/store.tsx +11 -0
- package/src/ui/views/BeaconSetup.tsx +11 -7
- package/src/ui/views/ListenerSetup.tsx +205 -0
- package/src/ui/views/Modules.tsx +70 -5
- 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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
+
}
|
package/src/ui/views/Modules.tsx
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|