spinuptui 1.2.0 → 1.3.0-alpha.2

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.
@@ -0,0 +1,232 @@
1
+ // Modules tab: the parts of SpinupTUI that keep working after the TUI is closed.
2
+ // A view over the module contract (src/modules): each shipped module's status on
3
+ // this machine, plus the ones still to come, greyed out. This is also where a
4
+ // module gets set up — discovery and turning it on happen in the same place.
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 } from "../components.tsx"
11
+ import { List, moveSelection } from "../List.tsx"
12
+ import { StatusBar } from "../StatusBar.tsx"
13
+ import { useStore } from "../store.tsx"
14
+ import { toast } from "../toast.ts"
15
+ import { MODULES, UPCOMING_MODULES } from "../../modules/index.ts"
16
+ import type { ModuleId, ModuleStatus } from "../../modules/contract.ts"
17
+ import { beaconFromConfig, beaconSend, testMessage } from "../../lib/beacon.ts"
18
+ import { loadConfig } from "../../config.ts"
19
+ import { upcomingRuns } from "../../modules/scheduler.ts"
20
+
21
+ interface Card {
22
+ id: ModuleId
23
+ name: string
24
+ summary: string
25
+ dependsOn: ModuleId[]
26
+ upcoming: boolean
27
+ }
28
+
29
+ const CARDS: Card[] = [
30
+ ...MODULES.map((m) => ({ id: m.id, name: m.name, summary: m.summary, dependsOn: m.dependsOn, upcoming: false })),
31
+ ...UPCOMING_MODULES.map((m) => ({ ...m, upcoming: true })),
32
+ ]
33
+
34
+ const NAMES = new Map(CARDS.map((c) => [c.id, c.name]))
35
+
36
+ function stateLabel(card: Card, status: ModuleStatus | undefined): { dot: string; label: string; color: string } {
37
+ if (card.upcoming) return { dot: "·", label: "coming soon", color: theme.textFaint }
38
+ if (!status) return { dot: "○", label: "checking…", color: theme.textDim }
39
+ if (status.state === "ok") return { dot: "●", label: "set up", color: theme.good }
40
+ if (status.state === "degraded") return { dot: "●", label: "needs attention", color: theme.warn }
41
+ return { dot: "○", label: "not set up", color: theme.textDim }
42
+ }
43
+
44
+ export function Modules({ rows }: { rows: number }) {
45
+ const store = useStore()
46
+ const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen, schedulerInstallOpen, setSchedulerInstallOpen } = store
47
+ const [selected, setSelected] = useState(0)
48
+ const [statuses, setStatuses] = useState<Map<ModuleId, ModuleStatus>>(new Map())
49
+ const [testing, setTesting] = useState(false)
50
+
51
+ // Recompute on open and whenever a setup overlay closes (it may have changed
52
+ // the config). status() is cheap and never sends anything.
53
+ const [tick, setTick] = useState(0)
54
+ useEffect(() => {
55
+ if (beaconSetupOpen || schedulerInstallOpen) return
56
+ let live = true
57
+ void Promise.all(MODULES.map((m) => m.status())).then((list) => {
58
+ if (live) setStatuses(new Map(list.map((s) => [s.id, s])))
59
+ })
60
+ return () => {
61
+ live = false
62
+ }
63
+ }, [beaconSetupOpen, schedulerInstallOpen, tick])
64
+
65
+ const isActive = route === "modules" && !inputMode && !overlayOpen
66
+ const card = CARDS[selected]!
67
+ const status = statuses.get(card.id)
68
+
69
+ const sendTest = () => {
70
+ if (testing) return
71
+ setTesting(true)
72
+ void beaconSend(beaconFromConfig(loadConfig()), testMessage()).then((r) => {
73
+ setTesting(false)
74
+ setTick((t) => t + 1)
75
+ if (r.ok) toast.success(`Test page sent via ${r.channel}`)
76
+ else toast.error(r.message)
77
+ })
78
+ }
79
+
80
+ useKeyboard((key) => {
81
+ if (!isActive) return
82
+ switch (key.name) {
83
+ case "up":
84
+ case "k":
85
+ return setSelected((i) => moveSelection(i, -1, CARDS.length))
86
+ case "down":
87
+ case "j":
88
+ return setSelected((i) => moveSelection(i, 1, CARDS.length))
89
+ case "return":
90
+ case "s":
91
+ if (card.id === "beacon") return setBeaconSetupOpen(true)
92
+ if (card.id === "scheduler") return setSchedulerInstallOpen(true)
93
+ return
94
+ case "t":
95
+ if (card.id === "beacon" && status && status.state !== "not_configured") return sendTest()
96
+ return
97
+ }
98
+ })
99
+
100
+ const beaconReady = card.id === "beacon" && status !== undefined && status.state !== "not_configured"
101
+
102
+ return (
103
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
104
+ <box style={{ flexGrow: 1, flexDirection: "row", padding: 1, gap: 1 }}>
105
+ <Panel title=" Modules " active width={40}>
106
+ <List
107
+ items={CARDS}
108
+ selectedIndex={selected}
109
+ viewportRows={Math.max(3, rows - 4)}
110
+ focused
111
+ keyFor={(c) => c.id}
112
+ renderRow={(c, sel) => {
113
+ const s = stateLabel(c, statuses.get(c.id))
114
+ return (
115
+ <>
116
+ <text content={s.dot + " "} fg={s.color} style={{ flexShrink: 0 }} />
117
+ <text
118
+ content={c.name}
119
+ fg={c.upcoming ? theme.textFaint : sel ? theme.text : theme.textDim}
120
+ wrapMode="none"
121
+ style={{ flexGrow: 1, flexShrink: 1 }}
122
+ />
123
+ <text content={" " + s.label} fg={s.color} wrapMode="none" style={{ flexShrink: 0 }} />
124
+ </>
125
+ )
126
+ }}
127
+ />
128
+ </Panel>
129
+
130
+ <Panel title={` ${card.name} `} flexGrow={1}>
131
+ <box style={{ flexDirection: "column" }}>
132
+ <text content={card.summary} fg={card.upcoming ? theme.textDim : theme.text} />
133
+ <box style={{ height: 1 }} />
134
+ {card.dependsOn.length > 0 && (
135
+ <text content={`Builds on: ${card.dependsOn.map((d) => NAMES.get(d) ?? d).join(", ")}`} fg={theme.textFaint} />
136
+ )}
137
+ {card.upcoming ? <UpcomingDetail /> : <ModuleDetail card={card} status={status} />}
138
+ </box>
139
+ </Panel>
140
+ </box>
141
+ <StatusBar
142
+ hints={[
143
+ { key: "↑↓/jk", label: "select" },
144
+ ...(card.id === "beacon" ? [{ key: "⏎", label: beaconReady ? "reconfigure" : "set up" }] : []),
145
+ ...(card.id === "scheduler" ? [{ key: "⏎", label: status && status.state !== "not_configured" ? "reinstall" : "install" }] : []),
146
+ ...(beaconReady ? [{ key: "t", label: testing ? "sending…" : "test page" }] : []),
147
+ ]}
148
+ />
149
+ </box>
150
+ )
151
+ }
152
+
153
+ function UpcomingDetail() {
154
+ return (
155
+ <box style={{ flexDirection: "column" }}>
156
+ <box style={{ height: 1 }} />
157
+ <text content="Coming soon" fg={theme.purple} attributes={1} />
158
+ <text content="Not in this version yet. It will appear here, ready to set up, when it ships." fg={theme.textFaint} />
159
+ </box>
160
+ )
161
+ }
162
+
163
+ function ModuleDetail({ card, status }: { card: Card; status: ModuleStatus | undefined }) {
164
+ if (!status) return <text content="Checking…" fg={theme.textDim} />
165
+ const s = stateLabel(card, status)
166
+ return (
167
+ <box style={{ flexDirection: "column" }}>
168
+ <box style={{ height: 1 }} />
169
+ <box style={{ flexDirection: "row" }}>
170
+ <text content={`${s.dot} `} fg={s.color} style={{ flexShrink: 0 }} />
171
+ <text content={status.summary} fg={s.color} wrapMode="none" style={{ flexShrink: 1 }} />
172
+ </box>
173
+ {status.checks.length > 0 && (
174
+ <>
175
+ <box style={{ height: 1 }} />
176
+ {status.checks.map((c) => (
177
+ <box key={c.name} style={{ flexDirection: "row" }}>
178
+ <text content={c.ok ? "✓ " : "✗ "} fg={c.ok ? theme.good : theme.bad} style={{ flexShrink: 0 }} />
179
+ <text content={`${c.name}: `} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
180
+ <text content={truncate(c.detail, 80)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
181
+ </box>
182
+ ))}
183
+ </>
184
+ )}
185
+ <box style={{ height: 1 }} />
186
+ {card.id === "beacon" && status.state === "not_configured" && (
187
+ <>
188
+ <text content="Beacon pages you on Telegram when something needs you. Scripts, cron jobs and" fg={theme.textDim} />
189
+ <text content={'the other modules all send through it: spinuptui beacon send "…".'} fg={theme.textDim} />
190
+ <box style={{ height: 1 }} />
191
+ <text content="Press ⏎ to connect a Telegram bot. It takes about two minutes." fg={theme.accent} />
192
+ </>
193
+ )}
194
+ {card.id === "beacon" && status.state !== "not_configured" && (
195
+ <text content="⏎ reconfigure the pager · t send a test page · CLI: spinuptui beacon status" fg={theme.textFaint} />
196
+ )}
197
+ {card.id === "scheduler" && <SchedulerDetail installed={status.state !== "not_configured"} />}
198
+ </box>
199
+ )
200
+ }
201
+
202
+ function SchedulerDetail({ installed }: { installed: boolean }) {
203
+ const [runs] = useState(() => upcomingRuns())
204
+ return (
205
+ <>
206
+ <box style={{ height: 1 }} />
207
+ {!installed && (
208
+ <>
209
+ <text
210
+ content="Scheduler runs follow-ups on this machine while you're away: queue a check for tomorrow at 8 and the result arrives on your pager. It's also the clock the other modules run on, so turning it on is what makes this machine a station."
211
+ fg={theme.textDim}
212
+ />
213
+ <box style={{ height: 1 }} />
214
+ <text content="Press ⏎ to see exactly what it installs, then confirm." fg={theme.accent} />
215
+ <box style={{ height: 1 }} />
216
+ </>
217
+ )}
218
+ <text content={runs.length ? "Next up:" : "No follow-ups queued."} fg={theme.textDim} />
219
+ {runs.map((r) => (
220
+ <box key={`${r.source}/${r.id}`} style={{ flexDirection: "row" }}>
221
+ <text content={` ${r.next.padEnd(17)} `} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
222
+ <text content={`${r.source}/${r.id}`} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
223
+ </box>
224
+ ))}
225
+ <box style={{ height: 1 }} />
226
+ <text
227
+ content={`${installed ? "⏎ reinstall (repairs the unit) · " : ""}CLI: spinuptui scheduler add | list | status`}
228
+ fg={theme.textFaint}
229
+ />
230
+ </>
231
+ )
232
+ }
@@ -0,0 +1,172 @@
1
+ // Scheduler's install overlay (Modules tab, ⏎ on Scheduler): shows exactly what
2
+ // `spinuptui scheduler install --plan` would write and run, and applies it on
3
+ // `y` — the confirm-before-firing pattern. Applying ends with the proving tick
4
+ // (a tick that really ran under launchd), so "done" here means the station's
5
+ // clock is running, not just that a file was written.
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 { applyInstall, binHasScheduler, NO_SCHEDULER, planInstall, type ApplyResult, type PlanResult } from "../../lib/schedulerInstall.ts"
15
+
16
+ type Phase = "confirm" | "applying" | "done" | "error"
17
+
18
+ export function SchedulerInstall() {
19
+ const { setSchedulerInstallOpen } = useStore()
20
+ const [plan, setPlan] = useState<PlanResult>(() => planInstall())
21
+ // Same refusal as `install --plan`: don't offer a plan whose binary has no
22
+ // Scheduler (it would launch the dashboard under launchd).
23
+ useEffect(() => {
24
+ if (!plan.ok) return
25
+ void binHasScheduler(plan).then((has) => {
26
+ if (!has) {
27
+ setPlan(NO_SCHEDULER)
28
+ setPhase("error")
29
+ }
30
+ })
31
+ // Checked once, for the plan computed on open.
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ }, [])
34
+ const [phase, setPhase] = useState<Phase>(plan.ok ? "confirm" : "error")
35
+ const [stage, setStage] = useState("")
36
+ const [result, setResult] = useState<ApplyResult | null>(null)
37
+ const [live, setLive] = useState(true)
38
+ useEffect(() => () => setLive(false), [])
39
+
40
+ const close = () => setSchedulerInstallOpen(false)
41
+
42
+ const apply = () => {
43
+ if (!plan.ok) return
44
+ setPhase("applying")
45
+ void applyInstall(plan, (s) => live && setStage(s)).then((r) => {
46
+ setResult(r)
47
+ setPhase(r.ok ? "done" : "error")
48
+ })
49
+ }
50
+
51
+ useKeyboard((key) => {
52
+ const name = key.name ?? ""
53
+ if (name === "escape" || name === "q") return close()
54
+ if (phase === "confirm" && name === "y" && plan.ok && plan.platform === "darwin") return apply()
55
+ if (phase === "error" && name === "r" && plan.ok) return setPhase("confirm")
56
+ })
57
+
58
+ return (
59
+ <box
60
+ style={{
61
+ position: "absolute",
62
+ top: 0,
63
+ left: 0,
64
+ width: "100%",
65
+ height: "100%",
66
+ flexDirection: "column",
67
+ backgroundColor: theme.bg,
68
+ zIndex: 210,
69
+ }}
70
+ >
71
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
72
+ <text content="⏱ Scheduler " fg={theme.brand} style={{ flexShrink: 0 }} />
73
+ <text content="install the station's clock" fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
74
+ </box>
75
+ <Centered>{body()}</Centered>
76
+ <StatusBar hints={hints()} showGlobal={false} />
77
+ </box>
78
+ )
79
+
80
+ function body() {
81
+ if (phase === "applying") {
82
+ return (
83
+ <box style={{ flexDirection: "column", alignItems: "center" }}>
84
+ <box style={{ flexDirection: "row" }}>
85
+ <Spinner />
86
+ <text content={` ${stage || "Installing…"}`} fg={theme.textDim} wrapMode="none" />
87
+ </box>
88
+ <box style={{ height: 1 }} />
89
+ <text content="Install finishes with a real tick from launchd (up to a minute)." fg={theme.textFaint} wrapMode="none" />
90
+ </box>
91
+ )
92
+ }
93
+ if (phase === "done" && result?.ok) {
94
+ return (
95
+ <Panel title=" Installed " active>
96
+ <box style={{ flexDirection: "column", width: 70, paddingTop: 1, paddingBottom: 1 }}>
97
+ <text content={`✓ ${result.label} is ticking: its first run under launchd was at ${new Date(result.tickAt).toLocaleTimeString()}.`} fg={theme.good} />
98
+ <box style={{ height: 1 }} />
99
+ <text content="Queue a follow-up from any session:" fg={theme.textDim} />
100
+ <text content={' spinuptui scheduler add <id> --kind spinuptui --when "YYYY-MM-DD 08:00" \\'} fg={theme.accent} wrapMode="none" />
101
+ <text content={' --expires YYYY-MM-DD --created-by "…" -- incidents example.com'} fg={theme.accent} wrapMode="none" />
102
+ <box style={{ height: 1 }} />
103
+ {result.notes.map((n) => (
104
+ <text key={n} content={`Note: ${n}`} fg={theme.textFaint} />
105
+ ))}
106
+ <text content="Esc to close" fg={theme.textFaint} />
107
+ </box>
108
+ </Panel>
109
+ )
110
+ }
111
+ if (phase === "error" || !plan.ok) {
112
+ const r = !plan.ok ? plan : result && !result.ok ? result : null
113
+ return (
114
+ <Panel title=" Can't install " active>
115
+ <box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
116
+ <text content={`✕ ${r?.message ?? "Something went wrong."}`} fg={theme.bad} />
117
+ {r?.remedy && (
118
+ <>
119
+ <box style={{ height: 1 }} />
120
+ <text content={r.remedy} fg={theme.textDim} />
121
+ </>
122
+ )}
123
+ <box style={{ height: 1 }} />
124
+ <text content={plan.ok ? "Press r to see the plan again · Esc to close" : "Esc to close"} fg={theme.textFaint} />
125
+ </box>
126
+ </Panel>
127
+ )
128
+ }
129
+ const darwin = plan.platform === "darwin"
130
+ return (
131
+ <Panel title=" Install Scheduler's dispatcher " active>
132
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
133
+ <text content="Runs at login and every 5 minutes, unattended:" fg={theme.text} />
134
+ <text content={` ${truncate(plan.programArguments.join(" "), 72)}`} fg={theme.accent} wrapMode="none" />
135
+ <box style={{ height: 1 }} />
136
+ <text content={`Unit: ${truncate(plan.unitPath, 68)}`} fg={theme.textDim} wrapMode="none" />
137
+ <text content={`PATH: ${truncate(plan.env.PATH ?? "", 68)}`} fg={theme.textDim} wrapMode="none" />
138
+ <box style={{ height: 1 }} />
139
+ {plan.commands.map((c) => (
140
+ <text key={c} content={`· ${truncate(c, 72)}`} fg={theme.textDim} wrapMode="none" />
141
+ ))}
142
+ <box style={{ height: 1 }} />
143
+ {plan.notes.map((n) => (
144
+ <text key={n} content={`Note: ${n}`} fg={theme.textFaint} />
145
+ ))}
146
+ <box style={{ height: 1 }} />
147
+ <text
148
+ content={darwin ? "Press y to install · Esc to cancel" : "Installing on Linux isn't supported yet · Esc to close"}
149
+ fg={darwin ? theme.text : theme.warn}
150
+ wrapMode="none"
151
+ />
152
+ </box>
153
+ </Panel>
154
+ )
155
+ }
156
+
157
+ function hints() {
158
+ if (phase === "confirm" && plan.ok && plan.platform === "darwin") {
159
+ return [
160
+ { key: "y", label: "install" },
161
+ { key: "esc", label: "cancel" },
162
+ ]
163
+ }
164
+ if (phase === "error" && plan.ok) {
165
+ return [
166
+ { key: "r", label: "back to plan" },
167
+ { key: "esc", label: "close" },
168
+ ]
169
+ }
170
+ return [{ key: "esc", label: "close" }]
171
+ }
172
+ }