spinuptui 0.19.0

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 (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. package/src/version.ts +8 -0
@@ -0,0 +1,571 @@
1
+ // Site monitoring overlay — opened with `m` in the sites pane.
2
+ //
3
+ // The monitor screen is the default; the Kuma connect form is opt-in (`c`, or
4
+ // pressing `a` while unconnected), so the vanity-page refresh works with no
5
+ // Uptime Kuma at all:
6
+ // - `R` on a vanity site (domain = server name) re-publishes the embedded page
7
+ // — the upgrade path for pages seeded by older Spinup versions. Without a
8
+ // Kuma connection that's ALL it does; with one it also registers the healthz
9
+ // + load push monitors and installs the heartbeat cron.
10
+ // - `a` registers monitors for this site (vanity: healthz + load push + cron;
11
+ // regular site: homepage monitor only — client site files are never touched).
12
+ // - `f` (regular sites) calibrates the front-page check: reads the live front
13
+ // page, derives a template fingerprint (body class / canonical — survives
14
+ // copy edits), and registers a Kuma keyword monitor asserting it at a chosen
15
+ // window. Catches "the cache is serving the wrong page" (HTTP stays 200).
16
+ // Re-running recalibrates in place (monitor history survives a redesign).
17
+ // - `n` (alerts) lists Kuma's notification providers by name (Telegram, email,
18
+ // …) and toggles them per site across all of the site's Spinup monitors —
19
+ // detection is real (providers ride the post-login event burst), only the
20
+ // provider's own setup (bot token etc.) stays in Kuma's settings.
21
+ // - `r` (vanity, confirm-gated) rotates the monitoring secrets: new push token
22
+ // edited into the existing monitor (history kept), cron rewritten, new health
23
+ // key re-seeded — so secrets shown on a screencast can be killed right after.
24
+ // - The connect form verifies by actually logging in before anything persists;
25
+ // the minted JWT is stored so later sessions (and 2FA accounts) log in by
26
+ // token. Env-sourced connections (SPINUP_KUMA_*) never see the form.
27
+
28
+ import { useEffect, useState } from "react"
29
+ import { useKeyboard } from "@opentui/react"
30
+ import { theme } from "../../lib/theme.ts"
31
+ import { Panel, Centered, Field, SecretInput, Spinner } from "../components.tsx"
32
+ import { StatusBar } from "../StatusBar.tsx"
33
+ import { useStore, type KumaAlertProvider } from "../store.tsx"
34
+ import { isVanityPair } from "../../lib/vanitySite.ts"
35
+ import { runSiteDoctor, type DoctorReport } from "../../lib/siteDoctor.ts"
36
+ import { classifyStack } from "../../lib/stack.ts"
37
+
38
+ const BASE_FIELDS = ["url", "username", "password"] as const
39
+ // Fixed input width: the panel body is 66 wide, the label gutter is 12 ("❯ " +
40
+ // padEnd(10)), so 52 fills the row — roomy enough that long URLs stay visible.
41
+ const INPUT_W = 52
42
+
43
+ // Check windows for the front-page fingerprint monitor. The check is one GET
44
+ // served straight from nginx's page cache (no PHP), so even 5m costs the site
45
+ // nothing — the window is really "how long a wrong page may go unnoticed".
46
+ const FP_WINDOWS = [
47
+ { label: "5m", sec: 300 },
48
+ { label: "15m", sec: 900 },
49
+ { label: "30m", sec: 1800 },
50
+ { label: "1h", sec: 3600 },
51
+ ] as const
52
+
53
+ // The `n` (alerts) step's state machine: load → list providers → toggle.
54
+ type AlertsState =
55
+ | null
56
+ | { kind: "loading" }
57
+ | { kind: "error"; error: string }
58
+ | { kind: "ready"; providers: KumaAlertProvider[]; monitorIds: number[]; idx: number; busy: boolean; flash: string | null }
59
+
60
+ export function KumaSite() {
61
+ const { kumaSite: site, setKumaSite, kumaConfigured, connectKuma, kumaMonitorFor, kumaOps, startKumaSetup, startVanityReseed, startKumaRotate, startFingerprintSetup, fetchKumaAlerts, toggleKumaAlert, clearKumaOp, kumaStatus, servers, setInputMode } = useStore()
62
+
63
+ const [draft, setDraft] = useState({ url: "", username: "", password: "" })
64
+ const [fieldIdx, setFieldIdx] = useState(0)
65
+ const [busy, setBusy] = useState(false)
66
+ const [error, setError] = useState<string | null>(null)
67
+ const [connectedVersion, setConnectedVersion] = useState<string | null>(null)
68
+ const [showConnect, setShowConnect] = useState(false)
69
+ // `r` arms a confirm (rotation kills the live push URL / health key the moment
70
+ // it fires — never do that on a stray keypress); y/⏎ fires, Esc disarms.
71
+ const [confirmRotate, setConfirmRotate] = useState(false)
72
+ // `f` opens the front-page check-window picker; ⏎ calibrates & registers.
73
+ const [fpPick, setFpPick] = useState(false)
74
+ const [fpIdx, setFpIdx] = useState(0)
75
+ // `n` opens the alerts step — a live read of Kuma's notification providers and
76
+ // which are attached to this site's monitors; ⏎ toggles the selected one.
77
+ const [alerts, setAlerts] = useState<AlertsState>(null)
78
+ // `d` runs the doctor — read-only HTTP diagnosis (works without Kuma).
79
+ const [doctor, setDoctor] = useState<null | { kind: "loading" } | { kind: "ready"; report: DoctorReport }>(null)
80
+ // 2FA: revealed only after Kuma answers `tokenRequired` to a correct password.
81
+ // The code is used once — the stored JWT covers every later login.
82
+ const [needsTwofa, setNeedsTwofa] = useState(false)
83
+ const [twofa, setTwofa] = useState("")
84
+ const fields: string[] = needsTwofa ? [...BASE_FIELDS, "2FA code"] : [...BASE_FIELDS]
85
+
86
+ const server = site ? servers.find((s) => s.id === site.server_id) : undefined
87
+ const isVanity = !!site && !!server && isVanityPair(site.domain, server.name)
88
+ const registered = site ? kumaMonitorFor(site.domain) : null
89
+ const op = site ? kumaOps.get(site.id) : undefined
90
+ // Not gated on kumaConfigured: `c` must also work as a RE-connect (e.g. the
91
+ // stored JWT went stale after a Kuma password change on a 2FA account —
92
+ // without this the only fix would be hand-editing config.json).
93
+ const connecting = showConnect
94
+
95
+ // While the connect form is up, its inputs own the keyboard (suppresses the
96
+ // global single-key shortcuts); cleared on connect/close.
97
+ useEffect(() => {
98
+ setInputMode(connecting)
99
+ return () => setInputMode(false)
100
+ }, [connecting, setInputMode])
101
+
102
+ const runDoctor = () => {
103
+ if (!site) return
104
+ setDoctor({ kind: "loading" })
105
+ const proto = site.https?.enabled ? "https" : "http"
106
+ void runSiteDoctor({
107
+ url: `${proto}://${site.domain}/`,
108
+ expectedKeyword: registered?.fingerprint?.keyword ?? null,
109
+ pageCacheEnabled: site.page_cache?.enabled ?? null,
110
+ sshTarget: site.site_user && server ? `${site.site_user}@${server.name}` : null,
111
+ isWordPress: !!site.is_wordpress,
112
+ // Bedrock relocates the login under /wp/ — aim the door probe there.
113
+ loginPath: classifyStack(site) === "Bedrock" ? "/wp/wp-login.php" : "/wp-login.php",
114
+ }).then((report) => setDoctor((prev) => (prev ? { kind: "ready", report } : prev)))
115
+ }
116
+
117
+ const close = () => {
118
+ // A settled result was seen — forget it so the overlay opens fresh next
119
+ // time (a RUNNING op stays: esc deliberately backgrounds it).
120
+ if (site && op && op.status !== "running") clearKumaOp(site.id)
121
+ setInputMode(false)
122
+ setKumaSite(null)
123
+ }
124
+
125
+ const verify = () => {
126
+ if (busy) return
127
+ if (!draft.url.trim() || !draft.username.trim() || !draft.password) {
128
+ setError("URL, username and password are all needed.")
129
+ return
130
+ }
131
+ if (needsTwofa && !twofa.trim()) {
132
+ setError("Enter the current 6-digit code from your authenticator.")
133
+ return
134
+ }
135
+ setBusy(true)
136
+ setError(null)
137
+ void connectKuma(draft, needsTwofa ? twofa.trim() : undefined).then((r) => {
138
+ setBusy(false)
139
+ if (r.ok) {
140
+ setConnectedVersion(r.version ?? "connected")
141
+ setShowConnect(false)
142
+ setInputMode(false)
143
+ return
144
+ }
145
+ if (r.tokenRequired && !needsTwofa) {
146
+ // Password was right; the account wants a TOTP. Reveal the field and
147
+ // land the cursor on it.
148
+ setNeedsTwofa(true)
149
+ setFieldIdx(BASE_FIELDS.length)
150
+ setError("2FA is on — enter the current code (needed once; a token is stored after).")
151
+ return
152
+ }
153
+ setError(r.error)
154
+ })
155
+ }
156
+
157
+ useKeyboard((key) => {
158
+ const raw = key.name ?? ""
159
+ const name = key.shift && raw.length === 1 ? raw.toUpperCase() : raw
160
+ if (connecting) {
161
+ // The focused <input> consumes printable keys; only navigation reaches us.
162
+ if (name === "escape") {
163
+ if (busy) return
164
+ return setShowConnect(false) // back to the monitor screen, not out
165
+ }
166
+ if (name === "return") {
167
+ if (busy) return
168
+ if (fieldIdx < fields.length - 1) return setFieldIdx(fieldIdx + 1)
169
+ return verify()
170
+ }
171
+ if (name === "tab" || name === "down") return setFieldIdx((i) => Math.min(i + 1, fields.length - 1))
172
+ if (name === "up") return setFieldIdx((i) => Math.max(i - 1, 0))
173
+ return
174
+ }
175
+ if (confirmRotate) {
176
+ if ((name === "y" || name === "return") && site) {
177
+ setConfirmRotate(false)
178
+ return startKumaRotate(site)
179
+ }
180
+ if (name === "escape" || name === "n" || name === "q") return setConfirmRotate(false)
181
+ return
182
+ }
183
+ if (doctor) {
184
+ if (name === "escape" || name === "q") return setDoctor(null)
185
+ if (doctor.kind !== "ready") return
186
+ if (name === "d") return runDoctor()
187
+ if (name === "f" && !isVanity && doctor.report.verdict === "recalibrate") {
188
+ // The doctor's own suggestion — jump straight into recalibration.
189
+ setDoctor(null)
190
+ const storedSec = registered?.fingerprint?.interval
191
+ const idx = FP_WINDOWS.findIndex((w) => w.sec === storedSec)
192
+ setFpIdx(idx >= 0 ? idx : 0)
193
+ return setFpPick(true)
194
+ }
195
+ return
196
+ }
197
+ if (alerts) {
198
+ if (alerts.kind === "ready" && alerts.busy) return // a toggle is mid-flight
199
+ if (name === "escape" || name === "q") return setAlerts(null)
200
+ if (alerts.kind !== "ready") return
201
+ if (name === "up" || name === "k") return setAlerts({ ...alerts, idx: Math.max(alerts.idx - 1, 0), flash: null })
202
+ if (name === "down" || name === "j") return setAlerts({ ...alerts, idx: Math.min(alerts.idx + 1, alerts.providers.length - 1), flash: null })
203
+ if ((name === "return" || name === "space" || name === " ") && site && alerts.providers.length > 0) {
204
+ const p = alerts.providers[alerts.idx]!
205
+ const on = !p.attachedAll
206
+ setAlerts({ ...alerts, busy: true, flash: null })
207
+ void toggleKumaAlert(site, p.id, on, alerts.monitorIds).then((r) => {
208
+ setAlerts((prev) => {
209
+ if (!prev || prev.kind !== "ready") return prev
210
+ if (!r.ok) return { ...prev, busy: false, flash: `✕ ${r.error}` }
211
+ const providers = prev.providers.map((q) => (q.id === p.id ? { ...q, attachedAll: on, attachedAny: on } : q))
212
+ const n = prev.monitorIds.length
213
+ return { ...prev, providers, busy: false, flash: `✓ ${p.name} ${on ? "now alerts" : "no longer alerts"} for ${n} monitor${n === 1 ? "" : "s"}` }
214
+ })
215
+ })
216
+ return
217
+ }
218
+ return
219
+ }
220
+ if (fpPick) {
221
+ if (name === "left" || name === "up" || name === "h" || name === "k") return setFpIdx((i) => Math.max(i - 1, 0))
222
+ if (name === "right" || name === "down" || name === "l" || name === "j" || name === "tab") return setFpIdx((i) => Math.min(i + 1, FP_WINDOWS.length - 1))
223
+ if (/^[1-4]$/.test(name)) return setFpIdx(Number(name) - 1)
224
+ if (name === "return" && site) {
225
+ setFpPick(false)
226
+ return startFingerprintSetup(site, FP_WINDOWS[fpIdx]!.sec)
227
+ }
228
+ if (name === "escape" || name === "q") return setFpPick(false)
229
+ return
230
+ }
231
+ if (name === "c") return setShowConnect(true)
232
+ if (name === "f" && !isVanity && site && op?.status !== "running") {
233
+ if (!kumaConfigured) return setShowConnect(true) // the check lives in Kuma
234
+ // Preselect the stored window on recalibration so ⏎⏎ keeps it.
235
+ const storedSec = registered?.fingerprint?.interval
236
+ const idx = FP_WINDOWS.findIndex((w) => w.sec === storedSec)
237
+ setFpIdx(idx >= 0 ? idx : 0)
238
+ return setFpPick(true)
239
+ }
240
+ if (name === "d" && !isVanity && site && op?.status !== "running") {
241
+ // Pure HTTP — deliberately NOT gated on a Kuma connection.
242
+ return runDoctor()
243
+ }
244
+ if (name === "n" && site && op?.status !== "running") {
245
+ if (!kumaConfigured) return setShowConnect(true) // alert wiring lives in Kuma
246
+ setAlerts({ kind: "loading" })
247
+ void fetchKumaAlerts(site).then((r) => {
248
+ setAlerts(r.ok ? { kind: "ready", providers: r.providers, monitorIds: r.monitorIds, idx: 0, busy: false, flash: null } : { kind: "error", error: r.error })
249
+ })
250
+ return
251
+ }
252
+ if (name === "r" && isVanity && site && op?.status !== "running") return setConfirmRotate(true)
253
+ if (name === "a" && site && op?.status !== "running") {
254
+ if (!kumaConfigured) return setShowConnect(true) // monitors need a connection
255
+ return startKumaSetup(site)
256
+ }
257
+ if (name === "R" && isVanity && site && op?.status !== "running") {
258
+ // Connected: re-publish + monitors + cron. Unconnected: just the page.
259
+ return kumaConfigured ? startKumaSetup(site, { reseed: true }) : startVanityReseed(site)
260
+ }
261
+ if (name === "escape" || name === "q") return close()
262
+ })
263
+
264
+ if (!site) return null
265
+
266
+ return (
267
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 215 }}>
268
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
269
+ <text content="◉ Site monitoring " fg={theme.brand} style={{ flexShrink: 0 }} />
270
+ <text content={site.domain} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
271
+ </box>
272
+
273
+ <Centered>{connecting ? renderConnect() : renderMonitor()}</Centered>
274
+
275
+ <StatusBar hints={hints()} showGlobal={false} />
276
+ </box>
277
+ )
278
+
279
+ function renderConnect() {
280
+ return (
281
+ <Panel title=" Connect Uptime Kuma " active>
282
+ <box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
283
+ <text content="One-time setup: your Uptime Kuma instance's URL and login." fg={theme.textDim} wrapMode="none" />
284
+ <text content="Verified by logging in before anything is saved (config, 0600)." fg={theme.textFaint} wrapMode="none" />
285
+ <box style={{ height: 1 }} />
286
+ {fields.map((f, i) => (
287
+ <box key={f} style={{ flexDirection: "column" }}>
288
+ <box style={{ flexDirection: "row" }}>
289
+ <text content={`${i === fieldIdx ? "❯" : " "} ${f.padEnd(10)}`} fg={i === fieldIdx ? theme.brand : theme.textDim} style={{ flexShrink: 0 }} />
290
+ {f === "password" ? (
291
+ <SecretInput focused={i === fieldIdx && !busy} value={draft.password} onChange={(v: string) => setDraft((d) => ({ ...d, password: v }))} onSubmit={verify} width={INPUT_W} />
292
+ ) : f === "2FA code" ? (
293
+ <input
294
+ focused={i === fieldIdx && !busy}
295
+ value={twofa}
296
+ onInput={setTwofa}
297
+ placeholder="123456"
298
+ style={{ width: INPUT_W, backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
299
+ />
300
+ ) : (
301
+ <input
302
+ focused={i === fieldIdx && !busy}
303
+ value={draft[f as "url" | "username"]}
304
+ onInput={(v: string) => setDraft((d) => ({ ...d, [f]: v }))}
305
+ placeholder={f === "url" ? "https://kuma.example.com" : ""}
306
+ style={{ width: INPUT_W, backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }}
307
+ />
308
+ )}
309
+ </box>
310
+ {i < fields.length - 1 && <box style={{ height: 1 }} />}
311
+ </box>
312
+ ))}
313
+ <box style={{ height: 1 }} />
314
+ {busy ? (
315
+ <box style={{ flexDirection: "row" }}>
316
+ <Spinner />
317
+ <text content=" Logging in…" fg={theme.textDim} wrapMode="none" />
318
+ </box>
319
+ ) : error ? (
320
+ <text content={`✕ ${error}`} fg={theme.bad} wrapMode="none" />
321
+ ) : (
322
+ <text content="⏎ next field / connect · Esc back" fg={theme.textFaint} wrapMode="none" />
323
+ )}
324
+ </box>
325
+ </Panel>
326
+ )
327
+ }
328
+
329
+ function renderMonitor() {
330
+ const fp = registered?.fingerprint
331
+ const fpUp = site ? (kumaStatus.get(site.domain)?.fingerprintUp ?? null) : null
332
+ const win = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600}h` : `${Math.round(sec / 60)}m`)
333
+ const fpValue = fp
334
+ ? `${fp.detail} · every ${win(fp.interval)}${fpUp === false ? " · WRONG PAGE SERVED" : fpUp === true ? " · ok" : ""}`
335
+ : registered?.fingerprintId
336
+ ? "registered (details unknown) · f recalibrates"
337
+ : "not calibrated · f"
338
+ const fpColor = fpUp === false ? theme.bad : fp ? (fpUp === true ? theme.good : theme.text) : theme.textFaint
339
+ return (
340
+ <Panel title=" Site monitoring " active>
341
+ <box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
342
+ {connectedVersion && (
343
+ <>
344
+ <text content={`● Uptime Kuma connected (${connectedVersion}).`} fg={theme.good} wrapMode="none" />
345
+ <box style={{ height: 1 }} />
346
+ </>
347
+ )}
348
+ <Field label="Site" value={site!.domain} />
349
+ <Field label="Kind" value={isVanity ? "vanity page (full server monitoring)" : "site homepage (up/down + cert expiry)"} />
350
+ <Field label="Kuma" value={kumaConfigured ? "connected · c to reconnect" : "not connected · c"} valueColor={kumaConfigured ? theme.good : theme.textFaint} />
351
+ <Field
352
+ label="Monitors"
353
+ value={
354
+ registered?.healthId
355
+ ? `registered${registered.pushId ? " (healthz + load push)" : ""}`
356
+ : registered?.fingerprintId
357
+ ? "front-page check only · a adds the up/down monitor"
358
+ : "not registered yet"
359
+ }
360
+ />
361
+ {!isVanity && <Field label="Front page" value={fpValue} valueColor={fpColor} />}
362
+ <box style={{ height: 1 }} />
363
+ {doctor ? (
364
+ renderDoctor()
365
+ ) : alerts ? (
366
+ renderAlerts()
367
+ ) : fpPick ? (
368
+ <>
369
+ <text content="Front-page check — alerts when the wrong page is served." fg={theme.text} wrapMode="none" />
370
+ <text content="Reads the live page now (while it's healthy), derives a template" fg={theme.textDim} wrapMode="none" />
371
+ <text content="fingerprint (body class / canonical — survives copy edits), and" fg={theme.textDim} wrapMode="none" />
372
+ <text content="registers a Kuma keyword monitor asserting it." fg={theme.textDim} wrapMode="none" />
373
+ <box style={{ height: 1 }} />
374
+ <box style={{ flexDirection: "row" }}>
375
+ <text content="Check window:" fg={theme.textDim} style={{ flexShrink: 0 }} />
376
+ {FP_WINDOWS.map((w, i) => (
377
+ <text key={w.label} content={i === fpIdx ? ` ▸${w.label}` : ` ${w.label}`} fg={i === fpIdx ? theme.brand : theme.textFaint} style={{ flexShrink: 0 }} />
378
+ ))}
379
+ </box>
380
+ <box style={{ height: 1 }} />
381
+ <text content="←/→ or 1-4 window · ⏎ calibrate & register · esc cancel" fg={theme.textFaint} wrapMode="none" />
382
+ </>
383
+ ) : confirmRotate ? (
384
+ <>
385
+ <text content="Rotate this site's monitoring secrets?" fg={theme.warn} wrapMode="none" />
386
+ <text content="A new push URL and health key are minted; the old ones stop" fg={theme.textDim} wrapMode="none" />
387
+ <text content="working immediately. Kuma monitor history is kept." fg={theme.textDim} wrapMode="none" />
388
+ <box style={{ height: 1 }} />
389
+ <text content="y / ⏎ rotate · esc cancel" fg={theme.textFaint} wrapMode="none" />
390
+ </>
391
+ ) : op?.status === "running" ? (
392
+ <box style={{ flexDirection: "row" }}>
393
+ <Spinner />
394
+ <text content={` ${op.detail}`} fg={theme.textDim} wrapMode="none" />
395
+ </box>
396
+ ) : isVanity ? (
397
+ <>
398
+ {/* A settled result shows ABOVE the actions, never instead of them —
399
+ the overlay must always offer its next moves. */}
400
+ {op?.status === "error" && (
401
+ <>
402
+ <text content={`✕ ${op.error}`} fg={theme.bad} />
403
+ <box style={{ height: 1 }} />
404
+ </>
405
+ )}
406
+ {op?.status === "done" && (
407
+ <>
408
+ <text content={`✓ ${op.detail}`} fg={theme.good} />
409
+ <box style={{ height: 1 }} />
410
+ </>
411
+ )}
412
+ <text
413
+ content={kumaConfigured ? "R — re-publish the page, register monitors & install the cron" : "R — re-publish the page (current version, health endpoints)"}
414
+ fg={theme.textDim}
415
+ wrapMode="none"
416
+ />
417
+ <text
418
+ content={kumaConfigured ? "a — register monitors & cron without touching the page" : "a — register Kuma monitors (connects Uptime Kuma first)"}
419
+ fg={theme.textDim}
420
+ wrapMode="none"
421
+ />
422
+ <text content="r — rotate secrets: new push URL + health key (old ones die)" fg={theme.textDim} wrapMode="none" />
423
+ <text content="n — alerts: choose where this server's checks notify" fg={theme.textDim} wrapMode="none" />
424
+ </>
425
+ ) : (
426
+ <>
427
+ {op?.status === "error" && (
428
+ <>
429
+ <text content={`✕ ${op.error}`} fg={theme.bad} />
430
+ <box style={{ height: 1 }} />
431
+ </>
432
+ )}
433
+ {op?.status === "done" && (
434
+ <>
435
+ <text content={`✓ ${op.detail}`} fg={theme.good} />
436
+ <box style={{ height: 1 }} />
437
+ </>
438
+ )}
439
+ <text content={kumaConfigured ? "a — register a homepage monitor in Uptime Kuma" : "a — register a homepage monitor (connects Uptime Kuma first)"} fg={theme.textDim} wrapMode="none" />
440
+ <text
441
+ content={registered?.fingerprintId ? "f — recalibrate the front-page check (e.g. after a redesign)" : "f — front-page check: alert when the wrong page is served"}
442
+ fg={theme.textDim}
443
+ wrapMode="none"
444
+ />
445
+ <text content="n — alerts: choose where this site's checks notify" fg={theme.textDim} wrapMode="none" />
446
+ <text content="d — doctor: prove whether the cache is serving the wrong page" fg={theme.textDim} wrapMode="none" />
447
+ </>
448
+ )}
449
+ </box>
450
+ </Panel>
451
+ )
452
+ }
453
+
454
+ function renderDoctor() {
455
+ if (!doctor) return null
456
+ if (doctor.kind === "loading") {
457
+ return (
458
+ <box style={{ flexDirection: "row" }}>
459
+ <Spinner />
460
+ <text content=" Examining the live site (cached vs fresh render)…" fg={theme.textDim} wrapMode="none" />
461
+ </box>
462
+ )
463
+ }
464
+ const r = doctor.report
465
+ const glyph = { ok: "✓", warn: "!", bad: "✕", info: "·" } as const
466
+ const gColor = { ok: theme.good, warn: theme.warn, bad: theme.bad, info: theme.textFaint } as const
467
+ const vColor = r.verdict === "healthy" ? theme.good : r.verdict === "stale-cache" || r.verdict === "down" || r.verdict === "partial-outage" ? theme.bad : theme.warn
468
+ return (
469
+ <>
470
+ {r.checks.map((c, i) => (
471
+ <box key={i} style={{ flexDirection: "row" }}>
472
+ <text content={`${glyph[c.status]} ${c.label.padEnd(13)}`} fg={gColor[c.status]} style={{ flexShrink: 0 }} />
473
+ <text content={c.detail} fg={c.status === "bad" ? theme.bad : theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
474
+ </box>
475
+ ))}
476
+ <box style={{ height: 1 }} />
477
+ <text content={r.summary} fg={vColor} wrapMode="none" />
478
+ {r.runbook.length > 0 && (
479
+ <>
480
+ <box style={{ height: 1 }} />
481
+ {r.runbook.map((line, i) => (
482
+ <text key={i} content={` ${line}`} fg={line.startsWith("#") ? theme.textFaint : theme.text} wrapMode="none" />
483
+ ))}
484
+ </>
485
+ )}
486
+ <box style={{ height: 1 }} />
487
+ <text content={`d re-run${r.verdict === "recalibrate" ? " · f recalibrate" : ""} · esc back`} fg={theme.textFaint} wrapMode="none" />
488
+ </>
489
+ )
490
+ }
491
+
492
+ function renderAlerts() {
493
+ if (!alerts) return null
494
+ if (alerts.kind === "loading") {
495
+ return (
496
+ <box style={{ flexDirection: "row" }}>
497
+ <Spinner />
498
+ <text content=" Reading notification providers from Kuma…" fg={theme.textDim} wrapMode="none" />
499
+ </box>
500
+ )
501
+ }
502
+ if (alerts.kind === "error") {
503
+ return (
504
+ <>
505
+ <text content={`✕ ${alerts.error}`} fg={theme.bad} wrapMode="none" />
506
+ <box style={{ height: 1 }} />
507
+ <text content="esc back" fg={theme.textFaint} wrapMode="none" />
508
+ </>
509
+ )
510
+ }
511
+ if (alerts.providers.length === 0) {
512
+ return (
513
+ <>
514
+ <text content="No notification providers in Uptime Kuma yet." fg={theme.text} wrapMode="none" />
515
+ <text content="Add one in Kuma → Settings → Notifications (Telegram, email, …)," fg={theme.textDim} wrapMode="none" />
516
+ <text content="then press n again — SpinupTUI wires it to this site's checks." fg={theme.textDim} wrapMode="none" />
517
+ <box style={{ height: 1 }} />
518
+ <text content="esc back" fg={theme.textFaint} wrapMode="none" />
519
+ </>
520
+ )
521
+ }
522
+ const n = alerts.monitorIds.length
523
+ return (
524
+ <>
525
+ <text content={`Alerts — where this site's ${n} check${n === 1 ? "" : "s"} notify:`} fg={theme.text} wrapMode="none" />
526
+ <box style={{ height: 1 }} />
527
+ {alerts.providers.map((p, i) => {
528
+ const glyph = p.attachedAll ? "✓" : p.attachedAny ? "◐" : "○"
529
+ const glyphColor = p.attachedAll ? theme.good : p.attachedAny ? theme.warn : theme.textFaint
530
+ const suffix = p.attachedAny && !p.attachedAll ? " (some monitors only — ⏎ attaches to all)" : !p.active ? " (paused in Kuma)" : ""
531
+ return (
532
+ <box key={p.id} style={{ flexDirection: "row" }}>
533
+ <text content={i === alerts.idx ? "❯ " : " "} fg={theme.brand} style={{ flexShrink: 0 }} />
534
+ <text content={glyph} fg={glyphColor} style={{ flexShrink: 0 }} />
535
+ <text content={` ${p.name}${suffix}`} fg={i === alerts.idx ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
536
+ </box>
537
+ )
538
+ })}
539
+ <box style={{ height: 1 }} />
540
+ {alerts.busy ? (
541
+ <box style={{ flexDirection: "row" }}>
542
+ <Spinner />
543
+ <text content=" Rewiring…" fg={theme.textDim} wrapMode="none" />
544
+ </box>
545
+ ) : alerts.flash ? (
546
+ <text content={alerts.flash} fg={alerts.flash.startsWith("✓") ? theme.good : theme.bad} wrapMode="none" />
547
+ ) : (
548
+ <text content="↑↓ select · ⏎ toggle on/off · esc done" fg={theme.textFaint} wrapMode="none" />
549
+ )}
550
+ </>
551
+ )
552
+ }
553
+
554
+ function hints() {
555
+ if (connecting) return [{ key: "⏎", label: "next / connect" }, { key: "esc", label: "back" }]
556
+ if (confirmRotate) return [{ key: "y/⏎", label: "rotate" }, { key: "esc", label: "cancel" }]
557
+ if (doctor) return doctor.kind === "ready" ? [{ key: "d", label: "re-run" }, { key: "esc", label: "back" }] : [{ key: "esc", label: "back" }]
558
+ if (alerts) return alerts.kind === "ready" && alerts.providers.length > 0 ? [{ key: "↑↓", label: "select" }, { key: "⏎", label: "toggle" }, { key: "esc", label: "done" }] : [{ key: "esc", label: "back" }]
559
+ if (fpPick) return [{ key: "←/→", label: "window" }, { key: "⏎", label: "calibrate" }, { key: "esc", label: "cancel" }]
560
+ if (op?.status === "running") return [{ key: "esc", label: "background" }]
561
+ const base: { key: string; label: string }[] = []
562
+ if (isVanity) base.push({ key: "R", label: kumaConfigured ? "refresh page + monitors" : "refresh page" })
563
+ if (isVanity) base.push({ key: "r", label: "rotate secrets" })
564
+ base.push({ key: "a", label: registered ? "repair monitors" : "add monitors" })
565
+ if (!isVanity) base.push({ key: "f", label: registered?.fingerprintId ? "recalibrate front page" : "front-page check" })
566
+ if (!isVanity) base.push({ key: "d", label: "doctor" })
567
+ base.push({ key: "n", label: "alerts" })
568
+ base.push({ key: "c", label: kumaConfigured ? "reconnect Kuma" : "connect Kuma" })
569
+ return [...base, { key: "esc", label: "close" }]
570
+ }
571
+ }