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.
- package/CHANGELOG.md +83 -1
- package/README.md +144 -581
- package/package.json +1 -1
- package/src/config.ts +3 -0
- package/src/dev/fixtures.ts +494 -5
- package/src/index.tsx +44 -1
- package/src/lib/cliIncidents.ts +126 -0
- package/src/lib/cliSsh.ts +161 -0
- package/src/lib/probe.ts +1 -1
- package/src/lib/ssh.ts +46 -0
- package/src/lib/uptimeKuma.ts +99 -3
- package/src/lib/vanitySite.ts +74 -0
- package/src/ui/Header.tsx +10 -2
- package/src/ui/store.tsx +123 -6
- package/src/ui/views/KumaSite.tsx +508 -127
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// `spinuptui incidents <domain>` / `spinuptui incidents --all` — surfaces
|
|
2
|
+
// Uptime Kuma's down/up incident history for the fleet SpinupTUI itself
|
|
3
|
+
// manages monitoring for, for external tooling (e.g. an incident-diagnostics
|
|
4
|
+
// agent polling on its own schedule). Scoped to config.kumaMonitors, not
|
|
5
|
+
// Kuma's full live monitor list — a monitor added by hand in Kuma with no
|
|
6
|
+
// corresponding SpinupWP site would have nowhere actionable for a caller to
|
|
7
|
+
// go, so it's deliberately excluded.
|
|
8
|
+
|
|
9
|
+
import type { AppConfig, KumaMonitorRef } from "../config.ts"
|
|
10
|
+
import { withKuma, KumaError, type KumaBeat } from "./uptimeKuma.ts"
|
|
11
|
+
|
|
12
|
+
export type IncidentKind = "health" | "push" | "redis" | "fingerprint" | "fatal" | "bypass"
|
|
13
|
+
export type IncidentStatus = "down" | "up" | "pending" | "maintenance"
|
|
14
|
+
|
|
15
|
+
export interface IncidentEvent {
|
|
16
|
+
domain: string
|
|
17
|
+
kind: IncidentKind
|
|
18
|
+
status: IncidentStatus
|
|
19
|
+
time: string
|
|
20
|
+
message: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type IncidentsResult =
|
|
24
|
+
| { ok: true; windowHours: number; events: IncidentEvent[] }
|
|
25
|
+
| { ok: false; reason: "kuma_not_configured" | "no_monitors_registered" | "kuma_error"; message: string }
|
|
26
|
+
|
|
27
|
+
const STATUS_LABEL: Record<number, IncidentStatus> = { 0: "down", 1: "up", 2: "pending", 3: "maintenance" }
|
|
28
|
+
|
|
29
|
+
const KIND_FIELDS: Array<[keyof KumaMonitorRef, IncidentKind]> = [
|
|
30
|
+
["healthId", "health"],
|
|
31
|
+
["pushId", "push"],
|
|
32
|
+
["redisId", "redis"],
|
|
33
|
+
["fingerprintId", "fingerprint"],
|
|
34
|
+
["fatalId", "fatal"],
|
|
35
|
+
["bypassId", "bypass"],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
interface Target {
|
|
39
|
+
domain: string
|
|
40
|
+
kind: IncidentKind
|
|
41
|
+
id: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function resolveIncidents(
|
|
45
|
+
cfg: AppConfig,
|
|
46
|
+
opts: { domain?: string; hours: number },
|
|
47
|
+
): Promise<IncidentsResult> {
|
|
48
|
+
if (!cfg.uptimeKuma) {
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
reason: "kuma_not_configured",
|
|
52
|
+
message: "No Uptime Kuma connection configured in SpinupTUI.",
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const domains = opts.domain ? [opts.domain] : Object.keys(cfg.kumaMonitors)
|
|
57
|
+
const targets: Target[] = []
|
|
58
|
+
for (const domain of domains) {
|
|
59
|
+
const ref = cfg.kumaMonitors[domain]
|
|
60
|
+
if (!ref) continue
|
|
61
|
+
for (const [field, kind] of KIND_FIELDS) {
|
|
62
|
+
const id = ref[field]
|
|
63
|
+
if (typeof id === "number") targets.push({ domain, kind, id })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (targets.length === 0) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
reason: "no_monitors_registered",
|
|
71
|
+
message: opts.domain
|
|
72
|
+
? `No Kuma monitors registered for "${opts.domain}" in SpinupTUI.`
|
|
73
|
+
: "No Kuma monitors registered for any site in SpinupTUI.",
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const { result } = await withKuma(cfg.uptimeKuma, async (kuma) => {
|
|
79
|
+
const perTarget = await Promise.all(
|
|
80
|
+
targets.map(async (t) => {
|
|
81
|
+
try {
|
|
82
|
+
return { t, beats: await kuma.getMonitorBeats(t.id, opts.hours) }
|
|
83
|
+
} catch {
|
|
84
|
+
return { t, beats: [] as KumaBeat[] }
|
|
85
|
+
}
|
|
86
|
+
}),
|
|
87
|
+
)
|
|
88
|
+
const events: IncidentEvent[] = []
|
|
89
|
+
for (const { t, beats } of perTarget) {
|
|
90
|
+
for (const b of beats) {
|
|
91
|
+
if (!b.important) continue
|
|
92
|
+
const status = STATUS_LABEL[b.status] ?? "pending"
|
|
93
|
+
const message = b.msg || ""
|
|
94
|
+
// The fatal sentinel is registered under the server's vanity domain,
|
|
95
|
+
// not the site that's actually broken — a down beat pushed BY OUR
|
|
96
|
+
// OWN cron carries the real, comma-separated affected domain(s) as
|
|
97
|
+
// msg instead. But Kuma can ALSO generate its own synthetic down
|
|
98
|
+
// beats on this same push monitor (e.g. "No heartbeat in the time
|
|
99
|
+
// window" if the cron itself stops beating) — those are an
|
|
100
|
+
// infrastructure signal about the server, not a parseable domain
|
|
101
|
+
// list, and must NOT be mistaken for one (confirmed live: this
|
|
102
|
+
// exact message has no commas and isn't a hostname). Only messages
|
|
103
|
+
// that parse cleanly as a comma-separated list of hostname-shaped
|
|
104
|
+
// tokens get split into per-domain events; anything else — Kuma's
|
|
105
|
+
// own wording included — stays tagged to the vanity domain like
|
|
106
|
+
// every other kind.
|
|
107
|
+
const domainList = message.split(",").map((d) => d.trim())
|
|
108
|
+
const looksLikeDomainList = domainList.every((d) => /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)+$/.test(d))
|
|
109
|
+
if (t.kind === "fatal" && status === "down" && looksLikeDomainList) {
|
|
110
|
+
for (const domain of domainList) {
|
|
111
|
+
events.push({ domain, kind: t.kind, status, time: b.time, message })
|
|
112
|
+
}
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
events.push({ domain: t.domain, kind: t.kind, status, time: b.time, message })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
events.sort((a, b) => a.time.localeCompare(b.time))
|
|
119
|
+
return events
|
|
120
|
+
})
|
|
121
|
+
return { ok: true, windowHours: opts.hours, events: result }
|
|
122
|
+
} catch (err) {
|
|
123
|
+
const message = err instanceof KumaError ? err.message : `Unexpected error: ${(err as Error).message}`
|
|
124
|
+
return { ok: false, reason: "kuma_error", message }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// `spinuptui ssh <domain>` — non-interactive SSH access lookup for external
|
|
2
|
+
// tooling (e.g. an incident-diagnostics agent that's handed only a domain).
|
|
3
|
+
// Resolves domain -> site -> server -> SSH target, then runs a live
|
|
4
|
+
// connectivity probe (BatchMode, same as probeSite) so the result is a
|
|
5
|
+
// trustworthy go/no-go rather than a guess.
|
|
6
|
+
|
|
7
|
+
import type { AppConfig } from "../config.ts"
|
|
8
|
+
import type { SpinupWPClientLike } from "../api/client.ts"
|
|
9
|
+
import { ApiError } from "../api/client.ts"
|
|
10
|
+
import { resolveSiteSshTarget, SSH_OPTS } from "./probe.ts"
|
|
11
|
+
|
|
12
|
+
export type SshAccessReason =
|
|
13
|
+
| "no_token"
|
|
14
|
+
| "token_rejected"
|
|
15
|
+
| "site_not_found"
|
|
16
|
+
| "multiple_matches"
|
|
17
|
+
| "server_has_no_ip"
|
|
18
|
+
| "permission_denied"
|
|
19
|
+
| "connection_failed"
|
|
20
|
+
| "ssh_error"
|
|
21
|
+
| "api_error"
|
|
22
|
+
|
|
23
|
+
export interface SshAccessCandidate {
|
|
24
|
+
siteId: number
|
|
25
|
+
serverId: number
|
|
26
|
+
server: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type SshAccessResult =
|
|
30
|
+
| { ok: true; domain: string; primaryDomain: string; sshTarget: string; port: number | null; server: string }
|
|
31
|
+
| {
|
|
32
|
+
ok: false
|
|
33
|
+
domain: string
|
|
34
|
+
reason: SshAccessReason
|
|
35
|
+
message: string
|
|
36
|
+
remedy?: string
|
|
37
|
+
candidates?: SshAccessCandidate[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const GRANT_KEY_REMEDY =
|
|
41
|
+
"Run `spinuptui`, select this site in the Browser view, and press K to grant this device's key (sudo must be connected on the server first — press S)."
|
|
42
|
+
|
|
43
|
+
export async function resolveSshAccess(
|
|
44
|
+
domain: string,
|
|
45
|
+
client: SpinupWPClientLike,
|
|
46
|
+
cfg: AppConfig,
|
|
47
|
+
): Promise<SshAccessResult> {
|
|
48
|
+
if (!cfg.token) {
|
|
49
|
+
return { ok: false, domain, reason: "no_token", message: "No API token configured. Run `spinuptui login`." }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let site
|
|
53
|
+
let server
|
|
54
|
+
try {
|
|
55
|
+
const sites = await client.listSites()
|
|
56
|
+
const matches = sites.filter(
|
|
57
|
+
(s) => s.domain === domain || s.additional_domains?.some((a) => a.domain === domain),
|
|
58
|
+
)
|
|
59
|
+
if (matches.length === 0) {
|
|
60
|
+
return {
|
|
61
|
+
ok: false,
|
|
62
|
+
domain,
|
|
63
|
+
reason: "site_not_found",
|
|
64
|
+
message: `No site matching "${domain}" found in this SpinupWP account.`,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (matches.length > 1) {
|
|
68
|
+
const candidates: SshAccessCandidate[] = await Promise.all(
|
|
69
|
+
matches.map(async (s) => {
|
|
70
|
+
const srv = await client.getServer(s.server_id)
|
|
71
|
+
return { siteId: s.id, serverId: s.server_id, server: srv.name }
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
domain,
|
|
77
|
+
reason: "multiple_matches",
|
|
78
|
+
message: `"${domain}" matches ${matches.length} sites in this account — cannot pick one automatically.`,
|
|
79
|
+
candidates,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
site = matches[0]
|
|
83
|
+
server = await client.getServer(site.server_id)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err instanceof ApiError && err.status === 401) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
domain,
|
|
89
|
+
reason: "token_rejected",
|
|
90
|
+
message: "Saved API token was rejected (401) — it may be expired or revoked.",
|
|
91
|
+
remedy: "Run `spinuptui login` to save a fresh token.",
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const message = err instanceof ApiError ? err.message : `Unexpected error: ${(err as Error).message}`
|
|
95
|
+
return { ok: false, domain, reason: "api_error", message }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!server.ip_address) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
domain,
|
|
102
|
+
reason: "server_has_no_ip",
|
|
103
|
+
message: `Server "${server.name}" has no IP address on file.`,
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const target = resolveSiteSshTarget(site, server, cfg.sshUser)
|
|
108
|
+
const port = server.ssh_port && server.ssh_port !== 22 ? server.ssh_port : null
|
|
109
|
+
const portOpt = port ? ["-p", String(port)] : []
|
|
110
|
+
|
|
111
|
+
let proc: ReturnType<typeof Bun.spawn>
|
|
112
|
+
try {
|
|
113
|
+
proc = Bun.spawn(["ssh", ...SSH_OPTS, ...portOpt, target, "true"], {
|
|
114
|
+
stdout: "pipe",
|
|
115
|
+
stderr: "pipe",
|
|
116
|
+
stdin: "ignore",
|
|
117
|
+
})
|
|
118
|
+
} catch (err) {
|
|
119
|
+
return { ok: false, domain, reason: "ssh_error", message: `Failed to launch ssh: ${(err as Error).message}` }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const timeout = setTimeout(() => {
|
|
123
|
+
try {
|
|
124
|
+
proc.kill()
|
|
125
|
+
} catch {
|
|
126
|
+
/* already gone */
|
|
127
|
+
}
|
|
128
|
+
}, 15000)
|
|
129
|
+
|
|
130
|
+
const exitCode = await proc.exited
|
|
131
|
+
clearTimeout(timeout)
|
|
132
|
+
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
|
|
133
|
+
|
|
134
|
+
if (exitCode === 0) {
|
|
135
|
+
return { ok: true, domain, primaryDomain: site.domain, sshTarget: target, port, server: server.name }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (/permission denied/i.test(stderr)) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
domain,
|
|
142
|
+
reason: "permission_denied",
|
|
143
|
+
message: stderr.trim().split("\n").slice(-2).join(" ") || "Permission denied.",
|
|
144
|
+
remedy: GRANT_KEY_REMEDY,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (/(connection timed out|operation timed out|connection refused|could not resolve)/i.test(stderr)) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
domain,
|
|
151
|
+
reason: "connection_failed",
|
|
152
|
+
message: stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
ok: false,
|
|
157
|
+
domain,
|
|
158
|
+
reason: "ssh_error",
|
|
159
|
+
message: stderr.trim().split("\n").slice(-2).join(" ") || `ssh exited with code ${exitCode}`,
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/lib/probe.ts
CHANGED
|
@@ -60,7 +60,7 @@ export function resolveSiteSshTarget(site: Site, server: Server | undefined, ssh
|
|
|
60
60
|
return `${user}@${ip}`
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
const SSH_OPTS = [
|
|
63
|
+
export const SSH_OPTS = [
|
|
64
64
|
"-o", "BatchMode=yes",
|
|
65
65
|
"-o", "ConnectTimeout=7",
|
|
66
66
|
"-o", "StrictHostKeyChecking=accept-new",
|
package/src/lib/ssh.ts
CHANGED
|
@@ -444,6 +444,52 @@ async function runSudoSiteOp(
|
|
|
444
444
|
return { ok: true, target: `${siteUser}@${ip}` }
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
export type SudoServerResult = { ok: true } | { ok: false; error: string }
|
|
448
|
+
|
|
449
|
+
// Same probe → maybe-password → `sudo -S -p '' bash -s` idiom as runSudoSiteOp,
|
|
450
|
+
// but scoped to the SERVER as root — no Site/site_user involved. Used for ops
|
|
451
|
+
// that need to read/write across every site's directory (e.g. the fatal-log
|
|
452
|
+
// monitor's cron, which globs /sites/* — a regular site user can't even list
|
|
453
|
+
// that directory, confirmed live).
|
|
454
|
+
export async function runSudoServerOp(
|
|
455
|
+
server: Server,
|
|
456
|
+
opts: { sudoUser: string; sudoPassword: string },
|
|
457
|
+
build: () => string,
|
|
458
|
+
marker: string,
|
|
459
|
+
): Promise<SudoServerResult> {
|
|
460
|
+
const ip = server.ip_address
|
|
461
|
+
if (!ip) return { ok: false, error: "Server has no IP address." }
|
|
462
|
+
const target = `${opts.sudoUser}@${ip}`
|
|
463
|
+
const port = server.ssh_port ?? null
|
|
464
|
+
|
|
465
|
+
const probe = await runSshStdin(target, port, "sudo -n true 2>&1", null, 15000)
|
|
466
|
+
let needPassword = false
|
|
467
|
+
if (probe.code === 0) {
|
|
468
|
+
needPassword = false
|
|
469
|
+
} else if (probe.code === 255 || probe.code === -1) {
|
|
470
|
+
return { ok: false, error: lastErrorLine(probe.stdout + "\n" + probe.stderr, "Couldn't connect over SSH — is your key on the server for this user?") }
|
|
471
|
+
} else {
|
|
472
|
+
const out = (probe.stdout + " " + probe.stderr).toLowerCase()
|
|
473
|
+
if (/not in the sudoers|not allowed to run|may not run|unknown user|is not allowed/.test(out)) {
|
|
474
|
+
return { ok: false, error: `${opts.sudoUser} can't run sudo on this server.` }
|
|
475
|
+
}
|
|
476
|
+
needPassword = true
|
|
477
|
+
if (!opts.sudoPassword) return { ok: false, error: "A sudo password is required for this server." }
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const script = build()
|
|
481
|
+
const payload = needPassword ? `${opts.sudoPassword}\n${script}\n` : `${script}\n`
|
|
482
|
+
const res = await runSshStdin(target, port, "sudo -S -p '' bash -s", payload, 30000)
|
|
483
|
+
if (res.code !== 0 || !res.stdout.includes(marker)) {
|
|
484
|
+
const out = (res.stderr + " " + res.stdout).toLowerCase()
|
|
485
|
+
if (/incorrect password|sorry, try again|authentication failure/.test(out)) {
|
|
486
|
+
return { ok: false, error: "Sudo password was rejected." }
|
|
487
|
+
}
|
|
488
|
+
return { ok: false, error: lastErrorLine(res.stderr || res.stdout, `The remote command failed (ssh exit ${res.code}).`) }
|
|
489
|
+
}
|
|
490
|
+
return { ok: true }
|
|
491
|
+
}
|
|
492
|
+
|
|
447
493
|
// Append the given public keys to a site user's authorized_keys, via the server's
|
|
448
494
|
// sudo user. Idempotent (re-running never duplicates a line). The caller resolves
|
|
449
495
|
// which keys to deploy (machine key via ensureSpinupKey, personal via listPersonalKeys).
|
package/src/lib/uptimeKuma.ts
CHANGED
|
@@ -41,6 +41,7 @@ export interface KumaBeat {
|
|
|
41
41
|
time: string
|
|
42
42
|
msg?: string
|
|
43
43
|
ping?: number | null
|
|
44
|
+
important?: boolean // true on a status-transition row (Kuma's own "important events" flag)
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// A notification provider as configured in Kuma (Settings → Notifications) —
|
|
@@ -403,6 +404,27 @@ export function redisPushMonitorPayload(name: string, pushToken: string): Record
|
|
|
403
404
|
}
|
|
404
405
|
}
|
|
405
406
|
|
|
407
|
+
// The server-wide PHP-fatal sentinel: one per server (like the Redis sentinel),
|
|
408
|
+
// fed by a root-level cron that greps every site's error.log/debug.log for new
|
|
409
|
+
// "PHP Fatal error" lines — catches PHP dying behind a still-warm page cache,
|
|
410
|
+
// which the plain up/down and fingerprint monitors sleep straight through.
|
|
411
|
+
// Unlike every other monitor kind, a down beat's `msg` carries a comma-separated
|
|
412
|
+
// list of the actual site domain(s) that fataled (this monitor's own domain is
|
|
413
|
+
// just the server's vanity anchor, not the site that's actually broken).
|
|
414
|
+
export function fatalPushMonitorPayload(name: string, pushToken: string): Record<string, unknown> {
|
|
415
|
+
return {
|
|
416
|
+
type: "push",
|
|
417
|
+
name,
|
|
418
|
+
pushToken,
|
|
419
|
+
description: "Server-wide PHP fatal sentinel: a root cron greps every site's error/debug log each minute for new PHP fatals and reports up/down. `msg` on a down beat lists the affected domain(s).",
|
|
420
|
+
interval: 60,
|
|
421
|
+
retryInterval: 60,
|
|
422
|
+
maxretries: 2,
|
|
423
|
+
upsideDown: false,
|
|
424
|
+
accepted_statuscodes: ["200-299"],
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
406
428
|
// The front-page fingerprint monitor (site monitoring Phase 1): a `keyword`
|
|
407
429
|
// monitor asserting the front page still carries the template-identity string
|
|
408
430
|
// calibration derived (lib/siteFingerprint.ts). Catches "the page cache is
|
|
@@ -453,6 +475,53 @@ export async function registerFingerprintMonitor(
|
|
|
453
475
|
return kuma.addMonitor(fingerprintMonitorPayload(name, url, opts.keyword, opts.interval))
|
|
454
476
|
}
|
|
455
477
|
|
|
478
|
+
// The cache-bypass monitor (site monitoring blind-spot fix, part 2): a plain
|
|
479
|
+
// http monitor on the front page, but with a Cookie header that forces
|
|
480
|
+
// SpinupWP's nginx to bypass the page cache — the same bypass siteDoctor.ts
|
|
481
|
+
// already uses and has verified live. Unlike the health/fingerprint monitors,
|
|
482
|
+
// this actually exercises PHP on every check, so it's deliberately opt-in
|
|
483
|
+
// (not automatic) and meant for a much looser interval than either of those.
|
|
484
|
+
export function bypassMonitorPayload(name: string, url: string, interval: number): Record<string, unknown> {
|
|
485
|
+
return {
|
|
486
|
+
type: "http",
|
|
487
|
+
name,
|
|
488
|
+
url,
|
|
489
|
+
method: "GET",
|
|
490
|
+
interval,
|
|
491
|
+
retryInterval: 60,
|
|
492
|
+
resendInterval: 0,
|
|
493
|
+
maxretries: 2,
|
|
494
|
+
timeout: 48,
|
|
495
|
+
accepted_statuscodes: ["200-299"],
|
|
496
|
+
expiryNotification: false, // the plain health monitor already owns cert expiry
|
|
497
|
+
ignoreTls: false,
|
|
498
|
+
upsideDown: false,
|
|
499
|
+
maxredirects: 10,
|
|
500
|
+
headers: JSON.stringify({ Cookie: "wordpress_no_cache=1" }),
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Adopt-or-EDIT-or-create, same philosophy as registerFingerprintMonitor:
|
|
505
|
+
// recalibrating the interval updates the existing row in place so history and
|
|
506
|
+
// notification wiring survive.
|
|
507
|
+
export async function registerBypassMonitor(
|
|
508
|
+
kuma: KumaClient,
|
|
509
|
+
domain: string,
|
|
510
|
+
opts: { proto: "http" | "https"; interval: number; knownId?: number | null },
|
|
511
|
+
): Promise<number> {
|
|
512
|
+
const name = `${domain} cache-bypass`
|
|
513
|
+
const url = `${opts.proto}://${domain}/`
|
|
514
|
+
const monitors = await kuma.getMonitors()
|
|
515
|
+
const byId = new Map(monitors.map((m) => [m.id, m]))
|
|
516
|
+
const existingId = (opts.knownId != null && byId.has(opts.knownId) ? opts.knownId : undefined) ?? monitors.find((m) => m.name === name)?.id
|
|
517
|
+
if (existingId != null) {
|
|
518
|
+
const full = await kuma.getMonitor(existingId)
|
|
519
|
+
await kuma.editMonitor({ ...full, type: "http", url, interval: opts.interval, headers: JSON.stringify({ Cookie: "wordpress_no_cache=1" }) })
|
|
520
|
+
return existingId
|
|
521
|
+
}
|
|
522
|
+
return kuma.addMonitor(bypassMonitorPayload(name, url, opts.interval))
|
|
523
|
+
}
|
|
524
|
+
|
|
456
525
|
// Adopt-or-create the monitors for one domain — THE single implementation shared
|
|
457
526
|
// by the vanity wizard's monitor step and the `m` overlay's add/repair, so the
|
|
458
527
|
// two paths cannot drift. Rules:
|
|
@@ -464,8 +533,24 @@ export async function registerFingerprintMonitor(
|
|
|
464
533
|
export async function registerMonitors(
|
|
465
534
|
kuma: KumaClient,
|
|
466
535
|
domain: string,
|
|
467
|
-
opts: {
|
|
468
|
-
|
|
536
|
+
opts: {
|
|
537
|
+
proto: "http" | "https"
|
|
538
|
+
healthzPath: string
|
|
539
|
+
wantPush: boolean
|
|
540
|
+
wantRedis?: boolean
|
|
541
|
+
wantFatal?: boolean
|
|
542
|
+
known?: KumaMonitorRef | null
|
|
543
|
+
pushToken?: string | null
|
|
544
|
+
},
|
|
545
|
+
): Promise<{
|
|
546
|
+
healthId: number
|
|
547
|
+
pushId?: number
|
|
548
|
+
pushToken?: string
|
|
549
|
+
redisId?: number
|
|
550
|
+
redisToken?: string
|
|
551
|
+
fatalId?: number
|
|
552
|
+
fatalToken?: string
|
|
553
|
+
}> {
|
|
469
554
|
const monitors = await kuma.getMonitors()
|
|
470
555
|
const byId = new Map(monitors.map((m) => [m.id, m]))
|
|
471
556
|
const byName = new Map(monitors.map((m) => [m.name, m]))
|
|
@@ -482,7 +567,12 @@ export async function registerMonitors(
|
|
|
482
567
|
const existingRedis = opts.wantRedis && live(known.redisId) == null ? byName.get(`${domain} redis`) : undefined
|
|
483
568
|
if (existingRedis?.pushToken) redisToken = existingRedis.pushToken
|
|
484
569
|
|
|
485
|
-
|
|
570
|
+
// Same independent-token story as Redis — its own push URL, its own cron line.
|
|
571
|
+
let fatalToken = known.fatalToken ?? genPushToken()
|
|
572
|
+
const existingFatal = opts.wantFatal && live(known.fatalId) == null ? byName.get(`${domain} php-fatal`) : undefined
|
|
573
|
+
if (existingFatal?.pushToken) fatalToken = existingFatal.pushToken
|
|
574
|
+
|
|
575
|
+
const [healthId, pushId, redisId, fatalId] = await Promise.all([
|
|
486
576
|
(async () => live(known.healthId) ?? byName.get(domain)?.id ?? (await kuma.addMonitor(healthMonitorPayload(domain, `${opts.proto}://${domain}${opts.healthzPath}`))))(),
|
|
487
577
|
(async () => {
|
|
488
578
|
if (!opts.wantPush) return undefined
|
|
@@ -492,6 +582,10 @@ export async function registerMonitors(
|
|
|
492
582
|
if (!opts.wantRedis) return undefined
|
|
493
583
|
return live(known.redisId) ?? existingRedis?.id ?? (await kuma.addMonitor(redisPushMonitorPayload(`${domain} redis`, redisToken)))
|
|
494
584
|
})(),
|
|
585
|
+
(async () => {
|
|
586
|
+
if (!opts.wantFatal) return undefined
|
|
587
|
+
return live(known.fatalId) ?? existingFatal?.id ?? (await kuma.addMonitor(fatalPushMonitorPayload(`${domain} php-fatal`, fatalToken)))
|
|
588
|
+
})(),
|
|
495
589
|
])
|
|
496
590
|
return {
|
|
497
591
|
healthId,
|
|
@@ -499,6 +593,8 @@ export async function registerMonitors(
|
|
|
499
593
|
pushToken: opts.wantPush ? pushToken : undefined,
|
|
500
594
|
redisId,
|
|
501
595
|
redisToken: opts.wantRedis ? redisToken : undefined,
|
|
596
|
+
fatalId,
|
|
597
|
+
fatalToken: opts.wantFatal ? fatalToken : undefined,
|
|
502
598
|
}
|
|
503
599
|
}
|
|
504
600
|
|
package/src/lib/vanitySite.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
// docs/vanity-site/index.php is the design reference; keep the two in sync.
|
|
7
7
|
|
|
8
8
|
import { resolve4 } from "node:dns/promises"
|
|
9
|
+
import type { Server } from "../api/types.ts"
|
|
9
10
|
import { SSH_OPTS, sshPort, runProcess, meaningfulError, remoteDocRoot } from "./dbBackup.ts"
|
|
10
11
|
import { pushUrl, LOAD_PUSH_SCALE } from "./uptimeKuma.ts"
|
|
12
|
+
import { runSudoServerOp } from "./ssh.ts"
|
|
11
13
|
|
|
12
14
|
// The vanity convention: the site living at the server's own hostname. Every
|
|
13
15
|
// feature that special-cases vanity sites (full monitoring, page re-seed) keys
|
|
@@ -295,3 +297,75 @@ export async function seedVanityPushCron(t: PushCronTarget): Promise<{ ok: boole
|
|
|
295
297
|
if (r.code !== 0) return { ok: false, error: meaningfulError(r.stderr, "Couldn't install the heartbeat cron over SSH.") }
|
|
296
298
|
return { ok: true }
|
|
297
299
|
}
|
|
300
|
+
|
|
301
|
+
const FATAL_CRON_MARKER = "spinup-kuma-fatal"
|
|
302
|
+
const FATAL_SCRIPT_PATH = "/root/spinup-fatal-check.sh"
|
|
303
|
+
const FATAL_INSTALLED_MARKER = "===FATALCRON_INSTALLED"
|
|
304
|
+
|
|
305
|
+
// Server-wide PHP-fatal sentinel (site monitoring blind-spot fix): SpinupWP's
|
|
306
|
+
// page cache keeps serving `/` even while PHP is fataling on anything the
|
|
307
|
+
// cache doesn't already have — the plain up/down and fingerprint monitors
|
|
308
|
+
// never touch broken PHP as long as the cache stays warm (verified against a
|
|
309
|
+
// real 2026-07-07 incident that sat undetected for 54 of its 68 minutes).
|
|
310
|
+
//
|
|
311
|
+
// Runs as ROOT (via sudo) because it globs every site on the box: a site's
|
|
312
|
+
// own user can't even list `/sites` (confirmed live), let alone read a
|
|
313
|
+
// sibling site's logs once logrotate recreates them `640 root adm`. One
|
|
314
|
+
// monitor covers the whole server rather than one per site, to avoid Kuma
|
|
315
|
+
// "monitor bloat" — a down beat's `msg` carries the actual affected
|
|
316
|
+
// domain(s), since THIS monitor's own name is just the server's vanity
|
|
317
|
+
// anchor, not the site that's actually broken.
|
|
318
|
+
//
|
|
319
|
+
// A PHP fatal can land in either logs/error.log (nginx) or logs/debug.log
|
|
320
|
+
// (WordPress's own log, populated when WP_DEBUG_LOG is on) depending on that
|
|
321
|
+
// site's config — verified live: the real incident above is in debug.log,
|
|
322
|
+
// not error.log. Both are checked for every site.
|
|
323
|
+
const FATAL_CHECK_SCRIPT = `#!/bin/bash
|
|
324
|
+
STATE_DIR=/root/.spinup-fatal-state
|
|
325
|
+
mkdir -p "$STATE_DIR"
|
|
326
|
+
FOUND=""
|
|
327
|
+
for site_dir in /sites/*/; do
|
|
328
|
+
domain=$(basename "$site_dir")
|
|
329
|
+
for logname in error.log debug.log; do
|
|
330
|
+
logfile="\${site_dir}logs/\${logname}"
|
|
331
|
+
[ -f "$logfile" ] || continue
|
|
332
|
+
state_file="$STATE_DIR/\${domain}__\${logname}.offset"
|
|
333
|
+
prev=$(cat "$state_file" 2>/dev/null || echo 0)
|
|
334
|
+
cur=$(stat -c %s "$logfile" 2>/dev/null || echo 0)
|
|
335
|
+
[ "$cur" -lt "$prev" ] && prev=0
|
|
336
|
+
if [ "$cur" -gt "$prev" ] && tail -c +"$((prev + 1))" "$logfile" | grep -q "PHP Fatal error"; then
|
|
337
|
+
FOUND="\${FOUND:+$FOUND,}$domain"
|
|
338
|
+
fi
|
|
339
|
+
echo "$cur" > "$state_file"
|
|
340
|
+
done
|
|
341
|
+
done
|
|
342
|
+
if [ -n "$FOUND" ]; then
|
|
343
|
+
curl -fsS --max-time 20 "__PUSH_URL__?status=down&msg=$FOUND" >/dev/null 2>&1
|
|
344
|
+
else
|
|
345
|
+
curl -fsS --max-time 20 "__PUSH_URL__?status=up&msg=fatal-check-ok" >/dev/null 2>&1
|
|
346
|
+
fi
|
|
347
|
+
`
|
|
348
|
+
|
|
349
|
+
// Adopt-or-refresh: writes the check script (push URL baked in) and installs
|
|
350
|
+
// the one-line-per-minute crontab entry that invokes it, in ROOT's crontab.
|
|
351
|
+
// Idempotent, same strip-marked-line-then-append idiom as seedVanityPushCron.
|
|
352
|
+
export async function seedFatalMonitorCron(
|
|
353
|
+
server: Server,
|
|
354
|
+
opts: { sudoUser: string; sudoPassword: string; kumaUrl: string; pushToken: string },
|
|
355
|
+
): Promise<{ ok: boolean; error?: string }> {
|
|
356
|
+
const push = pushUrl(opts.kumaUrl, opts.pushToken)
|
|
357
|
+
const script = FATAL_CHECK_SCRIPT.replace(/__PUSH_URL__/g, push)
|
|
358
|
+
const scriptB64 = Buffer.from(script, "utf8").toString("base64")
|
|
359
|
+
return runSudoServerOp(
|
|
360
|
+
server,
|
|
361
|
+
{ sudoUser: opts.sudoUser, sudoPassword: opts.sudoPassword },
|
|
362
|
+
() =>
|
|
363
|
+
[
|
|
364
|
+
`printf '%s' '${scriptB64}' | base64 -d > ${FATAL_SCRIPT_PATH}`,
|
|
365
|
+
`chmod +x ${FATAL_SCRIPT_PATH}`,
|
|
366
|
+
`( crontab -l 2>/dev/null | grep -v -e '${FATAL_CRON_MARKER}' ; echo '* * * * * ${FATAL_SCRIPT_PATH} >/dev/null 2>&1 # ${FATAL_CRON_MARKER}' ) | crontab -`,
|
|
367
|
+
`echo ${FATAL_INSTALLED_MARKER}`,
|
|
368
|
+
].join("\n"),
|
|
369
|
+
FATAL_INSTALLED_MARKER,
|
|
370
|
+
)
|
|
371
|
+
}
|
package/src/ui/Header.tsx
CHANGED
|
@@ -28,7 +28,13 @@ const SUBTITLES: Record<Route, string> = {
|
|
|
28
28
|
|
|
29
29
|
export function Header() {
|
|
30
30
|
const { route, servers, sites, loading, lastUpdated, updateInfo, newServerJob, vanityJob, cloneJob, cloneServer, kumaStatus } = useStore()
|
|
31
|
-
|
|
31
|
+
// Name the domain(s) when there are few enough to fit the header row; fall
|
|
32
|
+
// back to a bare count once there are too many to list safely. Deliberately
|
|
33
|
+
// still just the plain up/down signal (kumaStatus.up) — not expanding what
|
|
34
|
+
// counts as "down" here, only how it's displayed.
|
|
35
|
+
const downDomains = [...kumaStatus.entries()].filter(([, s]) => s.up === false).map(([domain]) => domain)
|
|
36
|
+
const kumaDown = downDomains.length
|
|
37
|
+
const kumaDownLabel = kumaDown === 0 ? "" : kumaDown <= 2 ? `${downDomains.map((d) => truncate(d, 24)).join(", ")} down` : `${kumaDown} monitors down`
|
|
32
38
|
const updateReady = updateInfo?.updateAvailable ?? false
|
|
33
39
|
const building = isNewServerInFlight(newServerJob)
|
|
34
40
|
// The sshkey step is the one that waits on the USER (add your key, confirm in
|
|
@@ -103,7 +109,9 @@ export function Header() {
|
|
|
103
109
|
</box>
|
|
104
110
|
)}
|
|
105
111
|
{/* Uptime Kuma: only bad news earns header space — silence when all up. */}
|
|
106
|
-
{kumaDown > 0 &&
|
|
112
|
+
{kumaDown > 0 && (
|
|
113
|
+
<text content={` ▼ ${kumaDownLabel} — 4 to find ${kumaDown === 1 ? "it" : "them"} `} fg={theme.bad} style={{ flexShrink: 0 }} wrapMode="none" />
|
|
114
|
+
)}
|
|
107
115
|
{loading && <Spinner interval={100} />}
|
|
108
116
|
<text content={` ${servers.length} servers · ${sites.length} sites `} fg={theme.textDim} style={{ flexShrink: 0 }} />
|
|
109
117
|
<text content={lastUpdated ? `updated ${timeAgo(lastUpdated.toISOString())}` : "loading…"} fg={theme.textFaint} style={{ flexShrink: 0 }} />
|