spinuptui 0.20.0 → 0.21.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.
@@ -1,19 +1,23 @@
1
1
  // Site monitoring overlay — opened with `m` in the sites pane.
2
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).
3
+ // Full-screen two-pane browser (mirrors ProviderConnect.tsx): a left list of
4
+ // this site's monitor kinds (source: MONITOR_GLOSSARY, filtered by vanity vs
5
+ // regular) with a status dot, and a right detail pane showing whichever one
6
+ // is highlighted — its one-liner, longer mechanism description, live status,
7
+ // and how to act on it. The Kuma connect form is opt-in (`c`, or pressing
8
+ // `a` while unconnected), so the vanity-page refresh works with no Uptime
9
+ // Kuma at all:
10
+ // - `↑↓`/`jk` move the highlight in the left list.
11
+ // - `a` acts on WHICHEVER monitor is currently selected — one key, not one
12
+ // per kind. Front page selected → opens the calibration window picker;
13
+ // Cache bypass selected → opens its window picker; anything else →
14
+ // register/repair (vanity: healthz + load push + cron + Redis/PHP-fatal
15
+ // sentinels if sudo is connected; regular site: homepage monitor only —
16
+ // client site files are never touched).
17
+ // - `R` on a vanity site (domain = server name) re-publishes the embedded
18
+ // page — the upgrade path for pages seeded by older Spinup versions.
19
+ // Without a Kuma connection that's ALL it does; with one it also
20
+ // registers monitors and installs the heartbeat cron.
17
21
  // - `n` (alerts) lists Kuma's notification providers by name (Telegram, email,
18
22
  // …) and toggles them per site across all of the site's Spinup monitors —
19
23
  // detection is real (providers ride the post-login event burst), only the
@@ -21,17 +25,20 @@
21
25
  // - `r` (vanity, confirm-gated) rotates the monitoring secrets: new push token
22
26
  // edited into the existing monitor (history kept), cron rewritten, new health
23
27
  // key re-seeded — so secrets shown on a screencast can be killed right after.
28
+ // - `d` (regular sites) runs the doctor — a pure-HTTP cache-vs-fresh-render
29
+ // diagnosis, deliberately not gated on a Kuma connection.
24
30
  // - The connect form verifies by actually logging in before anything persists;
25
31
  // the minted JWT is stored so later sessions (and 2FA accounts) log in by
26
32
  // token. Env-sourced connections (SPINUP_KUMA_*) never see the form.
27
33
 
28
34
  import { useEffect, useState } from "react"
29
35
  import { useKeyboard } from "@opentui/react"
30
- import { theme } from "../../lib/theme.ts"
36
+ import { theme, statusDot, statusColor } from "../../lib/theme.ts"
31
37
  import { Panel, Centered, Field, SecretInput, Spinner } from "../components.tsx"
32
38
  import { StatusBar } from "../StatusBar.tsx"
33
39
  import { useStore, type KumaAlertProvider } from "../store.tsx"
34
40
  import { isVanityPair } from "../../lib/vanitySite.ts"
41
+ import { openUrl } from "../../lib/open.ts"
35
42
  import { runSiteDoctor, type DoctorReport } from "../../lib/siteDoctor.ts"
36
43
  import { classifyStack } from "../../lib/stack.ts"
37
44
 
@@ -50,6 +57,88 @@ const FP_WINDOWS = [
50
57
  { label: "1h", sec: 3600 },
51
58
  ] as const
52
59
 
