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
package/src/ui/store.tsx
CHANGED
|
@@ -40,8 +40,8 @@ import {
|
|
|
40
40
|
} from "../lib/providers.ts"
|
|
41
41
|
import { ProvidersCache, type VerifiedConn } from "../lib/providersCache.ts"
|
|
42
42
|
import { recordProviderFor, type DnsRecord, type RecordResult } from "../lib/dnsRecords.ts"
|
|
43
|
-
import { deriveSiteUser, seedVanityIndex, seedVanityPushCron, probeServerRedis, aRecordResolves, isVanityPair } from "../lib/vanitySite.ts"
|
|
44
|
-
import { withKuma, KumaClient, registerMonitors, registerFingerprintMonitor, setMonitorNotifications, genPushToken, rotatePushToken, retargetHealthKeyMonitors, LOAD_PUSH_SCALE } from "../lib/uptimeKuma.ts"
|
|
43
|
+
import { deriveSiteUser, seedVanityIndex, seedVanityPushCron, seedFatalMonitorCron, probeServerRedis, aRecordResolves, isVanityPair } from "../lib/vanitySite.ts"
|
|
44
|
+
import { withKuma, KumaClient, registerMonitors, registerFingerprintMonitor, registerBypassMonitor, setMonitorNotifications, genPushToken, rotatePushToken, retargetHealthKeyMonitors, LOAD_PUSH_SCALE } from "../lib/uptimeKuma.ts"
|
|
45
45
|
import { deriveFingerprint } from "../lib/siteFingerprint.ts"
|
|
46
46
|
import type { StoredProviders } from "../config.ts"
|
|
47
47
|
import { fetchRebootInfo, grantSiteSshKey, revokeSiteSshKey, verifySudo, ensureSpinupKey, listPersonalKeys, keyBody, type RebootInfo } from "../lib/ssh.ts"
|
|
@@ -219,6 +219,14 @@ export interface KumaDomainStatus {
|
|
|
219
219
|
// The server's Redis sentinel (vanity domains only): false = the cron reports
|
|
220
220
|
// Redis unreachable while the server itself is beating fine.
|
|
221
221
|
redisUp: boolean | null
|
|
222
|
+
// The server-wide PHP-fatal sentinel (vanity domains only): false = a new
|
|
223
|
+
// fatal was detected on some site on this server (see the down beat's own
|
|
224
|
+
// message for which one — this field is just "is anything on fire").
|
|
225
|
+
fatalUp: boolean | null
|
|
226
|
+
// The cache-bypass monitor (regular sites, opt-in): false = PHP couldn't
|
|
227
|
+
// render a fresh request even with the page cache skipped — distinct from
|
|
228
|
+
// `up`, which a warm cache can keep green even when this is failing.
|
|
229
|
+
bypassUp: boolean | null
|
|
222
230
|
}
|
|
223
231
|
|
|
224
232
|
// Clone a server to a new server (backlog item 5). Five server-level steps wrapping
|
|
@@ -538,6 +546,7 @@ interface StoreValue extends DataState {
|
|
|
538
546
|
clearVanity: () => void
|
|
539
547
|
vanityHealthKeyFor: (domain: string) => string | null // key baked into the seeded page's ?format=json mode
|
|
540
548
|
kumaConfigured: boolean // an Uptime Kuma connection exists (config or env)
|
|
549
|
+
kumaUrl: string | null // base URL for deep-linking to a monitor's own Kuma page
|
|
541
550
|
kumaSite: Site | null // site whose Kuma overlay (m) is open
|
|
542
551
|
setKumaSite: (s: Site | null) => void
|
|
543
552
|
kumaOps: Map<number, KumaOp> // in-flight/settled monitor setups, by site id
|
|
@@ -547,6 +556,8 @@ interface StoreValue extends DataState {
|
|
|
547
556
|
startVanityReseed: (site: Site) => void // refresh an existing vanity page (no Kuma needed)
|
|
548
557
|
startKumaRotate: (site: Site) => void // rotate the push token + health key (vanity; old URLs die immediately)
|
|
549
558
|
startFingerprintSetup: (site: Site, intervalSec: number) => void // calibrate + register the front-page fingerprint monitor
|
|
559
|
+
startBypassMonitorSetup: (site: Site, intervalSec: number) => void // register (or recalibrate) the opt-in cache-bypass monitor
|
|
560
|
+
removeSiteMonitor: (site: Site, kind: "fingerprint" | "bypass") => void // delete an opt-in per-site monitor (front page / cache bypass only)
|
|
550
561
|
fetchKumaAlerts: (site: Site) => Promise<{ ok: true; providers: KumaAlertProvider[]; monitorIds: number[] } | { ok: false; error: string }> // live read of provider/attachment state
|
|
551
562
|
toggleKumaAlert: (site: Site, providerId: number, on: boolean, monitorIds: number[]) => Promise<{ ok: true } | { ok: false; error: string }>
|
|
552
563
|
clearKumaOp: (siteId: number) => void // forget a settled op so the overlay opens fresh
|
|
@@ -2947,7 +2958,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
2947
2958
|
// very maintenance windows we create around reboots).
|
|
2948
2959
|
const beatUp = (s: number) => s !== 0
|
|
2949
2960
|
for (const [domain, ref] of Object.entries(cfgRef.current.kumaMonitors)) {
|
|
2950
|
-
const status: KumaDomainStatus = { up: null, uptime24: null, responseBeats: [], loadBeats: [], lastLoad: null, fingerprintUp: null, redisUp: null }
|
|
2961
|
+
const status: KumaDomainStatus = { up: null, uptime24: null, responseBeats: [], loadBeats: [], lastLoad: null, fingerprintUp: null, redisUp: null, fatalUp: null, bypassUp: null }
|
|
2951
2962
|
if (ref.healthId && byId.has(ref.healthId)) {
|
|
2952
2963
|
const beats = kuma.beatsFor(ref.healthId)
|
|
2953
2964
|
const last = beats[beats.length - 1]
|
|
@@ -2974,6 +2985,16 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
2974
2985
|
const last = beats[beats.length - 1]
|
|
2975
2986
|
status.redisUp = last ? beatUp(last.status) : null
|
|
2976
2987
|
}
|
|
2988
|
+
if (ref.fatalId && byId.has(ref.fatalId)) {
|
|
2989
|
+
const beats = kuma.beatsFor(ref.fatalId)
|
|
2990
|
+
const last = beats[beats.length - 1]
|
|
2991
|
+
status.fatalUp = last ? beatUp(last.status) : null
|
|
2992
|
+
}
|
|
2993
|
+
if (ref.bypassId && byId.has(ref.bypassId)) {
|
|
2994
|
+
const beats = kuma.beatsFor(ref.bypassId)
|
|
2995
|
+
const last = beats[beats.length - 1]
|
|
2996
|
+
status.bypassUp = last ? beatUp(last.status) : null
|
|
2997
|
+
}
|
|
2977
2998
|
map.set(domain, status)
|
|
2978
2999
|
}
|
|
2979
3000
|
return map
|
|
@@ -3106,13 +3127,22 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3106
3127
|
wantRedis = probe.ok
|
|
3107
3128
|
if (!probe.ok) redisNote = probe.error ?? "Redis sentinel skipped."
|
|
3108
3129
|
}
|
|
3130
|
+
// The PHP-fatal sentinel is also server-wide (it globs every site's
|
|
3131
|
+
// logs), but unlike Redis it needs ROOT — a regular site user can't
|
|
3132
|
+
// even list /sites (confirmed live). Only offer it when sudo is
|
|
3133
|
+
// already connected for this server; otherwise skip quietly rather
|
|
3134
|
+
// than blocking the rest of setup, and say what unlocks it.
|
|
3135
|
+
const sudoReady = isVanity && !!server && isSudoConnected(server.id)
|
|
3136
|
+
const wantFatal = sudoReady
|
|
3137
|
+
let fatalNote: string | null = null
|
|
3138
|
+
if (isVanity && server && !sudoReady) fatalNote = "PHP-fatal sentinel skipped — connect sudo (S) first, then re-run to add it."
|
|
3109
3139
|
setOp({ status: "running", detail: "Registering monitors in Uptime Kuma…" })
|
|
3110
3140
|
const known = cfgRef.current.kumaMonitors[domain] ?? {}
|
|
3111
3141
|
// Monitor over the protocol the site actually serves — an https monitor
|
|
3112
3142
|
// on an http-only site would be permanently down.
|
|
3113
3143
|
const proto = site.https?.enabled ? "https" : "http"
|
|
3114
3144
|
const { result, jwt } = await withKuma(conn, (kuma) =>
|
|
3115
|
-
registerMonitors(kuma, domain, { proto, healthzPath: isVanity ? "/?healthz" : "/", wantPush: isVanity, wantRedis, known }),
|
|
3145
|
+
registerMonitors(kuma, domain, { proto, healthzPath: isVanity ? "/?healthz" : "/", wantPush: isVanity, wantRedis, wantFatal, known }),
|
|
3116
3146
|
)
|
|
3117
3147
|
persistKumaAuth(conn, jwt)
|
|
3118
3148
|
persistKumaMonitors(domain, {
|
|
@@ -3122,16 +3152,28 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3122
3152
|
pushToken: result.pushToken ?? known.pushToken,
|
|
3123
3153
|
redisId: result.redisId ?? known.redisId,
|
|
3124
3154
|
redisToken: result.redisToken ?? known.redisToken,
|
|
3155
|
+
fatalId: result.fatalId ?? known.fatalId,
|
|
3156
|
+
fatalToken: result.fatalToken ?? known.fatalToken,
|
|
3125
3157
|
})
|
|
3126
3158
|
if (isVanity && result.pushId && result.pushToken && server) {
|
|
3127
3159
|
setOp({ status: "running", detail: "Installing the heartbeat cron…" })
|
|
3128
3160
|
const cron = await seedVanityPushCron({ ...ssh, kumaUrl: conn.url, pushToken: result.pushToken, redisToken: result.redisId != null ? (result.redisToken ?? known.redisToken ?? null) : null })
|
|
3129
3161
|
if (!cron.ok) throw new Error(cron.error || "Couldn't install the heartbeat cron.")
|
|
3130
3162
|
}
|
|
3163
|
+
if (sudoReady && server && result.fatalId && result.fatalToken) {
|
|
3164
|
+
setOp({ status: "running", detail: "Installing the PHP-fatal check (sudo)…" })
|
|
3165
|
+
const sudoUser = sudoUsers.get(server.id) ?? ""
|
|
3166
|
+
const sudoPassword = sudoPwRef.current.get(server.id) ?? ""
|
|
3167
|
+
const fatalCron = await seedFatalMonitorCron(server, { sudoUser, sudoPassword, kumaUrl: conn.url, pushToken: result.fatalToken })
|
|
3168
|
+
if (!fatalCron.ok) {
|
|
3169
|
+
if (/password was rejected|password is required|can't run sudo|couldn't connect/i.test(fatalCron.error ?? "")) disconnectSudo(server.id)
|
|
3170
|
+
fatalNote = `PHP-fatal sentinel skipped — ${fatalCron.error ?? "couldn't install the cron."}`
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3131
3173
|
setOp({
|
|
3132
3174
|
status: "done",
|
|
3133
3175
|
detail: isVanity
|
|
3134
|
-
? `Monitors live: healthz checks + load graph${result.redisId != null ? " + Redis sentinel" : ""} (cron beating every minute).${redisNote ? ` ${redisNote}` : ""}`
|
|
3176
|
+
? `Monitors live: healthz checks + load graph${result.redisId != null ? " + Redis sentinel" : ""}${result.fatalId != null && !fatalNote ? " + PHP-fatal sentinel" : ""} (cron beating every minute).${redisNote ? ` ${redisNote}` : ""}${fatalNote ? ` ${fatalNote}` : ""}`
|
|
3135
3177
|
: "Monitor live: homepage checks + certificate-expiry alerts.",
|
|
3136
3178
|
})
|
|
3137
3179
|
} catch (err) {
|
|
@@ -3140,7 +3182,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3140
3182
|
}
|
|
3141
3183
|
void run()
|
|
3142
3184
|
},
|
|
3143
|
-
[servers, reseedVanityPage, persistKumaAuth, persistKumaMonitors],
|
|
3185
|
+
[servers, reseedVanityPage, persistKumaAuth, persistKumaMonitors, isSudoConnected, sudoUsers, disconnectSudo],
|
|
3144
3186
|
)
|
|
3145
3187
|
|
|
3146
3188
|
// Calibrate + register the front-page fingerprint monitor (site monitoring
|
|
@@ -3186,6 +3228,76 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3186
3228
|
[persistKumaAuth, persistKumaMonitors],
|
|
3187
3229
|
)
|
|
3188
3230
|
|
|
3231
|
+
// Register (or recalibrate) the per-site cache-bypass monitor (site
|
|
3232
|
+
// monitoring blind-spot fix, part 2): opt-in, deliberately a much looser
|
|
3233
|
+
// interval than health/fingerprint since it forces a real PHP render on
|
|
3234
|
+
// every check, unlike those two which are served straight from cache.
|
|
3235
|
+
const startBypassMonitorSetup = useCallback(
|
|
3236
|
+
(site: Site, intervalSec: number) => {
|
|
3237
|
+
const conn = cfgRef.current.uptimeKuma
|
|
3238
|
+
if (!conn || isDevMode()) return
|
|
3239
|
+
const setOp = (op: KumaOp) => setKumaOps((prev) => new Map(prev).set(site.id, op))
|
|
3240
|
+
const run = async () => {
|
|
3241
|
+
try {
|
|
3242
|
+
const domain = site.domain
|
|
3243
|
+
const proto = site.https?.enabled ? "https" : "http"
|
|
3244
|
+
const known = cfgRef.current.kumaMonitors[domain] ?? {}
|
|
3245
|
+
setOp({ status: "running", detail: "Registering the cache-bypass monitor…" })
|
|
3246
|
+
const { result: bypassId, jwt } = await withKuma(conn, (kuma) =>
|
|
3247
|
+
registerBypassMonitor(kuma, domain, { proto, interval: intervalSec, knownId: known.bypassId }),
|
|
3248
|
+
)
|
|
3249
|
+
persistKumaAuth(conn, jwt)
|
|
3250
|
+
persistKumaMonitors(domain, { ...known, bypassId })
|
|
3251
|
+
const win = intervalSec % 3600 === 0 ? `${intervalSec / 3600}h` : `${Math.round(intervalSec / 60)}m`
|
|
3252
|
+
setOp({ status: "done", detail: `Cache-bypass check live — forces a fresh render every ${win}, bypassing the page cache.` })
|
|
3253
|
+
} catch (err) {
|
|
3254
|
+
setOp({ status: "error", detail: "", error: (err as Error).message })
|
|
3255
|
+
}
|
|
3256
|
+
}
|
|
3257
|
+
void run()
|
|
3258
|
+
},
|
|
3259
|
+
[persistKumaAuth, persistKumaMonitors],
|
|
3260
|
+
)
|
|
3261
|
+
|
|
3262
|
+
// Remove an opt-in per-site monitor (front-page fingerprint or cache-bypass)
|
|
3263
|
+
// — deliberately NOT offered for health/push/redis/fatal, which are all
|
|
3264
|
+
// re-derived automatically on the next `a`/`R` press (no persisted opt-out
|
|
3265
|
+
// exists for them yet), so "removing" one there would just silently
|
|
3266
|
+
// reappear. These two are genuinely opt-in: pressing `a` after removal
|
|
3267
|
+
// just reopens the picker, nothing re-creates them on its own.
|
|
3268
|
+
const removeSiteMonitor = useCallback(
|
|
3269
|
+
(site: Site, kind: "fingerprint" | "bypass") => {
|
|
3270
|
+
const conn = cfgRef.current.uptimeKuma
|
|
3271
|
+
if (!conn || isDevMode()) return
|
|
3272
|
+
const setOp = (op: KumaOp) => setKumaOps((prev) => new Map(prev).set(site.id, op))
|
|
3273
|
+
const run = async () => {
|
|
3274
|
+
try {
|
|
3275
|
+
const domain = site.domain
|
|
3276
|
+
const known = cfgRef.current.kumaMonitors[domain] ?? {}
|
|
3277
|
+
const id = kind === "fingerprint" ? known.fingerprintId : known.bypassId
|
|
3278
|
+
const label = kind === "fingerprint" ? "Front-page" : "Cache-bypass"
|
|
3279
|
+
if (id == null) return setOp({ status: "done", detail: "Already removed." })
|
|
3280
|
+
setOp({ status: "running", detail: `Removing the ${label.toLowerCase()} monitor…` })
|
|
3281
|
+
const { jwt } = await withKuma(conn, (kuma) => kuma.deleteMonitor(id))
|
|
3282
|
+
persistKumaAuth(conn, jwt)
|
|
3283
|
+
const next = { ...known }
|
|
3284
|
+
if (kind === "fingerprint") {
|
|
3285
|
+
delete next.fingerprintId
|
|
3286
|
+
delete next.fingerprint
|
|
3287
|
+
} else {
|
|
3288
|
+
delete next.bypassId
|
|
3289
|
+
}
|
|
3290
|
+
persistKumaMonitors(domain, next)
|
|
3291
|
+
setOp({ status: "done", detail: `${label} monitor removed.` })
|
|
3292
|
+
} catch (err) {
|
|
3293
|
+
setOp({ status: "error", detail: "", error: (err as Error).message })
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3296
|
+
void run()
|
|
3297
|
+
},
|
|
3298
|
+
[persistKumaAuth, persistKumaMonitors],
|
|
3299
|
+
)
|
|
3300
|
+
|
|
3189
3301
|
// Load the alert wiring for one site: every notification provider configured
|
|
3190
3302
|
// in Kuma, and whether each is attached to this site's Spinup monitors. The
|
|
3191
3303
|
// view calls this on demand (`n` in the overlay) — it's a live read, not
|
|
@@ -4083,6 +4195,9 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4083
4195
|
clearVanity,
|
|
4084
4196
|
vanityHealthKeyFor,
|
|
4085
4197
|
kumaConfigured: !isDevMode() && cfgRef.current.uptimeKuma != null,
|
|
4198
|
+
// Base URL for deep-linking straight to a monitor's own Kuma page
|
|
4199
|
+
// (`${kumaUrl}/dashboard/${id}`) — null in Dev Mode / when unconfigured.
|
|
4200
|
+
kumaUrl: !isDevMode() ? (cfgRef.current.uptimeKuma?.url ?? null) : null,
|
|
4086
4201
|
kumaSite,
|
|
4087
4202
|
setKumaSite,
|
|
4088
4203
|
kumaOps,
|
|
@@ -4092,6 +4207,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4092
4207
|
startVanityReseed,
|
|
4093
4208
|
startKumaRotate,
|
|
4094
4209
|
startFingerprintSetup,
|
|
4210
|
+
startBypassMonitorSetup,
|
|
4211
|
+
removeSiteMonitor,
|
|
4095
4212
|
fetchKumaAlerts,
|
|
4096
4213
|
toggleKumaAlert,
|
|
4097
4214
|
clearKumaOp,
|