spinuptui 1.3.0-alpha.3 → 1.3.0-alpha.5
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 +32 -1
- package/README.md +12 -3
- package/package.json +1 -1
- package/src/config.ts +32 -0
- package/src/index.tsx +11 -0
- package/src/lib/beacon.test.ts +14 -0
- package/src/lib/beacon.ts +14 -1
- package/src/lib/beaconBoard.test.ts +69 -0
- package/src/lib/beaconBoard.ts +197 -0
- package/src/lib/beaconListen.test.ts +190 -0
- package/src/lib/beaconListen.ts +355 -0
- package/src/lib/beaconListenInstall.ts +251 -0
- package/src/lib/cliBeacon.ts +115 -3
- package/src/lib/cliIncidents.ts +13 -6
- package/src/lib/cliScheduler.ts +30 -1
- package/src/lib/cliSentinel.ts +451 -0
- package/src/lib/heartbeat.ts +5 -0
- package/src/lib/schedule.ts +4 -1
- package/src/lib/scheduler.test.ts +189 -1
- package/src/lib/schedulerExtend.ts +105 -0
- package/src/lib/schedulerInstall.ts +36 -15
- package/src/lib/schedulerJobs.ts +4 -2
- package/src/lib/schedulerRun.ts +61 -15
- package/src/lib/schedulerTick.ts +35 -13
- package/src/lib/sentinelAttach.test.ts +45 -0
- package/src/lib/sentinelAttach.ts +98 -0
- package/src/lib/sentinelDigest.test.ts +82 -0
- package/src/lib/sentinelDigest.ts +261 -0
- package/src/lib/sentinelExpect.test.ts +82 -0
- package/src/lib/sentinelExpect.ts +197 -0
- package/src/lib/sentinelIncidents.test.ts +145 -0
- package/src/lib/sentinelIncidents.ts +194 -0
- package/src/lib/sentinelPrompt.ts +89 -0
- package/src/lib/sentinelReply.ts +36 -0
- package/src/lib/sentinelState.ts +246 -0
- package/src/lib/sentinelTick.test.ts +214 -0
- package/src/lib/sentinelTick.ts +265 -0
- package/src/lib/sentinelWorker.test.ts +170 -0
- package/src/lib/sentinelWorker.ts +291 -0
- package/src/modules/beacon.ts +9 -1
- package/src/modules/contract.ts +34 -0
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +147 -6
- package/src/modules/sentinel.ts +90 -0
- package/src/ui/App.tsx +8 -0
- package/src/ui/store.tsx +11 -0
- package/src/ui/views/BeaconSetup.tsx +11 -7
- package/src/ui/views/ListenerSetup.tsx +205 -0
- package/src/ui/views/Modules.tsx +70 -5
- package/src/ui/views/SentinelSetup.tsx +171 -0
package/src/lib/schedulerTick.ts
CHANGED
|
@@ -34,9 +34,9 @@ import {
|
|
|
34
34
|
type TickRecord,
|
|
35
35
|
type TickRun,
|
|
36
36
|
} from "./schedulerJobs.ts"
|
|
37
|
-
import { jobEnv, notify, runJob, runProcess, selfCommand } from "./schedulerRun.ts"
|
|
37
|
+
import { BLOCKED_EXIT, jobEnv, notify, runJob, runProcess, selfCommand } from "./schedulerRun.ts"
|
|
38
38
|
import { commitAndPush, decideClaim, gitPull, readClaim, stationName } from "./schedulerGit.ts"
|
|
39
|
-
import { pagerProblem, sendHeartbeat, tickHeartbeat } from "./heartbeat.ts"
|
|
39
|
+
import { pagerProblem, sendHeartbeat, stillRunningHeartbeat, tickHeartbeat } from "./heartbeat.ts"
|
|
40
40
|
|
|
41
41
|
const LOG_MAX_BYTES = 1_000_000
|
|
42
42
|
const MODULE_JOB_TIMEOUT_MS = 10 * 60_000
|
|
@@ -48,7 +48,7 @@ export type TickResult = ({ ok: true } & TickRecord) | { ok: true; skipped: "loc
|
|
|
48
48
|
export interface ModuleJobState {
|
|
49
49
|
last_run?: string
|
|
50
50
|
last_scheduled?: string
|
|
51
|
-
last_status?: "ok" | "failed"
|
|
51
|
+
last_status?: "ok" | "failed" | "blocked"
|
|
52
52
|
failing_since?: string
|
|
53
53
|
last_output?: string
|
|
54
54
|
}
|
|
@@ -56,7 +56,7 @@ export interface ModuleJobState {
|
|
|
56
56
|
export interface ModuleJobDeps {
|
|
57
57
|
stateDir: string
|
|
58
58
|
now: Date
|
|
59
|
-
run(decl: ScheduledJobDecl): Promise<{ ok: boolean; output: string }>
|
|
59
|
+
run(decl: ScheduledJobDecl): Promise<{ ok: boolean; output: string; blocked?: boolean }>
|
|
60
60
|
page(text: string): Promise<string>
|
|
61
61
|
}
|
|
62
62
|
|
|
@@ -91,13 +91,14 @@ export async function runModuleJobs(decls: ScheduledJobDecl[], deps: ModuleJobDe
|
|
|
91
91
|
// aligned to ticks instead of drifting by each run's duration.
|
|
92
92
|
const started = deps.now
|
|
93
93
|
const r = await deps.run(decl)
|
|
94
|
-
const status = r.ok ? "ok" : "failed"
|
|
94
|
+
const status = r.ok ? "ok" : r.blocked ? "blocked" : "failed"
|
|
95
95
|
const tail = r.output.trim().split("\n").slice(-10).join("\n").slice(-600)
|
|
96
96
|
let notified: string | undefined
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
97
|
+
const wasOk = !state.last_status || state.last_status === "ok"
|
|
98
|
+
if (status !== "ok" && status !== state.last_status) {
|
|
99
|
+
notified = await deps.page(`${status === "blocked" ? "🚧" : "⚠️"} ${decl.id} ${status} (spinuptui ${decl.command.join(" ")})\n\n${tail || "(no output)"}`)
|
|
100
|
+
if (wasOk) state.failing_since = fmt(started)
|
|
101
|
+
} else if (status === "ok" && !wasOk) {
|
|
101
102
|
notified = await deps.page(`✅ ${decl.id} is working again (failing since ${state.failing_since ?? "?"})`)
|
|
102
103
|
delete state.failing_since
|
|
103
104
|
}
|
|
@@ -250,7 +251,12 @@ export async function tick(opts: TickOptions): Promise<TickResult> {
|
|
|
250
251
|
const dir = schedulerStateDir()
|
|
251
252
|
mkdirSync(dir, { recursive: true })
|
|
252
253
|
const lock = tryLock(join(dir, "tick.lock"))
|
|
253
|
-
if (!lock)
|
|
254
|
+
if (!lock) {
|
|
255
|
+
// The heartbeat can't wait for a long job to finish: Kuma would call a busy
|
|
256
|
+
// station dead. The holder is alive, so this tick says so.
|
|
257
|
+
if (opts.via === "dispatcher") await recordStillRunning()
|
|
258
|
+
return { ok: true, skipped: "locked", message: "A previous tick is still running." }
|
|
259
|
+
}
|
|
254
260
|
const startedAt = Date.now()
|
|
255
261
|
const now = opts.now ?? new Date()
|
|
256
262
|
const ran: TickRun[] = []
|
|
@@ -265,7 +271,7 @@ export async function tick(opts: TickOptions): Promise<TickResult> {
|
|
|
265
271
|
now,
|
|
266
272
|
run: async (decl) => {
|
|
267
273
|
const p = await runProcess([...selfCommand(), ...decl.command], { cwd: dir, env: jobEnv(), timeoutMs: MODULE_JOB_TIMEOUT_MS })
|
|
268
|
-
return { ok: p.code === 0, output: p.output }
|
|
274
|
+
return { ok: p.code === 0, output: p.output, blocked: p.code === BLOCKED_EXIT }
|
|
269
275
|
},
|
|
270
276
|
page: (text) => notify(text),
|
|
271
277
|
})
|
|
@@ -300,8 +306,7 @@ export async function tick(opts: TickOptions): Promise<TickResult> {
|
|
|
300
306
|
record.heartbeat = { ok: r.ok, status: r.status, ...(r.error ? { error: r.error } : {}) }
|
|
301
307
|
if (!r.ok) record.errors.push(`heartbeat: Kuma didn't take the ping (${r.error})`)
|
|
302
308
|
}
|
|
303
|
-
|
|
304
|
-
renameSync(`${lastTickPath()}.tmp`, lastTickPath())
|
|
309
|
+
writeLastTick(record)
|
|
305
310
|
}
|
|
306
311
|
if (ran.length || errors.length) appendTickLog(record)
|
|
307
312
|
return { ok: true, ...record }
|
|
@@ -310,6 +315,23 @@ export async function tick(opts: TickOptions): Promise<TickResult> {
|
|
|
310
315
|
}
|
|
311
316
|
}
|
|
312
317
|
|
|
318
|
+
function writeLastTick(record: TickRecord): void {
|
|
319
|
+
writeFileSync(`${lastTickPath()}.tmp`, JSON.stringify(record, null, 2) + "\n")
|
|
320
|
+
renameSync(`${lastTickPath()}.tmp`, lastTickPath())
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function recordStillRunning(): Promise<void> {
|
|
324
|
+
const record: TickRecord = { at: new Date().toISOString(), via: "dispatcher", durationMs: 0, ran: [], errors: [], stillRunning: true }
|
|
325
|
+
const hb = loadConfig().scheduler?.heartbeat
|
|
326
|
+
if (hb) {
|
|
327
|
+
const t = stillRunningHeartbeat(pagerProblem())
|
|
328
|
+
const r = await sendHeartbeat(hb.url, t.status, t.msg, 0)
|
|
329
|
+
record.heartbeat = { ok: r.ok, status: r.status, ...(r.error ? { error: r.error } : {}) }
|
|
330
|
+
if (!r.ok) record.errors.push(`heartbeat: Kuma didn't take the ping (${r.error})`)
|
|
331
|
+
}
|
|
332
|
+
writeLastTick(record)
|
|
333
|
+
}
|
|
334
|
+
|
|
313
335
|
function appendTickLog(record: TickRecord): void {
|
|
314
336
|
try {
|
|
315
337
|
const path = tickLogPath()
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { applyAttach, planAttach } from "./sentinelAttach.ts"
|
|
3
|
+
import type { KumaMonitor } from "./uptimeKuma.ts"
|
|
4
|
+
|
|
5
|
+
const m = (id: number, name: string, over: Partial<KumaMonitor> = {}): KumaMonitor => ({ id, name, type: "http", ...over })
|
|
6
|
+
const sites = ["a.example.com", "b.example.com", "c.example.com"]
|
|
7
|
+
|
|
8
|
+
describe("planAttach", () => {
|
|
9
|
+
test("matches by the naming convention", () => {
|
|
10
|
+
const p = planAttach([m(1, "a.example.com"), m(2, "a.example.com load", { type: "push" }), m(3, "a.example.com php-fatal"), m(4, "a.example.com front page"), m(5, "a.example.com cache-bypass"), m(6, "a.example.com redis")], sites, {})
|
|
11
|
+
expect(p.exact.map((x) => x.field).sort()).toEqual(["bypassId", "fatalId", "fingerprintId", "healthId", "pushId", "redisId"])
|
|
12
|
+
expect(p.unmonitored).toEqual(["b.example.com", "c.example.com"])
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test("a site with extra domains counts once", () => {
|
|
16
|
+
const p = planAttach([], ["a.example.com", "www.a.example.com"], {}, ["a.example.com"])
|
|
17
|
+
expect(p.unmonitored).toEqual(["a.example.com"])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test("a hand-made monitor on a fleet host is only likely", () => {
|
|
21
|
+
const p = planAttach([m(1, "Client homepage", { url: "https://www.b.example.com/" })], sites, {})
|
|
22
|
+
expect(p.exact).toEqual([])
|
|
23
|
+
expect(p.likely).toMatchObject([{ domain: "b.example.com", field: "healthId", monitorId: 1 }])
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("ignores strangers, and what's already attached", () => {
|
|
27
|
+
const p = planAttach([m(1, "a.example.com"), m(9, "My router", { url: "https://192.0.2.1" })], sites, { "a.example.com": { healthId: 1 } })
|
|
28
|
+
expect(p.already).toBe(1)
|
|
29
|
+
expect(p.exact).toEqual([])
|
|
30
|
+
expect(p.strangers).toEqual(["My router"])
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
describe("applyAttach", () => {
|
|
35
|
+
test("keeps the token Kuma already holds, and never overwrites", () => {
|
|
36
|
+
const monitors = [m(2, "a.example.com load", { type: "push", pushToken: "live-token" }), m(1, "a.example.com")]
|
|
37
|
+
const r = applyAttach({ "a.example.com": { healthId: 7 } }, [{ domain: "a.example.com", field: "pushId", monitorId: 2, name: "" }, { domain: "a.example.com", field: "healthId", monitorId: 1, name: "" }], monitors)
|
|
38
|
+
expect(r["a.example.com"]).toEqual({ healthId: 7, pushId: 2, pushToken: "live-token" })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("rebuilds a fingerprint's keyword", () => {
|
|
42
|
+
const r = applyAttach({}, [{ domain: "a.example.com", field: "fingerprintId", monitorId: 4, name: "" }], [m(4, "x", { keyword: "home", interval: 600 })])
|
|
43
|
+
expect(r["a.example.com"]?.fingerprint).toMatchObject({ keyword: "home", kind: "attached", interval: 600 })
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// `sentinel attach`: adopts an existing Kuma's monitors, because a fresh install
|
|
2
|
+
// connected to a Kuma full of them still sees none (`incidents` reads the local
|
|
3
|
+
// registry). The registry is rebuilt from what Kuma already knows.
|
|
4
|
+
//
|
|
5
|
+
// Read-only toward Kuma and the servers: it never creates, renames or edits a
|
|
6
|
+
// monitor, and never generates a push token, because the server crons are already
|
|
7
|
+
// pushing to the existing ones. Re-running only fills gaps.
|
|
8
|
+
|
|
9
|
+
import type { KumaMonitorRef } from "../config.ts"
|
|
10
|
+
import type { KumaMonitor } from "./uptimeKuma.ts"
|
|
11
|
+
|
|
12
|
+
// SpinupTUI's own naming convention, longest suffix first.
|
|
13
|
+
const SUFFIXES: [string, keyof KumaMonitorRef, (keyof KumaMonitorRef)?][] = [
|
|
14
|
+
[" php-fatal", "fatalId", "fatalToken"],
|
|
15
|
+
[" cache-bypass", "bypassId"],
|
|
16
|
+
[" front page", "fingerprintId"],
|
|
17
|
+
[" redis", "redisId", "redisToken"],
|
|
18
|
+
[" load", "pushId", "pushToken"],
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
export interface AttachMatch {
|
|
22
|
+
domain: string
|
|
23
|
+
field: keyof KumaMonitorRef
|
|
24
|
+
monitorId: number
|
|
25
|
+
name: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface AttachPlan {
|
|
29
|
+
exact: AttachMatch[]
|
|
30
|
+
likely: AttachMatch[]
|
|
31
|
+
unmonitored: string[]
|
|
32
|
+
strangers: string[]
|
|
33
|
+
already: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function nameMatch(m: KumaMonitor): { domain: string; field: keyof KumaMonitorRef; token?: keyof KumaMonitorRef } {
|
|
37
|
+
for (const [suffix, field, token] of SUFFIXES) if (m.name.endsWith(suffix)) return { domain: m.name.slice(0, -suffix.length), field, token }
|
|
38
|
+
return { domain: m.name, field: "healthId" }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function urlHost(url: string | undefined): string | null {
|
|
42
|
+
try {
|
|
43
|
+
return url ? new URL(url).hostname.toLowerCase() : null
|
|
44
|
+
} catch {
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// `domains` is every name a monitor might use; `primary` is one per site, so a
|
|
50
|
+
// site with extra domains counts once when listing what has no monitor.
|
|
51
|
+
export function planAttach(monitors: KumaMonitor[], domains: string[], registry: Record<string, KumaMonitorRef>, primary: string[] = domains): AttachPlan {
|
|
52
|
+
const sites = new Set(domains)
|
|
53
|
+
const has = (domain: string, field: keyof KumaMonitorRef) => typeof registry[domain]?.[field] === "number"
|
|
54
|
+
const taken = new Set(Object.values(registry).flatMap((r) => Object.entries(r).filter(([k, v]) => k.endsWith("Id") && typeof v === "number").map(([, v]) => v as number)))
|
|
55
|
+
const plan: AttachPlan = { exact: [], likely: [], unmonitored: [], strangers: [], already: 0 }
|
|
56
|
+
const covered = new Set(Object.keys(registry).filter((d) => registry[d]?.healthId))
|
|
57
|
+
const leftover: KumaMonitor[] = []
|
|
58
|
+
for (const m of monitors) {
|
|
59
|
+
if (taken.has(m.id)) {
|
|
60
|
+
plan.already++
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
const { domain, field } = nameMatch(m)
|
|
64
|
+
if (sites.has(domain) && !has(domain, field)) {
|
|
65
|
+
plan.exact.push({ domain, field, monitorId: m.id, name: m.name })
|
|
66
|
+
if (field === "healthId") covered.add(domain)
|
|
67
|
+
} else leftover.push(m)
|
|
68
|
+
}
|
|
69
|
+
for (const m of leftover) {
|
|
70
|
+
const host = urlHost(m.url)
|
|
71
|
+
const domain = host && (sites.has(host) ? host : sites.has(host.replace(/^www\./, "")) ? host.replace(/^www\./, "") : null)
|
|
72
|
+
if (domain && m.type === "http" && !covered.has(domain)) {
|
|
73
|
+
plan.likely.push({ domain, field: "healthId", monitorId: m.id, name: m.name })
|
|
74
|
+
covered.add(domain)
|
|
75
|
+
} else plan.strangers.push(m.name)
|
|
76
|
+
}
|
|
77
|
+
plan.unmonitored = primary.filter((d) => !covered.has(d) && !registry[d]).sort()
|
|
78
|
+
return plan
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The registry after attaching: existing entries are never overwritten, and each
|
|
82
|
+
// push monitor keeps the token Kuma already holds.
|
|
83
|
+
export function applyAttach(registry: Record<string, KumaMonitorRef>, matches: AttachMatch[], monitors: KumaMonitor[]): Record<string, KumaMonitorRef> {
|
|
84
|
+
const byId = new Map(monitors.map((m) => [m.id, m]))
|
|
85
|
+
const out: Record<string, KumaMonitorRef> = structuredClone(registry)
|
|
86
|
+
for (const a of matches) {
|
|
87
|
+
const ref = (out[a.domain] ??= {}) as Record<string, unknown>
|
|
88
|
+
if (typeof ref[a.field] === "number") continue
|
|
89
|
+
ref[a.field] = a.monitorId
|
|
90
|
+
const m = byId.get(a.monitorId)
|
|
91
|
+
const token = SUFFIXES.find(([, field]) => field === a.field)?.[2]
|
|
92
|
+
if (token && m?.pushToken) ref[token] = m.pushToken
|
|
93
|
+
if (a.field === "fingerprintId" && typeof m?.keyword === "string") {
|
|
94
|
+
ref.fingerprint = { keyword: m.keyword, kind: "attached", detail: "", interval: Number(m.interval) || 300, derivedAt: new Date().toISOString() }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return out
|
|
98
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { aptTargets, composeDigest, type DigestData } from "./sentinelDigest.ts"
|
|
3
|
+
import type { ExpectResult } from "./sentinelExpect.ts"
|
|
4
|
+
import type { Flags, Incident } from "./sentinelState.ts"
|
|
5
|
+
|
|
6
|
+
const when = new Date("2026-10-30T12:00:00")
|
|
7
|
+
const flag = (id: string, text = "LOW"): ExpectResult => ({ id, label: id, state: "flag", text })
|
|
8
|
+
const data = (over: Partial<DigestData> = {}): DigestData => ({ when, health: [{ name: "Station", ok: true, detail: "ticking" }], ticks: [], incidents: [], results: [], aptTotal: 0, apt: [], notes: [], ...over })
|
|
9
|
+
|
|
10
|
+
describe("the header reacts to what's new", () => {
|
|
11
|
+
test("all clear is green and silent", () => {
|
|
12
|
+
const d = composeDigest(data(), {})
|
|
13
|
+
expect(d.text.startsWith("✅ Fleet OK")).toBe(true)
|
|
14
|
+
expect(d.silent).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test("a new flag makes a sound, and records the day", () => {
|
|
18
|
+
const d = composeDigest(data({ results: [flag("a")] }), {})
|
|
19
|
+
expect(d.text.startsWith("⚠️ 1 new")).toBe(true)
|
|
20
|
+
expect(d.silent).toBe(false)
|
|
21
|
+
expect(d.text).toContain("New:")
|
|
22
|
+
expect(d.flags.a!.firstSeen).toBe("2026-10-30")
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test("yesterday's flag is ongoing, with its age, and stays quiet", () => {
|
|
26
|
+
const flags: Flags = { a: { firstSeen: "2026-10-18", text: "a: LOW" } }
|
|
27
|
+
const d = composeDigest(data({ results: [flag("a")] }), flags)
|
|
28
|
+
expect(d.text.startsWith("✅ Fleet OK")).toBe(true)
|
|
29
|
+
expect(d.silent).toBe(true)
|
|
30
|
+
expect(d.text).toContain("Ongoing:")
|
|
31
|
+
expect(d.text).toContain("(12 days)")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("an acknowledged flag becomes a count, and is never hidden", () => {
|
|
35
|
+
const d = composeDigest(data({ results: [flag("a")] }), { a: { firstSeen: "2026-10-18", text: "x", acked: true } })
|
|
36
|
+
expect(d.text).toContain("1 acknowledged flag still set")
|
|
37
|
+
expect(d.text).not.toContain("Ongoing:")
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("a flag that clears starts over if it comes back", () => {
|
|
41
|
+
const d = composeDigest(data({ results: [{ id: "a", label: "a", state: "ok", text: "fine" }] }), { a: { firstSeen: "2026-10-18", text: "x" } })
|
|
42
|
+
expect(d.flags).toEqual({})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test("a check that couldn't run keeps its flag's age", () => {
|
|
46
|
+
const d = composeDigest(data({ results: [{ id: "a", label: "a", state: "unverified", text: "no count" }] }), { a: { firstSeen: "2026-10-18", text: "x" } })
|
|
47
|
+
expect(d.flags.a!.firstSeen).toBe("2026-10-18")
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("a failing health check makes a sound", () => {
|
|
51
|
+
const d = composeDigest(data({ health: [{ name: "Agent", ok: false, detail: "login lapsed" }] }), {})
|
|
52
|
+
expect(d.text.startsWith("⚠️ Needs attention")).toBe(true)
|
|
53
|
+
expect(d.silent).toBe(false)
|
|
54
|
+
expect(d.text).toContain("Agent: ⚠️ login lapsed")
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("a stuck incident is named", () => {
|
|
59
|
+
const i: Incident = { id: "old", key: "k", server: null, state: "waiting_agent", createdAt: new Date(when.getTime() - 2 * 86_400_000).toISOString(), events: [{ domain: "a.example.com", kind: "health", time: "t", message: "", monitorId: 1 }], down: [], lastDownAt: "", attempts: 0 }
|
|
60
|
+
const d = composeDigest(data({ incidents: [i] }), {})
|
|
61
|
+
expect(d.silent).toBe(false)
|
|
62
|
+
expect(d.text).toContain("old: waiting_agent")
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test("activity counts, and recurrences by site", () => {
|
|
66
|
+
const base: Incident = { id: "x", key: "k", server: null, state: "reported", createdAt: when.toISOString(), events: [{ domain: "a.example.com", kind: "health", time: "t", message: "", monitorId: 1 }], down: [], lastDownAt: "", attempts: 1 }
|
|
67
|
+
const d = composeDigest(data({ incidents: [base, { ...base, id: "y", state: "recurrence" }, { ...base, id: "z", state: "recurrence" }], ticks: [{ at: "", ok: true, events: 0, incidents: 0, retries: 1 }, { at: "", ok: false, events: 0, incidents: 0, retries: 2 }] }), {})
|
|
68
|
+
expect(d.text).toContain("2 checks · 1 incident · 1 report · 2 recurrences · 1 blind · 1 cold start")
|
|
69
|
+
expect(d.text).toContain("a.example.com ×2")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test("server updates collapse to a count, naming only the exceptions", () => {
|
|
73
|
+
const apt: ExpectResult[] = [{ id: "apt-a", label: "a", state: "ok", text: "" }, { id: "apt-b", label: "b", state: "unverified", text: "" }, flag("apt-c", "STALE")]
|
|
74
|
+
const d = composeDigest(data({ apt, aptTotal: 3 }), {})
|
|
75
|
+
expect(d.text).toContain("Server updates: 1 of 3 normal, unverified: b")
|
|
76
|
+
expect(d.text).toContain("apt-c: STALE")
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("the update check tries one site per server, the one that worked last", () => {
|
|
80
|
+
const fleet = { domains: { "a.example.com": { server: "w1", site: "a.example.com" }, "b.example.com": { server: "w1", site: "b.example.com" }, "www.b.example.com": { server: "w1", site: "b.example.com" }, "w2.example.com": { server: "w2", site: "w2.example.com", vanity: true }, "c.example.com": { server: "w2", site: "c.example.com" } } }
|
|
81
|
+
expect(aptTargets(fleet, { w1: "b.example.com" }, new Set(["c.example.com"]))).toEqual([{ server: "w1", domain: "b.example.com" }, { server: "w2", domain: "c.example.com" }])
|
|
82
|
+
})
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// The daily digest: the place a slow-moving problem gets said out loud. It always
|
|
2
|
+
// sends, and it's silent unless something is new, because a header that's amber
|
|
3
|
+
// every day stops being read. A flag is under New on the day it trips, then under
|
|
4
|
+
// Ongoing with its age (docs/2026-10-30_sentinel-spec.md, "The digest").
|
|
5
|
+
|
|
6
|
+
import type { AppConfig } from "../config.ts"
|
|
7
|
+
import type { SpinupWPClientLike } from "../api/client.ts"
|
|
8
|
+
import { schedulerModule } from "../modules/scheduler.ts"
|
|
9
|
+
import { boardLogin } from "./beaconBoard.ts"
|
|
10
|
+
import { listenerHealth } from "./beaconListenInstall.ts"
|
|
11
|
+
import { agentCommand, AUTH_FAIL_RE, jobEnv, runProcess } from "./schedulerRun.ts"
|
|
12
|
+
import { APT_COMMAND, appendTrends, loadExpectations, readApt, runExpectation, validateExpectation, type ExpectDeps, type ExpectResult } from "./sentinelExpect.ts"
|
|
13
|
+
import { isWaiting, loadFleet, loadIncidents, loadPause, readTickLog, saveFleet, saveFlags, readJson, writeJson, sentinelDir, type Fleet, type Flags, type Incident, type SentinelSettings, type TickLine } from "./sentinelState.ts"
|
|
14
|
+
import { refreshFleetFrom } from "./sentinelTick.ts"
|
|
15
|
+
import { execSshCommand } from "./sshExec.ts"
|
|
16
|
+
import { hasIncidentMonitors } from "./cliIncidents.ts"
|
|
17
|
+
import { withKuma } from "./uptimeKuma.ts"
|
|
18
|
+
import { join } from "node:path"
|
|
19
|
+
|
|
20
|
+
const CHECK_TIMEOUT_MS = 30_000
|
|
21
|
+
const CONCURRENCY = 4
|
|
22
|
+
const AGENT_PROBE_MS = 60_000
|
|
23
|
+
const DAY_MS = 24 * 3_600_000
|
|
24
|
+
const STUCK_MS = DAY_MS
|
|
25
|
+
|
|
26
|
+
export interface HealthCheck {
|
|
27
|
+
name: string
|
|
28
|
+
ok: boolean
|
|
29
|
+
detail: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DigestData {
|
|
33
|
+
when: Date
|
|
34
|
+
health: HealthCheck[]
|
|
35
|
+
ticks: TickLine[]
|
|
36
|
+
incidents: Incident[]
|
|
37
|
+
results: ExpectResult[]
|
|
38
|
+
aptTotal: number
|
|
39
|
+
apt: ExpectResult[]
|
|
40
|
+
notes: string[]
|
|
41
|
+
paused?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Digest {
|
|
45
|
+
text: string
|
|
46
|
+
silent: boolean
|
|
47
|
+
flags: Flags
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const localDay = (d: Date) => d.toLocaleDateString("en-CA")
|
|
51
|
+
const dayCount = (from: string, to: string) => Math.round((Date.parse(to) - Date.parse(from)) / DAY_MS)
|
|
52
|
+
const plural = (n: number, s: string) => `${n} ${s}${n === 1 ? "" : "s"}`
|
|
53
|
+
|
|
54
|
+
export function composeDigest(d: DigestData, flags: Flags): Digest {
|
|
55
|
+
const today = localDay(d.when)
|
|
56
|
+
const since = d.when.getTime() - DAY_MS
|
|
57
|
+
const checks = [...d.results, ...d.apt]
|
|
58
|
+
const next: Flags = {}
|
|
59
|
+
const fresh: string[] = []
|
|
60
|
+
const ongoing: string[] = []
|
|
61
|
+
let acked = 0
|
|
62
|
+
for (const r of checks) {
|
|
63
|
+
if (r.state !== "flag") continue
|
|
64
|
+
const line = `${r.label}: ${r.text}`
|
|
65
|
+
const prior = flags[r.id]
|
|
66
|
+
next[r.id] = { firstSeen: prior?.firstSeen ?? today, text: line, ...(prior?.acked ? { acked: true } : {}) }
|
|
67
|
+
if (!prior || prior.firstSeen === today) fresh.push(line)
|
|
68
|
+
else if (prior.acked) acked++
|
|
69
|
+
else ongoing.push(`${line} (${plural(dayCount(prior.firstSeen, today), "day")})`)
|
|
70
|
+
}
|
|
71
|
+
// A check that couldn't run says nothing either way, so its flag keeps its age.
|
|
72
|
+
for (const r of checks) if (r.state === "unverified" && flags[r.id]) next[r.id] = flags[r.id]!
|
|
73
|
+
|
|
74
|
+
const recent = d.incidents.filter((i) => Date.parse(i.createdAt) >= since)
|
|
75
|
+
const stuck = d.incidents.filter((i) => isWaiting(i) && d.when.getTime() - Date.parse(i.createdAt) > STUCK_MS)
|
|
76
|
+
const failing = d.health.filter((h) => !h.ok)
|
|
77
|
+
const reports = recent.filter((i) => i.state === "reported").length
|
|
78
|
+
const recurrences = recent.filter((i) => i.state === "recurrence")
|
|
79
|
+
const blind = d.ticks.filter((t) => !t.ok).length
|
|
80
|
+
const retried = d.ticks.filter((t) => t.ok && t.retries > 0).length
|
|
81
|
+
|
|
82
|
+
const date = d.when.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" })
|
|
83
|
+
const attention = fresh.length > 0 || failing.length > 0 || stuck.length > 0
|
|
84
|
+
const header = !attention ? `✅ Fleet OK — ${date}` : fresh.length ? `⚠️ ${fresh.length} new — ${date}` : `⚠️ Needs attention — ${date}`
|
|
85
|
+
|
|
86
|
+
const lines = [header, ""]
|
|
87
|
+
if (d.paused) lines.push(`Paused until ${d.paused}.`, "")
|
|
88
|
+
lines.push(d.health.map((h) => `${h.name}: ${h.ok ? h.detail : `⚠️ ${h.detail}`}`).join("\n"))
|
|
89
|
+
lines.push(`Last 24h: ${plural(d.ticks.length, "check")} · ${plural(recent.length - recurrences.length, "incident")} · ${plural(reports, "report")} · ${plural(recurrences.length, "recurrence")} · ${blind} blind${retried ? ` · ${retried} cold start${retried === 1 ? "" : "s"}` : ""}`)
|
|
90
|
+
if (recurrences.length) {
|
|
91
|
+
const bySite = new Map<string, number>()
|
|
92
|
+
for (const i of recurrences) bySite.set(i.events[0]!.domain, (bySite.get(i.events[0]!.domain) ?? 0) + 1)
|
|
93
|
+
lines.push(`Recurring: ${[...bySite].map(([s, n]) => `${s} ×${n}`).join(", ")}`)
|
|
94
|
+
}
|
|
95
|
+
if (stuck.length) lines.push("", "Stuck:", ...stuck.map((i) => ` ${i.id}: ${i.state}${i.why ? ` (${i.why})` : ""}`))
|
|
96
|
+
if (fresh.length) lines.push("", "New:", ...fresh.map((l) => ` ${l}`))
|
|
97
|
+
if (ongoing.length) lines.push("", "Ongoing:", ...ongoing.map((l) => ` ${l}`))
|
|
98
|
+
if (acked) lines.push("", `${plural(acked, "acknowledged flag")} still set.`)
|
|
99
|
+
const quiet = d.results.filter((r) => r.state !== "flag")
|
|
100
|
+
if (quiet.length) lines.push("", "Checks:", ...quiet.map((r) => ` ${r.label}: ${r.state === "unverified" ? `unverified (${r.text})` : r.text}`))
|
|
101
|
+
if (d.aptTotal) {
|
|
102
|
+
const unverified = d.apt.filter((r) => r.state === "unverified")
|
|
103
|
+
const normal = d.apt.filter((r) => r.state === "ok").length
|
|
104
|
+
lines.push("", `Server updates: ${normal} of ${d.aptTotal} normal${unverified.length ? `, unverified: ${unverified.map((r) => r.label).join(", ")}` : ""}`)
|
|
105
|
+
}
|
|
106
|
+
if (d.notes.length) lines.push("", ...d.notes)
|
|
107
|
+
return { text: lines.join("\n"), silent: !attention, flags: next }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- gathering --------------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
async function bounded<T>(fn: () => Promise<T>, fallback: T, ms = CHECK_TIMEOUT_MS): Promise<T> {
|
|
113
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
114
|
+
try {
|
|
115
|
+
return await Promise.race([fn(), new Promise<T>((r) => (timer = setTimeout(() => r(fallback), ms)))])
|
|
116
|
+
} catch {
|
|
117
|
+
return fallback
|
|
118
|
+
} finally {
|
|
119
|
+
clearTimeout(timer)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function mapLimit<T, R>(items: T[], limit: number, fn: (x: T) => Promise<R>): Promise<R[]> {
|
|
124
|
+
const out: R[] = new Array(items.length)
|
|
125
|
+
let next = 0
|
|
126
|
+
await Promise.all(
|
|
127
|
+
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
128
|
+
while (next < items.length) {
|
|
129
|
+
const i = next++
|
|
130
|
+
out[i] = await fn(items[i]!)
|
|
131
|
+
}
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function expectDeps(cfg: AppConfig, client: SpinupWPClientLike): ExpectDeps {
|
|
138
|
+
return {
|
|
139
|
+
exec: async (domain, command, server) => {
|
|
140
|
+
const r = await execSshCommand(domain, command, client, cfg, { server: server ?? null })
|
|
141
|
+
return r.ok ? r.stdout : null
|
|
142
|
+
},
|
|
143
|
+
status: async (url) => {
|
|
144
|
+
try {
|
|
145
|
+
return (await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(20_000) })).status
|
|
146
|
+
} catch {
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// One attempt per server per day, on the site that worked last: a server with no
|
|
154
|
+
// reachable site reads "unverified", and fail2ban never hears about it.
|
|
155
|
+
const aptSitesPath = () => join(sentinelDir(), "apt-sites.json")
|
|
156
|
+
|
|
157
|
+
export function aptTargets(fleet: Fleet, lastGood: Record<string, string>, monitored: Set<string>): { server: string; domain: string }[] {
|
|
158
|
+
const byServer = new Map<string, string[]>()
|
|
159
|
+
for (const [domain, e] of Object.entries(fleet.domains)) {
|
|
160
|
+
if (domain !== e.site) continue
|
|
161
|
+
byServer.set(e.server, [...(byServer.get(e.server) ?? []), domain])
|
|
162
|
+
}
|
|
163
|
+
return [...byServer].sort().map(([server, domains]) => {
|
|
164
|
+
const rank = (d: string) => (d === lastGood[server] ? 0 : monitored.has(d) ? 1 : fleet.domains[d]?.vanity ? 2 : 3)
|
|
165
|
+
return { server, domain: [...domains].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b))[0]! }
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function checkApt(fleet: Fleet, cfg: AppConfig, deps: ExpectDeps, dryRun: boolean): Promise<ExpectResult[]> {
|
|
170
|
+
const lastGood = readJson<Record<string, string>>(aptSitesPath(), {})
|
|
171
|
+
const monitored = new Set(Object.entries(cfg.kumaMonitors).filter(([, r]) => hasIncidentMonitors(r)).map(([d]) => d))
|
|
172
|
+
const results = await mapLimit(aptTargets(fleet, lastGood, monitored), CONCURRENCY, async ({ server, domain }) => {
|
|
173
|
+
const out = await bounded(() => deps.exec(domain, APT_COMMAND), null)
|
|
174
|
+
const read = readApt(out)
|
|
175
|
+
if (read) lastGood[server] = domain
|
|
176
|
+
const base = { id: `apt-${server}`, label: server }
|
|
177
|
+
if (!read) return { ...base, state: "unverified" as const, text: "probe failed" }
|
|
178
|
+
return read.flag ? { ...base, state: "flag" as const, text: read.flag } : { ...base, state: "ok" as const, text: "updating" }
|
|
179
|
+
})
|
|
180
|
+
if (!dryRun) writeJson(aptSitesPath(), lastGood)
|
|
181
|
+
return results
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function probeAgent(s: SentinelSettings): Promise<HealthCheck> {
|
|
185
|
+
const env = jobEnv()
|
|
186
|
+
const cmd = agentCommand({ agent: s.agent!, prompt: "Reply with exactly: AUTH_OK", workdir: s.workspace }, env)
|
|
187
|
+
if (typeof cmd === "string") return { name: "Agent", ok: false, detail: cmd }
|
|
188
|
+
const r = await runProcess(cmd, { cwd: sentinelDir(), env, timeoutMs: AGENT_PROBE_MS }).catch((e: Error) => ({ code: 1, output: e.message, timedOut: false }))
|
|
189
|
+
if (r.output.includes("AUTH_OK")) return { name: "Agent", ok: true, detail: "OK" }
|
|
190
|
+
return { name: "Agent", ok: false, detail: AUTH_FAIL_RE.test(r.output) ? `the ${s.agent} login has lapsed` : `unverified (probe ${r.timedOut ? "timed out" : `exited ${r.code}`})` }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export async function gatherDigest(s: SentinelSettings, cfg: AppConfig, client: SpinupWPClientLike, opts: { dryRun?: boolean; now?: Date } = {}): Promise<DigestData> {
|
|
194
|
+
const when = opts.now ?? new Date()
|
|
195
|
+
const notes: string[] = []
|
|
196
|
+
let fleet = loadFleet()
|
|
197
|
+
try {
|
|
198
|
+
fleet = await refreshFleetFrom(client, when)
|
|
199
|
+
if (!opts.dryRun) saveFleet(fleet)
|
|
200
|
+
} catch (err) {
|
|
201
|
+
notes.push(`Couldn't refresh the fleet list: ${(err as Error).message}`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const health: HealthCheck[] = []
|
|
205
|
+
const sched = await schedulerModule.status()
|
|
206
|
+
const bad = sched.checks.find((c) => !c.ok && (c.name === "last tick" || c.name === "dispatcher" || c.name === "heartbeat"))
|
|
207
|
+
health.push({ name: "Station", ok: !bad, detail: bad ? `${bad.name}: ${bad.detail}` : "ticking" })
|
|
208
|
+
if (s.agent) health.push(await probeAgent(s))
|
|
209
|
+
if (s.reportTo) {
|
|
210
|
+
const b = await bounded(() => boardLogin(), { ok: false, detail: "timed out" })
|
|
211
|
+
health.push({ name: "Board", ok: b.ok, detail: b.ok ? "OK" : b.detail })
|
|
212
|
+
}
|
|
213
|
+
const listener = listenerHealth()
|
|
214
|
+
if (listener) health.push({ name: "Listener", ok: listener.ok, detail: listener.ok ? "OK" : listener.detail })
|
|
215
|
+
if (cfg.uptimeKuma) {
|
|
216
|
+
const ids = await bounded(async () => new Set((await withKuma(cfg.uptimeKuma!, (k) => k.getMonitors())).result.map((m) => m.id)), null)
|
|
217
|
+
if (!ids) health.push({ name: "Kuma", ok: false, detail: "couldn't list monitors" })
|
|
218
|
+
else {
|
|
219
|
+
const gone = Object.entries(cfg.kumaMonitors)
|
|
220
|
+
.filter(([, r]) => Object.entries(r).some(([k, v]) => k.endsWith("Id") && typeof v === "number" && !ids.has(v)))
|
|
221
|
+
.map(([d]) => d)
|
|
222
|
+
health.push({ name: "Kuma", ok: gone.length === 0, detail: gone.length ? `monitors deleted for ${gone.join(", ")}` : "OK" })
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const deps = expectDeps(cfg, client)
|
|
227
|
+
const today = when.getTime()
|
|
228
|
+
const active = loadExpectations().filter((e) => {
|
|
229
|
+
if (validateExpectation(e).length) {
|
|
230
|
+
notes.push(`Skipped invalid check ${e.id}: ${validateExpectation(e).join("; ")}`)
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
if (e.expires && Date.parse(`${e.expires}T23:59:59`) < today) {
|
|
234
|
+
notes.push(`Check ${e.id} expired on ${e.expires} and no longer runs.`)
|
|
235
|
+
return false
|
|
236
|
+
}
|
|
237
|
+
return true
|
|
238
|
+
})
|
|
239
|
+
const results = await mapLimit(active, CONCURRENCY, (e) =>
|
|
240
|
+
bounded(() => runExpectation(e, deps), { id: e.id, label: e.label ?? e.id, state: "unverified" as const, text: "timed out" }, 90_000),
|
|
241
|
+
)
|
|
242
|
+
const apt = await checkApt(fleet, cfg, deps, !!opts.dryRun)
|
|
243
|
+
const pause = loadPause()
|
|
244
|
+
return {
|
|
245
|
+
when,
|
|
246
|
+
health,
|
|
247
|
+
ticks: readTickLog(when.getTime() - DAY_MS),
|
|
248
|
+
incidents: loadIncidents().filter((i) => !i.closedAt || Date.parse(i.createdAt) > when.getTime() - DAY_MS || isWaiting(i)),
|
|
249
|
+
results,
|
|
250
|
+
aptTotal: apt.length,
|
|
251
|
+
apt,
|
|
252
|
+
notes,
|
|
253
|
+
...(pause && Date.parse(pause.until) > when.getTime() ? { paused: new Date(pause.until).toLocaleString() } : {}),
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function recordDigest(digest: Digest, data: DigestData): void {
|
|
258
|
+
saveFlags(digest.flags)
|
|
259
|
+
appendTrends(data.results, localDay(new Date(data.when.getTime() - DAY_MS)))
|
|
260
|
+
}
|
|
261
|
+
|