60
+ // Check windows for the cache-bypass monitor. Unlike the fingerprint check
61
+ // (served from cache, costs nothing), this forces a real PHP render every
62
+ // time — the window is really "how much added load is acceptable", so it
63
+ // skews much longer. Defaults to 1h.
64
+ const BYPASS_WINDOWS = [
65
+ { label: "30m", sec: 1800 },
66
+ { label: "1h", sec: 3600 },
67
+ { label: "2h", sec: 7200 },
68
+ { label: "4h", sec: 14400 },
69
+ { label: "6h", sec: 21600 },
70
+ ] as const
71
+ const BYPASS_DEFAULT_IDX = 1 // "1h"
72
+
73
+ // The `i` glossary overlay's content — one entry per monitor kind this view
74
+ // can register, accurate to the actual mechanism (not an approximation of
75
+ // it). `contexts` gates which site kind sees each entry.
76
+ interface GlossaryEntry {
77
+ key: string
78
+ label: string
79
+ tagline: string
80
+ detail: string
81
+ contexts: Array<"vanity" | "regular">
82
+ }
83
+ const MONITOR_GLOSSARY: GlossaryEntry[] = [
84
+ {
85
+ key: "health-site",
86
+ label: "Site health check",
87
+ tagline: "Is this specific website up right now?",
88
+ detail:
89
+ "A plain HTTP check on this site's own homepage, plus certificate-expiry alerting. Served straight from the page cache when one exists — cheap, but for that exact reason it can't see a PHP fatal hiding behind a still-warm cache. This is about whether THIS site answers — independent of whether the server it lives on is under strain.",
90
+ contexts: ["regular"],
91
+ },
92
+ {
93
+ key: "health-server",
94
+ label: "Server health check",
95
+ tagline: "Is the server itself under strain?",
96
+ detail:
97
+ "Hits the vanity page's /?healthz endpoint — a small script that reports the server's own resource state (load, disk) and returns 503 when it's strained. This is about the server's health, not any one site's: a customer site can be completely broken while this stays green, and the server can be strained while every site still limps out a 200.",
98
+ contexts: ["vanity"],
99
+ },
100
+ {
101
+ key: "push",
102
+ label: "Load heartbeat",
103
+ tagline: "Is the server itself still alive?",
104
+ detail:
105
+ "A once-a-minute cron on the server pushes its 1-minute load average to Kuma. It's a dead-man's-switch: the cron never reports \"down\" itself — it just goes silent if the server, the cron, or its network egress dies, and Kuma's own missed-heartbeat timeout is what raises the alarm.",
106
+ contexts: ["vanity"],
107
+ },
108
+ {
109
+ key: "redis",
110
+ label: "Redis sentinel",
111
+ tagline: "Is Redis actually answering?",
112
+ detail:
113
+ "The same heartbeat cron also runs redis-cli ping every minute and actively reports up/down. Unlike the load heartbeat, this alerts immediately when Redis stops answering while the server itself is fine — important because SpinupWP's default object-cache drop-in makes a dead Redis fatal on every page-cache miss.",
114
+ contexts: ["vanity"],
115
+ },
116
+ {
117
+ key: "fatal",
118
+ label: "PHP-fatal sentinel",
119
+ tagline: "Did any site on this server just start fataling?",
120
+ detail:
121
+ "A root-level cron scans every site's error/debug logs each minute for new \"PHP Fatal error\" lines. Catches the exact blind spot where a fatal happens behind a still-warm page cache and the plain per-site checks never notice. One monitor per server (not per site) to avoid pileup — the specific affected domain is named in the down alert's own message.",
122
+ contexts: ["vanity"],
123
+ },
124
+ {
125
+ key: "fingerprint",
126
+ label: "Front page",
127
+ tagline: "Is the cache serving the right page?",
128
+ detail:
129
+ "At setup time, reads the live homepage and derives a template-identity fingerprint from whatever's actually there — a body-class token if one exists, otherwise the canonical link tag. From then on it asserts that same fingerprint stays present. Reads straight from the page cache, so it catches the cache serving a stale or wrong template even though the page still answers 200 — a failure a plain up/down check can't see.",
130
+ contexts: ["regular"],
131
+ },
132
+ {
133
+ key: "bypass",
134
+ label: "Cache bypass",
135
+ tagline: "Can PHP actually render this page right now?",
136
+ detail:
137
+ "The same homepage URL, but with a Cookie: wordpress_no_cache=1 header that forces the page cache to be skipped — so unlike the checks above, this one genuinely exercises PHP on every check. That's also why it costs the site something (a full render each time) and is opt-in rather than automatic, meant for sites with an actual history of PHP fatals hiding behind the cache.",
138
+ contexts: ["regular"],
139
+ },
140
+ ]
141
+
53
142
  // The `n` (alerts) step's state machine: load → list providers → toggle.
54
143
  type AlertsState =
55
144
  | null
@@ -57,8 +146,13 @@ type AlertsState =
57
146
  | { kind: "error"; error: string }
58
147
  | { kind: "ready"; providers: KumaAlertProvider[]; monitorIds: number[]; idx: number; busy: boolean; flash: string | null }
59
148
 
