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.
Files changed (50) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +12 -3
  3. package/package.json +1 -1
  4. package/src/config.ts +32 -0
  5. package/src/index.tsx +11 -0
  6. package/src/lib/beacon.test.ts +14 -0
  7. package/src/lib/beacon.ts +14 -1
  8. package/src/lib/beaconBoard.test.ts +69 -0
  9. package/src/lib/beaconBoard.ts +197 -0
  10. package/src/lib/beaconListen.test.ts +190 -0
  11. package/src/lib/beaconListen.ts +355 -0
  12. package/src/lib/beaconListenInstall.ts +251 -0
  13. package/src/lib/cliBeacon.ts +115 -3
  14. package/src/lib/cliIncidents.ts +13 -6
  15. package/src/lib/cliScheduler.ts +30 -1
  16. package/src/lib/cliSentinel.ts +451 -0
  17. package/src/lib/heartbeat.ts +5 -0
  18. package/src/lib/schedule.ts +4 -1
  19. package/src/lib/scheduler.test.ts +189 -1
  20. package/src/lib/schedulerExtend.ts +105 -0
  21. package/src/lib/schedulerInstall.ts +36 -15
  22. package/src/lib/schedulerJobs.ts +4 -2
  23. package/src/lib/schedulerRun.ts +61 -15
  24. package/src/lib/schedulerTick.ts +35 -13
  25. package/src/lib/sentinelAttach.test.ts +45 -0
  26. package/src/lib/sentinelAttach.ts +98 -0
  27. package/src/lib/sentinelDigest.test.ts +82 -0
  28. package/src/lib/sentinelDigest.ts +261 -0
  29. package/src/lib/sentinelExpect.test.ts +82 -0
  30. package/src/lib/sentinelExpect.ts +197 -0
  31. package/src/lib/sentinelIncidents.test.ts +145 -0
  32. package/src/lib/sentinelIncidents.ts +194 -0
  33. package/src/lib/sentinelPrompt.ts +89 -0
  34. package/src/lib/sentinelReply.ts +36 -0
  35. package/src/lib/sentinelState.ts +246 -0
  36. package/src/lib/sentinelTick.test.ts +214 -0
  37. package/src/lib/sentinelTick.ts +265 -0
  38. package/src/lib/sentinelWorker.test.ts +170 -0
  39. package/src/lib/sentinelWorker.ts +291 -0
  40. package/src/modules/beacon.ts +9 -1
  41. package/src/modules/contract.ts +34 -0
  42. package/src/modules/index.ts +2 -7
  43. package/src/modules/scheduler.ts +147 -6
  44. package/src/modules/sentinel.ts +90 -0
  45. package/src/ui/App.tsx +8 -0
  46. package/src/ui/store.tsx +11 -0
  47. package/src/ui/views/BeaconSetup.tsx +11 -7
  48. package/src/ui/views/ListenerSetup.tsx +205 -0
  49. package/src/ui/views/Modules.tsx +70 -5
  50. package/src/ui/views/SentinelSetup.tsx +171 -0
@@ -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
- if (status === "failed" && state.last_status !== "failed") {
98
- notified = await deps.page(`⚠️ ${decl.id} failed (spinuptui ${decl.command.join(" ")})\n\n${tail || "(no output)"}`)
99
- state.failing_since = fmt(started)
100
- } else if (status === "ok" && state.last_status === "failed") {
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) return { ok: true, skipped: "locked", message: "A previous tick is still running." }
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
- writeFileSync(`${lastTickPath()}.tmp`, JSON.stringify(record, null, 2) + "\n")
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
+