149
+ // A passive, header-line summary of alert wiring — fetched once when the
150
+ // overlay opens (not polled), kept in sync with `n`'s own toggles. Separate
151
+ // from AlertsState since it must survive after `n`'s interactive view closes.
152
+ type AlertSummary = null | { kind: "loading" } | { kind: "error"; error: string } | { kind: "ready"; providers: KumaAlertProvider[] }
153
+
60
154
  export function KumaSite() {
61
- const { kumaSite: site, setKumaSite, kumaConfigured, connectKuma, kumaMonitorFor, kumaOps, startKumaSetup, startVanityReseed, startKumaRotate, startFingerprintSetup, fetchKumaAlerts, toggleKumaAlert, clearKumaOp, kumaStatus, servers, setInputMode } = useStore()
155
+ const { kumaSite: site, setKumaSite, kumaConfigured, kumaUrl, connectKuma, kumaMonitorFor, kumaOps, startKumaSetup, startVanityReseed, startKumaRotate, startFingerprintSetup, startBypassMonitorSetup, removeSiteMonitor, fetchKumaAlerts, toggleKumaAlert, clearKumaOp, kumaStatus, servers, setInputMode } = useStore()
62
156
 
63
157
  const [draft, setDraft] = useState({ url: "", username: "", password: "" })
64
158
  const [fieldIdx, setFieldIdx] = useState(0)
@@ -69,12 +163,26 @@ export function KumaSite() {
69
163
  // `r` arms a confirm (rotation kills the live push URL / health key the moment
70
164
  // it fires — never do that on a stray keypress); y/⏎ fires, Esc disarms.
71
165
  const [confirmRotate, setConfirmRotate] = useState(false)
166
+ // `x` arms a confirm before deleting an opt-in monitor (front page / cache
167
+ // bypass) — remembers WHICH kind, unlike confirmRotate (only one target).
168
+ const [confirmRemove, setConfirmRemove] = useState<"fingerprint" | "bypass" | null>(null)
72
169
  // `f` opens the front-page check-window picker; ⏎ calibrates & registers.
73
170
  const [fpPick, setFpPick] = useState(false)
74
171
  const [fpIdx, setFpIdx] = useState(0)
172
+ // `b` opens the cache-bypass check-window picker; ⏎ registers/recalibrates.
173
+ // Own state (not shared with fpPick) — different window array and default.
174
+ const [bypassPick, setBypassPick] = useState(false)
175
+ const [bypassIdx, setBypassIdx] = useState(BYPASS_DEFAULT_IDX)
75
176
  // `n` opens the alerts step — a live read of Kuma's notification providers and
76
177
  // which are attached to this site's monitors; ⏎ toggles the selected one.
77
178
  const [alerts, setAlerts] = useState<AlertsState>(null)
179
+ // Passive header-line summary of alert wiring — fetched once per site open
180
+ // (see the effect below), not polled; kept in sync with `n`'s own toggles.
181
+ const [alertSummary, setAlertSummary] = useState<AlertSummary>(null)
182
+ // Which monitor kind is highlighted in the left-hand list (full-screen
183
+ // browser). Clamped against `entries.length` at read time, not here — the
184
+ // list's length depends on isVanity, computed after this point.
185
+ const [selectedIdx, setSelectedIdx] = useState(0)
78
186
  // `d` runs the doctor — read-only HTTP diagnosis (works without Kuma).
79
187
  const [doctor, setDoctor] = useState<null | { kind: "loading" } | { kind: "ready"; report: DoctorReport }>(null)
80
188
  // 2FA: revealed only after Kuma answers `tokenRequired` to a correct password.
@@ -92,6 +200,139 @@ export function KumaSite() {
92
200
  // without this the only fix would be hand-editing config.json).
93
201
  const connecting = showConnect
94
202
 
203
+ // The left-hand list's rows for this site's context, and the currently
204
+ // highlighted one — clamped so a stale index (e.g. from a previous site
205
+ // with more rows) never indexes past the end.
206
+ const entries = MONITOR_GLOSSARY.filter((e) => e.contexts.includes(isVanity ? "vanity" : "regular"))
207
+ const safeIdx = Math.min(selectedIdx, Math.max(entries.length - 1, 0))
208
+ const selected = entries[safeIdx]
209
+
210
+ const win = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600}h` : `${Math.round(sec / 60)}m`)
211
+ const healthUp = site ? (kumaStatus.get(site.domain)?.up ?? null) : null
212
+ const fpUp = site ? (kumaStatus.get(site.domain)?.fingerprintUp ?? null) : null
213
+ const redisUp = site ? (kumaStatus.get(site.domain)?.redisUp ?? null) : null
214
+ const fatalUp = site ? (kumaStatus.get(site.domain)?.fatalUp ?? null) : null
215
+ const bypassUp = site ? (kumaStatus.get(site.domain)?.bypassUp ?? null) : null
216
+ const fp = registered?.fingerprint
217
+ const fpValue = fp
218
+ ? `${fp.detail} · every ${win(fp.interval)}${fpUp === false ? " · WRONG PAGE SERVED" : fpUp === true ? " · ok" : ""}`
219
+ : registered?.fingerprintId
220
+ ? "registered (details unknown) · a recalibrates"
221
+ : "not calibrated · a"
222
+ const fpColor = fpUp === false ? theme.bad : fp ? (fpUp === true ? theme.good : theme.text) : theme.textFaint
223
+ const redisValue = !registered?.redisId
224
+ ? "not registered · a adds it"
225
+ : redisUp === false
226
+ ? "down"
227
+ : redisUp === true
228
+ ? "up"
229
+ : "registered (status unknown)"
230
+ const redisColor = redisUp === false ? theme.bad : registered?.redisId ? (redisUp === true ? theme.good : theme.text) : theme.textFaint
231
+ const fatalValue = !registered?.fatalId
232
+ ? "not registered · a adds it"
233
+ : fatalUp === false
234
+ ? "down"
235
+ : fatalUp === true
236
+ ? "up"
237
+ : "registered (status unknown)"
238
+ const fatalColor = fatalUp === false ? theme.bad : registered?.fatalId ? (fatalUp === true ? theme.good : theme.text) : theme.textFaint
239
+ const bypassValue = !registered?.bypassId
240
+ ? "not registered"
241
+ : bypassUp === false
242
+ ? "down · a recalibrates"
243
+ : bypassUp === true
244
+ ? "up · a recalibrates"
245
+ : "registered (status unknown) · a recalibrates"
246
+ const bypassColor = bypassUp === false ? theme.bad : registered?.bypassId ? (bypassUp === true ? theme.good : theme.text) : theme.textFaint
247
+
248
+ // Per-entry status, normalized into the { registered, upState } shape
249
+ // statusDot/statusColor's connection-style vocabulary maps onto (neither
250
+ // accepts "up"/"down" directly — see theme.ts).
251
+ function statusFor(key: string): { registered: boolean; upState: boolean | null; value: string; color: string } {
252
+ switch (key) {
253
+ case "health-site":
254
+ case "health-server":
255
+ return {
256
+ registered: !!registered?.healthId,
257
+ upState: healthUp,
258
+ value: !registered?.healthId
259
+ ? registered?.fingerprintId
260
+ ? "front-page check only"
261
+ : "not registered"
262
+ : healthUp === false
263
+ ? "down"
264
+ : healthUp === true
265
+ ? "up"
266
+ : "registered (status unknown)",
267
+ color: healthUp === false ? theme.bad : registered?.healthId ? (healthUp === true ? theme.good : theme.text) : theme.textFaint,
268
+ }
269
+ case "push":
270
+ // The load-heartbeat cron feeds the SAME shared `up` field as the
271
+ // health check (store.tsx's poll loop only sets it from push when
272
+ // health hasn't already) — there's no independent push-only signal.
273
+ return {
274
+ registered: !!registered?.pushId,
275
+ upState: healthUp,
276
+ value: !registered?.pushId ? "not registered" : healthUp === false ? "down" : healthUp === true ? "up" : "registered (status unknown)",
277
+ color: healthUp === false ? theme.bad : registered?.pushId ? (healthUp === true ? theme.good : theme.text) : theme.textFaint,
278
+ }
279
+ case "redis":
280
+ return { registered: !!registered?.redisId, upState: redisUp, value: redisValue, color: redisColor }
281
+ case "fatal":
282
+ return { registered: !!registered?.fatalId, upState: fatalUp, value: fatalValue, color: fatalColor }
283
+ case "fingerprint":
284
+ return { registered: !!registered?.fingerprintId, upState: fpUp, value: fpValue, color: fpColor }
285
+ case "bypass":
286
+ return { registered: !!registered?.bypassId, upState: bypassUp, value: bypassValue, color: bypassColor }
287
+ default:
288
+ return { registered: false, upState: null, value: "unknown", color: theme.textFaint }
289
+ }
290
+ }
291
+ // statusDot/statusColor's own vocabulary (theme.ts:26-47) is connection/
292
+ // deploy-style strings, not up/down — normalize here rather than there.
293
+ function dotStatus(s: { registered: boolean; upState: boolean | null }): string | null {
294
+ if (!s.registered) return null
295
+ if (s.upState === true) return "active"
296
+ if (s.upState === false) return "failed"
297
+ return "pending"
298
+ }
299
+ // The specific Kuma monitor id behind an entry, for the `o` deep-link — null
300
+ // when not yet registered (nothing to link to).
301
+ function monitorIdFor(key: string): number | null {
302
+ switch (key) {
303
+ case "health-site":
304
+ case "health-server":
305
+ return registered?.healthId ?? null
306
+ case "push":
307
+ return registered?.pushId ?? null
308
+ case "redis":
309
+ return registered?.redisId ?? null
310
+ case "fatal":
311
+ return registered?.fatalId ?? null
312
+ case "fingerprint":
313
+ return registered?.fingerprintId ?? null
314
+ case "bypass":
315
+ return registered?.bypassId ?? null
316
+ default:
317
+ return null
318
+ }
319
+ }
320
+
321
+ // Compresses alertSummary into one header line. Only flags the ABNORMAL
322
+ // case explicitly (partial wiring) — full wiring is the expected state and
323
+ // doesn't need its own callout.
324
+ function formatAlertSummary(): { text: string; color: string } {
325
+ if (!kumaConfigured) return { text: "Alerts: connect Kuma first · c", color: theme.textFaint }
326
+ if (!alertSummary || alertSummary.kind === "loading") return { text: "Alerts: checking…", color: theme.textFaint }
327
+ if (alertSummary.kind === "error") return { text: "Alerts: unavailable · n to retry", color: theme.textFaint }
328
+ if (alertSummary.providers.length === 0) return { text: "Alerts: none configured in Kuma · n to add one", color: theme.textFaint }
329
+ const attached = alertSummary.providers.filter((p) => p.attachedAny)
330
+ if (attached.length === 0) return { text: "Alerts: not wired to any provider · n to choose", color: theme.warn }
331
+ const names = attached.map((p) => p.name).join(", ")
332
+ const anyPartial = attached.some((p) => p.attachedAny && !p.attachedAll)
333
+ return { text: `Alerts: ${names}${anyPartial ? " (some monitors only)" : ""} · n to manage`, color: theme.good }
334
+ }
335
+
95
336
  // While the connect form is up, its inputs own the keyboard (suppresses the
96
337
  // global single-key shortcuts); cleared on connect/close.
97
338
  useEffect(() => {
@@ -99,6 +340,27 @@ export function KumaSite() {
99
340
  return () => setInputMode(false)
100
341
  }, [connecting, setInputMode])
101
342
 
343
+ // Silently fetch alert-wiring status once per site open (not polled — this
344
+ // needs a getMonitor round-trip per monitor, unlike the passive poll loop's
345
+ // beat reads, so it isn't "free" the way up/down status is). Re-fires on a
346
+ // genuinely different site (site?.domain toggles through undefined when the
347
+ // overlay closes, so reopening the same site re-fetches too).
348
+ useEffect(() => {
349
+ if (!site || !kumaConfigured) {
350
+ setAlertSummary(null)
351
+ return
352
+ }
353
+ let cancelled = false
354
+ setAlertSummary({ kind: "loading" })
355
+ void fetchKumaAlerts(site).then((r) => {
356
+ if (cancelled) return
357
+ setAlertSummary(r.ok ? { kind: "ready", providers: r.providers } : { kind: "error", error: r.error })
358
+ })
359
+ return () => {
360
+ cancelled = true
361
+ }
362
+ }, [site?.domain, kumaConfigured, fetchKumaAlerts])
363
+
102
364
  const runDoctor = () => {
103
365
  if (!site) return
104
366
  setDoctor({ kind: "loading" })
@@ -180,6 +442,15 @@ export function KumaSite() {
180
442
  if (name === "escape" || name === "n" || name === "q") return setConfirmRotate(false)
181
443
  return
182
444
  }
445
+ if (confirmRemove) {
446
+ if ((name === "y" || name === "return") && site) {
447
+ const kind = confirmRemove
448
+ setConfirmRemove(null)
449
+ return removeSiteMonitor(site, kind)
450
+ }
451
+ if (name === "escape" || name === "n" || name === "q") return setConfirmRemove(null)
452
+ return
453
+ }
183
454
  if (doctor) {
184
455
  if (name === "escape" || name === "q") return setDoctor(null)
185
456
  if (doctor.kind !== "ready") return
@@ -212,6 +483,12 @@ export function KumaSite() {
212
483
  const n = prev.monitorIds.length
213
484
  return { ...prev, providers, busy: false, flash: `✓ ${p.name} ${on ? "now alerts" : "no longer alerts"} for ${n} monitor${n === 1 ? "" : "s"}` }
214
485
  })
486
+ // Keep the header's passive summary in sync without a re-fetch.
487
+ if (r.ok) {
488
+ setAlertSummary((prev) =>
489
+ prev?.kind === "ready" ? { kind: "ready", providers: prev.providers.map((q) => (q.id === p.id ? { ...q, attachedAll: on, attachedAny: on } : q)) } : prev,
490
+ )
491
+ }
215
492
  })
216
493
  return
217
494
  }
@@ -228,15 +505,27 @@ export function KumaSite() {
228
505
  if (name === "escape" || name === "q") return setFpPick(false)
229
506
  return
230
507
  }
508
+ if (bypassPick) {
509
+ if (name === "left" || name === "up" || name === "h" || name === "k") return setBypassIdx((i) => Math.max(i - 1, 0))
510
+ if (name === "right" || name === "down" || name === "l" || name === "j" || name === "tab") return setBypassIdx((i) => Math.min(i + 1, BYPASS_WINDOWS.length - 1))
511
+ if (/^[1-5]$/.test(name)) return setBypassIdx(Number(name) - 1)
512
+ if (name === "return" && site) {
513
+ setBypassPick(false)
514
+ return startBypassMonitorSetup(site, BYPASS_WINDOWS[bypassIdx]!.sec)
515
+ }
516
+ if (name === "escape" || name === "q") return setBypassPick(false)
517
+ return
518
+ }
231
519
  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)
520
+ if (name === "o" && site && op?.status !== "running") {
521
+ const id = selected ? monitorIdFor(selected.key) : null
522
+ if (kumaUrl && id != null) openUrl(`${kumaUrl}/dashboard/${id}`)
523
+ return
239
524
  }
525
+ // Move the left list's highlight — only reachable here since every modal
526
+ // state above already returned.
527
+ if ((name === "up" || name === "k") && site && op?.status !== "running") return setSelectedIdx((i) => Math.max(i - 1, 0))
528
+ if ((name === "down" || name === "j") && site && op?.status !== "running") return setSelectedIdx((i) => Math.min(i + 1, entries.length - 1))
240
529
  if (name === "d" && !isVanity && site && op?.status !== "running") {
241
530
  // Pure HTTP — deliberately NOT gated on a Kuma connection.
242
531
  return runDoctor()
@@ -250,8 +539,29 @@ export function KumaSite() {
250
539
  return
251
540
  }
252
541
  if (name === "r" && isVanity && site && op?.status !== "running") return setConfirmRotate(true)
542
+ if (
543
+ name === "x" &&
544
+ site &&
545
+ op?.status !== "running" &&
546
+ (selected?.key === "fingerprint" || selected?.key === "bypass") &&
547
+ monitorIdFor(selected.key) != null
548
+ ) {
549
+ return setConfirmRemove(selected.key)
550
+ }
551
+ // The single "act on whichever monitor is selected" key — replaces the old
552
+ // per-kind f/b keys now that the right pane shows one monitor at a time.
253
553
  if (name === "a" && site && op?.status !== "running") {
254
554
  if (!kumaConfigured) return setShowConnect(true) // monitors need a connection
555
+ if (selected?.key === "fingerprint") {
556
+ const storedSec = registered?.fingerprint?.interval
557
+ const idx = FP_WINDOWS.findIndex((w) => w.sec === storedSec)
558
+ setFpIdx(idx >= 0 ? idx : 0)
559
+ return setFpPick(true)
560
+ }
561
+ if (selected?.key === "bypass") {
562
+ setBypassIdx(BYPASS_DEFAULT_IDX)
563
+ return setBypassPick(true)
564
+ }
255
565
  return startKumaSetup(site)
256
566
  }
257
567
  if (name === "R" && isVanity && site && op?.status !== "running") {
@@ -270,7 +580,27 @@ export function KumaSite() {
270
580
  <text content={site.domain} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
271
581
  </box>
272
582
 
273
- <Centered>{connecting ? renderConnect() : renderMonitor()}</Centered>
583
+ {connecting ? (
584
+ <Centered>{renderConnect()}</Centered>
585
+ ) : confirmRotate ? (
586
+ <Centered>{renderRotateConfirm()}</Centered>
587
+ ) : confirmRemove ? (
588
+ <Centered>{renderRemoveConfirm()}</Centered>
589
+ ) : doctor ? (
590
+ <Centered>
591
+ <Panel title=" Doctor — cache diagnosis " active>
592
+ <box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>{renderDoctor()}</box>
593
+ </Panel>
594
+ </Centered>
595
+ ) : alerts ? (
596
+ <Centered>
597
+ <Panel title=" Alerts " active>
598
+ <box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>{renderAlerts()}</box>
599
+ </Panel>
600
+ </Centered>
601
+ ) : (
602
+ renderMonitorBrowser()
603
+ )}
274
604
 
275
605
  <StatusBar hints={hints()} showGlobal={false} />
276
606
  </box>
@@ -326,77 +656,41 @@ export function KumaSite() {
326
656
  )
327
657
  }
328
658
 
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
659
+ // Full-screen browser: a narrow left list of this site's monitor kinds
660
+ // (status dot + label) and a wide right detail pane for whichever one is
661
+ // selected — mirrors ProviderConnect.tsx's two-Panel-in-a-row layout.
662
+ function renderMonitorBrowser() {
663
+ const alertLine = formatAlertSummary()
339
664
  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" />
665
+ <box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
666
+ {connectedVersion && (
667
+ <>
668
+ <text content={`● Uptime Kuma connected (${connectedVersion}).`} fg={theme.good} wrapMode="none" />
669
+ <box style={{ height: 1 }} />
670
+ </>
671
+ )}
672
+ <text content={isVanity ? "vanity page (full server monitoring)" : "site homepage (up/down + cert expiry)"} fg={theme.textDim} wrapMode="none" />
673
+ <text content={kumaConfigured ? "Uptime Kuma: connected · c to reconnect" : "Uptime Kuma: not connected · c"} fg={kumaConfigured ? theme.good : theme.textFaint} wrapMode="none" />
674
+ <text content={alertLine.text} fg={alertLine.color} wrapMode="none" />
675
+ <box style={{ height: 1 }} />
676
+ <box style={{ flexGrow: 1, flexDirection: "row", gap: 1 }}>
677
+ <Panel title=" Monitors " active width={30}>
678
+ <box style={{ flexDirection: "column", paddingTop: 1, paddingBottom: 1 }}>
679
+ {entries.map((e, i) => {
680
+ const s = statusFor(e.key)
681
+ const sel = i === safeIdx
682
+ return (
683
+ <box key={e.key} style={{ flexDirection: "row", backgroundColor: sel ? theme.selectedBg : undefined }}>
684
+ <text content={sel ? "› " : " "} fg={theme.brand} style={{ flexShrink: 0 }} />
685
+ <text content={`${statusDot(dotStatus(s))} `} fg={statusColor(dotStatus(s))} style={{ flexShrink: 0 }} />
686
+ <text content={e.label} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
687
+ </box>
688
+ )
689
+ })}
395
690
  </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. */}
691
+ </Panel>
692
+ <Panel title={selected ? ` ${selected.label} ` : " Monitor "} flexGrow={1} active>
693
+ <box style={{ flexDirection: "column", width: 90, paddingTop: 1, paddingBottom: 1, paddingLeft: 1 }}>
400
694
  {op?.status === "error" && (
401
695
  <>
402
696
  <text content={`✕ ${op.error}`} fg={theme.bad} />
@@ -409,43 +703,111 @@ export function KumaSite() {
409
703
  <box style={{ height: 1 }} />
410
704
  </>
411
705
  )}
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" && (
706
+ {op?.status === "running" ? (
707
+ <box style={{ flexDirection: "row" }}>
708
+ <Spinner />
709
+ <text content={` ${op.detail}`} fg={theme.textDim} wrapMode="none" />
710
+ </box>
711
+ ) : fpPick && selected?.key === "fingerprint" ? (
428
712
  <>
429
- <text content={`✕ ${op.error}`} fg={theme.bad} />
713
+ <text content="Reads the live page now (while it's healthy), derives a template" fg={theme.textDim} wrapMode="none" />
714
+ <text content="fingerprint (body class / canonical — survives copy edits), and" fg={theme.textDim} wrapMode="none" />
715
+ <text content="registers a Kuma keyword monitor asserting it." fg={theme.textDim} wrapMode="none" />
430
716
  <box style={{ height: 1 }} />
717
+ <box style={{ flexDirection: "row" }}>
718
+ <text content="Check window:" fg={theme.textDim} style={{ flexShrink: 0 }} />
719
+ {FP_WINDOWS.map((w, i) => (
720
+ <text key={w.label} content={i === fpIdx ? ` ▸${w.label}` : ` ${w.label}`} fg={i === fpIdx ? theme.brand : theme.textFaint} style={{ flexShrink: 0 }} />
721
+ ))}
722
+ </box>
723
+ <box style={{ height: 1 }} />
724
+ <text content="←/→ or 1-4 window · ⏎ calibrate & register · esc cancel" fg={theme.textFaint} wrapMode="none" />
431
725
  </>
432
- )}
433
- {op?.status === "done" && (
726
+ ) : bypassPick && selected?.key === "bypass" ? (
434
727
  <>
435
- <text content={`✓ ${op.detail}`} fg={theme.good} />
728
+ <text content="Forces a real PHP render, catching a fatal hidden behind a still-" fg={theme.textDim} wrapMode="none" />
729
+ <text content="warm page cache. Costs the site a real render on every check, so" fg={theme.textDim} wrapMode="none" />
730
+ <text content="pick a loose window; use only on sites with a history of this." fg={theme.textDim} wrapMode="none" />
731
+ <box style={{ height: 1 }} />
732
+ <box style={{ flexDirection: "row" }}>
733
+ <text content="Check window:" fg={theme.textDim} style={{ flexShrink: 0 }} />
734
+ {BYPASS_WINDOWS.map((w, i) => (
735
+ <text key={w.label} content={i === bypassIdx ? ` ▸${w.label}` : ` ${w.label}`} fg={i === bypassIdx ? theme.brand : theme.textFaint} style={{ flexShrink: 0 }} />
736
+ ))}
737
+ </box>
436
738
  <box style={{ height: 1 }} />
739
+ <text content="←/→ or 1-5 window · ⏎ register · esc cancel" fg={theme.textFaint} wrapMode="none" />
437
740
  </>
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
- )}
741
+ ) : selected ? (
742
+ <>
743
+ <text content={selected.tagline} fg={theme.text} wrapMode="none" />
744
+ <box style={{ height: 1 }} />
745
+ <text content={selected.detail} fg={theme.textDim} />
746
+ <box style={{ height: 1 }} />
747
+ <Field label="Status" value={statusFor(selected.key).value} valueColor={statusFor(selected.key).color} />
748
+ <Field
749
+ label="In Kuma"
750
+ value={monitorIdFor(selected.key) != null ? `#${monitorIdFor(selected.key)} · o opens it` : "not registered yet"}
751
+ valueColor={monitorIdFor(selected.key) != null ? theme.text : theme.textFaint}
752
+ />
753
+ <Field
754
+ label="Action"
755
+ value={(() => {
756
+ const removable = (selected.key === "fingerprint" || selected.key === "bypass") && monitorIdFor(selected.key) != null
757
+ const base =
758
+ selected.key === "fingerprint"
759
+ ? registered?.fingerprintId
760
+ ? "a — recalibrate"
761
+ : "a — calibrate & register"
762
+ : selected.key === "bypass"
763
+ ? registered?.bypassId
764
+ ? "a — recalibrate"
765
+ : "a — register (opt-in)"
766
+ : statusFor(selected.key).registered
767
+ ? "a — repair / re-register"
768
+ : "a — register"
769
+ return removable ? `${base} · x — remove` : base
770
+ })()}
771
+ />
772
+ {selected.key !== "fingerprint" && selected.key !== "bypass" && statusFor(selected.key).registered && (
773
+ <text content="(safe to re-run any time — re-syncs with Kuma, e.g. if a monitor was" fg={theme.textFaint} wrapMode="none" />
774
+ )}
775
+ {selected.key !== "fingerprint" && selected.key !== "bypass" && statusFor(selected.key).registered && (
776
+ <text content="deleted directly in Kuma, or sudo just got connected for PHP-fatal)" fg={theme.textFaint} wrapMode="none" />
777
+ )}
778
+ </>
779
+ ) : null}
780
+ </box>
781
+ </Panel>
782
+ </box>
783
+ </box>
784
+ )
785
+ }
786
+
787
+ function renderRotateConfirm() {
788
+ return (
789
+ <Panel title=" Rotate monitoring secrets " active>
790
+ <box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
791
+ <text content="Rotate this site's monitoring secrets?" fg={theme.warn} wrapMode="none" />
792
+ <text content="A new push URL and health key are minted; the old ones stop" fg={theme.textDim} wrapMode="none" />
793
+ <text content="working immediately. Kuma monitor history is kept." fg={theme.textDim} wrapMode="none" />
794
+ <box style={{ height: 1 }} />
795
+ <text content="y / ⏎ rotate · esc cancel" fg={theme.textFaint} wrapMode="none" />
796
+ </box>
797
+ </Panel>
798
+ )
799
+ }
800
+
801
+ function renderRemoveConfirm() {
802
+ const label = confirmRemove === "fingerprint" ? "front-page" : "cache-bypass"
803
+ return (
804
+ <Panel title=" Remove monitor " active>
805
+ <box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
806
+ <text content={`Remove the ${label} monitor?`} fg={theme.warn} wrapMode="none" />
807
+ <text content="Deletes it in Kuma — its history is gone for good. Pressing" fg={theme.textDim} wrapMode="none" />
808
+ <text content="a afterward re-opens the picker to add it back from scratch." fg={theme.textDim} wrapMode="none" />
809
+ <box style={{ height: 1 }} />
810
+ <text content="y / ⏎ remove · esc cancel" fg={theme.textFaint} wrapMode="none" />
449
811
  </box>
450
812
  </Panel>
451
813
  )
@@ -554,16 +916,35 @@ export function KumaSite() {
554
916
  function hints() {
555
917
  if (connecting) return [{ key: "⏎", label: "next / connect" }, { key: "esc", label: "back" }]
556
918
  if (confirmRotate) return [{ key: "y/⏎", label: "rotate" }, { key: "esc", label: "cancel" }]
919
+ if (confirmRemove) return [{ key: "y/⏎", label: "remove" }, { key: "esc", label: "cancel" }]
557
920
  if (doctor) return doctor.kind === "ready" ? [{ key: "d", label: "re-run" }, { key: "esc", label: "back" }] : [{ key: "esc", label: "back" }]
558
921
  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
922
  if (fpPick) return [{ key: "←/→", label: "window" }, { key: "⏎", label: "calibrate" }, { key: "esc", label: "cancel" }]
923
+ if (bypassPick) return [{ key: "←/→", label: "window" }, { key: "⏎", label: "register" }, { key: "esc", label: "cancel" }]
560
924
  if (op?.status === "running") return [{ key: "esc", label: "background" }]
561
- const base: { key: string; label: string }[] = []
925
+ const base: { key: string; label: string }[] = [{ key: "↑↓", label: "select monitor" }]
562
926
  if (isVanity) base.push({ key: "R", label: kumaConfigured ? "refresh page + monitors" : "refresh page" })
563
927
  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" })
928
+ base.push({
929
+ key: "a",
930
+ label:
931
+ selected?.key === "fingerprint"
932
+ ? registered?.fingerprintId
933
+ ? "recalibrate front page"
934
+ : "front-page check"
935
+ : selected?.key === "bypass"
936
+ ? registered?.bypassId
937
+ ? "recalibrate cache-bypass"
938
+ : "cache-bypass check"
939
+ : statusFor(selected?.key ?? "").registered
940
+ ? "repair monitors"
941
+ : "add monitors",
942
+ })
943
+ if (selected && (selected.key === "fingerprint" || selected.key === "bypass") && monitorIdFor(selected.key) != null) {
944
+ base.push({ key: "x", label: "remove monitor" })
945
+ }
566
946
  if (!isVanity) base.push({ key: "d", label: "doctor" })
947
+ if (selected && monitorIdFor(selected.key) != null) base.push({ key: "o", label: "open in Kuma" })
567
948
  base.push({ key: "n", label: "alerts" })
568
949
  base.push({ key: "c", label: kumaConfigured ? "reconnect Kuma" : "connect Kuma" })
569
950
  return [...base, { key: "esc", label: "close" }]