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
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
// `spinuptui sentinel …` — the CLI over Sentinel. Headless like every module
|
|
2
|
+
// command: --json prints one result object on stdout, progress goes to stderr,
|
|
3
|
+
// failures use the { ok: false, reason, message, remedy? } shape and exit 1.
|
|
4
|
+
|
|
5
|
+
import { loadConfig, saveConfig, type SentinelStored } from "../config.ts"
|
|
6
|
+
import { SpinupWPClient } from "../api/client.ts"
|
|
7
|
+
import { moduleWorkDir } from "../modules/contract.ts"
|
|
8
|
+
import { sentinelModule } from "../modules/sentinel.ts"
|
|
9
|
+
import { BLOCKED_EXIT } from "./schedulerRun.ts"
|
|
10
|
+
import { postToBoard } from "./beaconBoard.ts"
|
|
11
|
+
import { readFileSync } from "node:fs"
|
|
12
|
+
import { join } from "node:path"
|
|
13
|
+
import { applyAttach, planAttach, type AttachPlan } from "./sentinelAttach.ts"
|
|
14
|
+
import { composeDigest, expectDeps, gatherDigest, recordDigest } from "./sentinelDigest.ts"
|
|
15
|
+
import { EXPECT_TYPES, loadExpectations, runExpectation, saveExpectations, validateExpectation, type Expectation } from "./sentinelExpect.ts"
|
|
16
|
+
import { describeIncident } from "./sentinelReply.ts"
|
|
17
|
+
import { clearPause, deleteIncident, loadFlags, loadIncident, loadIncidents, loadPause, loadSeen, loadTickState, logShadow, saveFlags, saveIncident, savePause, saveSeen, saveTickState, sentinelSettings, type Seen } from "./sentinelState.ts"
|
|
18
|
+
import { eventMs } from "./sentinelIncidents.ts"
|
|
19
|
+
import { realDeps, sendPage, sentinelTick } from "./sentinelTick.ts"
|
|
20
|
+
import { realWorkerDeps, runWorker } from "./sentinelWorker.ts"
|
|
21
|
+
import { withKuma, type KumaMonitor } from "./uptimeKuma.ts"
|
|
22
|
+
|
|
23
|
+
const VALUE_FLAGS = new Set(["--for", "--since", "--agent", "--workspace", "--report-to", "--type", "--domain", "--server", "--match", "--min", "--threshold", "--host", "--paths", "--log-glob", "--url", "--expect", "--label", "--note", "--expires"])
|
|
24
|
+
|
|
25
|
+
const USAGE = `Usage:
|
|
26
|
+
spinuptui sentinel setup [--plan | --apply] [--shadow] [--agent claude|codex|none]
|
|
27
|
+
[--workspace <dir>] [--report-to <board>]
|
|
28
|
+
spinuptui sentinel attach --plan | --apply [--include-likely]
|
|
29
|
+
adopt an existing Kuma's monitors; read-only toward Kuma
|
|
30
|
+
spinuptui sentinel status [--json]
|
|
31
|
+
spinuptui sentinel tick [--dry-run] one check of Kuma (Scheduler runs this)
|
|
32
|
+
spinuptui sentinel digest [--dry-run] the daily digest (Scheduler runs this)
|
|
33
|
+
spinuptui sentinel list [--open] [--json]
|
|
34
|
+
spinuptui sentinel show <incident>
|
|
35
|
+
spinuptui sentinel diagnose <incident> queue it; the next tick starts it
|
|
36
|
+
spinuptui sentinel post <incident> post its report to the board
|
|
37
|
+
spinuptui sentinel pause --for 2h stop acting on events, up to 7d
|
|
38
|
+
spinuptui sentinel resume
|
|
39
|
+
spinuptui sentinel go-live --since "YYYY-MM-DD HH:MM:SS"
|
|
40
|
+
leave shadow mode; events after that UTC time are new
|
|
41
|
+
spinuptui sentinel expect list | check <id> | remove <id> | ack <id>
|
|
42
|
+
spinuptui sentinel expect add <id> --type ${EXPECT_TYPES.join("|")} [--domain <d>] [--label <l>] [--note <n>] [--expires YYYY-MM-DD]
|
|
43
|
+
log-count: --match <text> [--min N] [--trend]
|
|
44
|
+
load-shape: --threshold N
|
|
45
|
+
cache-hit: --host <canonical host> --paths /,/?one=param
|
|
46
|
+
warmer: --log-glob <path>
|
|
47
|
+
status: --url <url> --expect <code>`
|
|
48
|
+
|
|
49
|
+
interface Parsed {
|
|
50
|
+
positionals: string[]
|
|
51
|
+
flags: Map<string, string>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parse(list: string[]): Parsed {
|
|
55
|
+
const positionals: string[] = []
|
|
56
|
+
const flags = new Map<string, string>()
|
|
57
|
+
for (let i = 0; i < list.length; i++) {
|
|
58
|
+
const a = list[i]!
|
|
59
|
+
if (!a.startsWith("--")) positionals.push(a)
|
|
60
|
+
else if (VALUE_FLAGS.has(a)) flags.set(a, list[++i] ?? "")
|
|
61
|
+
else flags.set(a, "")
|
|
62
|
+
}
|
|
63
|
+
return { positionals, flags }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function fail(json: boolean, reason: string, message: string, remedy?: string): number {
|
|
67
|
+
if (json) console.log(JSON.stringify({ ok: false, reason, message, ...(remedy ? { remedy } : {}) }))
|
|
68
|
+
else {
|
|
69
|
+
console.error(message)
|
|
70
|
+
if (remedy) console.error(remedy)
|
|
71
|
+
}
|
|
72
|
+
return 1
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const say = (line: string) => process.stderr.write(`→ ${line}\n`)
|
|
76
|
+
const out = (json: boolean, value: object, text: string) => console.log(json ? JSON.stringify(value) : text)
|
|
77
|
+
|
|
78
|
+
export async function runSentinel(argv: string[]): Promise<number> {
|
|
79
|
+
const p = parse(argv)
|
|
80
|
+
const json = p.flags.has("--json")
|
|
81
|
+
const [sub, ...rest] = p.positionals
|
|
82
|
+
switch (sub) {
|
|
83
|
+
case "setup":
|
|
84
|
+
return setup(p, json)
|
|
85
|
+
case "attach":
|
|
86
|
+
return attach(p, json)
|
|
87
|
+
case "status":
|
|
88
|
+
return status(json)
|
|
89
|
+
case "tick":
|
|
90
|
+
return tickCmd(p, json)
|
|
91
|
+
case "worker":
|
|
92
|
+
return workerCmd()
|
|
93
|
+
case "digest":
|
|
94
|
+
return digestCmd(p, json)
|
|
95
|
+
case "list":
|
|
96
|
+
return list(p, json)
|
|
97
|
+
case "show":
|
|
98
|
+
return show(rest)
|
|
99
|
+
case "diagnose":
|
|
100
|
+
return diagnose(rest, json)
|
|
101
|
+
case "post":
|
|
102
|
+
return post(rest, p, json)
|
|
103
|
+
case "pause":
|
|
104
|
+
return pause(p, json)
|
|
105
|
+
case "resume":
|
|
106
|
+
return resume(json)
|
|
107
|
+
case "go-live":
|
|
108
|
+
return goLive(p, json)
|
|
109
|
+
case "expect":
|
|
110
|
+
return expect(rest, p, json)
|
|
111
|
+
}
|
|
112
|
+
return fail(json, "usage", USAGE)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ---- setup --------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
interface SetupPlan {
|
|
118
|
+
blockers: string[]
|
|
119
|
+
next: SentinelStored
|
|
120
|
+
notes: string[]
|
|
121
|
+
watching: number
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function planSetup(p: { shadow?: boolean; agent?: string; workspace?: string; reportTo?: string }): SetupPlan {
|
|
125
|
+
const cfg = loadConfig()
|
|
126
|
+
const blockers: string[] = []
|
|
127
|
+
const notes: string[] = []
|
|
128
|
+
if (!cfg.beacon?.channels || !Object.keys(cfg.beacon.channels).length) blockers.push("Beacon has no channel: spinuptui beacon setup")
|
|
129
|
+
if (!cfg.scheduler?.dispatcher) blockers.push("Scheduler's dispatcher isn't installed: spinuptui scheduler install --plan")
|
|
130
|
+
if (!cfg.uptimeKuma) blockers.push("No Uptime Kuma connection: press M on a site")
|
|
131
|
+
const watching = Object.keys(cfg.kumaMonitors).length
|
|
132
|
+
if (!watching) notes.push("No monitors are registered yet: run spinuptui sentinel attach --plan.")
|
|
133
|
+
const found = (["claude", "codex"] as const).find((a) => Bun.which(a))
|
|
134
|
+
const agent = p.agent === "none" ? null : ((p.agent as "claude" | "codex" | undefined) ?? cfg.sentinel?.diagnosis?.agent ?? found ?? null)
|
|
135
|
+
if (agent === "codex") notes.push("Codex is unproven here: its read-only sandbox blocks the network that ssh-exec needs.")
|
|
136
|
+
if (!agent) notes.push("No agent: incidents get a write-up of the evidence, not a diagnosis.")
|
|
137
|
+
const reportTo = p.reportTo ?? cfg.sentinel?.reportTo ?? null
|
|
138
|
+
if (reportTo && !cfg.beacon?.boards?.[reportTo]) blockers.push(`No Beacon board named "${reportTo}": spinuptui beacon add basecamp ${reportTo} --project <id>`)
|
|
139
|
+
const next: SentinelStored = {
|
|
140
|
+
...cfg.sentinel,
|
|
141
|
+
enabled: true,
|
|
142
|
+
mode: p.shadow ? "shadow" : (cfg.sentinel?.mode ?? "live"),
|
|
143
|
+
reportTo,
|
|
144
|
+
...(p.workspace ? { workspace: p.workspace } : {}),
|
|
145
|
+
diagnosis: { ...cfg.sentinel?.diagnosis, agent },
|
|
146
|
+
}
|
|
147
|
+
return { blockers, next, notes, watching }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function setupText(plan: SetupPlan): string {
|
|
151
|
+
const n = plan.next
|
|
152
|
+
return [
|
|
153
|
+
`Sentinel would watch ${plan.watching} domain(s), every 5 minutes, with a digest at 08:00, as Scheduler jobs.`,
|
|
154
|
+
` mode: ${n.mode}`,
|
|
155
|
+
` agent: ${n.diagnosis?.agent ?? "none"}`,
|
|
156
|
+
` workspace: ${n.workspace ?? join("~/SpinupTUI", "sentinel")}`,
|
|
157
|
+
` board: ${n.reportTo ?? "none"}`,
|
|
158
|
+
...plan.notes.map((x) => `Note: ${x}`),
|
|
159
|
+
...plan.blockers.map((x) => `Blocked: ${x}`),
|
|
160
|
+
].join("\n")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Saves the settings and proves them with one real tick. The TUI uses this too.
|
|
164
|
+
export async function applySetup(next: SentinelStored) {
|
|
165
|
+
await saveConfig({ sentinel: next })
|
|
166
|
+
return tick(false)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function setup(p: Parsed, json: boolean): Promise<number> {
|
|
170
|
+
const plan = planSetup({ shadow: p.flags.has("--shadow"), agent: p.flags.get("--agent"), workspace: p.flags.get("--workspace"), reportTo: p.flags.get("--report-to") })
|
|
171
|
+
if (!p.flags.has("--apply")) {
|
|
172
|
+
if (!p.flags.has("--plan") && !json) console.log("Nothing applied. Review this, then run with --apply.\n")
|
|
173
|
+
out(json, { ok: true, ...plan }, setupText(plan))
|
|
174
|
+
return 0
|
|
175
|
+
}
|
|
176
|
+
if (plan.blockers.length) return fail(json, "not_ready", plan.blockers.join("\n"))
|
|
177
|
+
say("Running one real tick")
|
|
178
|
+
const r = await applySetup(plan.next)
|
|
179
|
+
if (!r.ok) return fail(json, r.reason, r.message)
|
|
180
|
+
out(json, r, r.firstRun ? "Sentinel is on: it read Kuma and starts from now." : `Sentinel is on: ${r.events} new event(s).`)
|
|
181
|
+
return 0
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ---- attach ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
function attachText(a: AttachPlan, includeLikely: boolean): string {
|
|
187
|
+
const lines = [`${a.exact.length} monitor(s) match by name and would attach; ${a.already} already attached.`]
|
|
188
|
+
if (a.likely.length) lines.push(`${a.likely.length} likely match(es)${includeLikely ? "" : ", attached only with --include-likely"}:`, ...a.likely.map((m) => ` ${m.name} → ${m.domain}`))
|
|
189
|
+
if (a.unmonitored.length) lines.push(`${a.unmonitored.length} site(s) have no monitor: ${a.unmonitored.slice(0, 10).join(", ")}${a.unmonitored.length > 10 ? ", …" : ""}`)
|
|
190
|
+
if (a.strangers.length) lines.push(`${a.strangers.length} Kuma monitor(s) match no site and are ignored.`)
|
|
191
|
+
return lines.join("\n")
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// What attach would do: reads Kuma and the fleet, changes nothing.
|
|
195
|
+
export async function loadAttachPlan(): Promise<{ plan: AttachPlan; monitors: KumaMonitor[] }> {
|
|
196
|
+
const cfg = loadConfig()
|
|
197
|
+
const monitors = (await withKuma(cfg.uptimeKuma!, (k) => k.getMonitors())).result
|
|
198
|
+
const sites = await new SpinupWPClient(cfg).listSites()
|
|
199
|
+
const domains = sites.flatMap((s) => [s.domain, ...(s.additional_domains ?? []).map((d) => d.domain)])
|
|
200
|
+
return { plan: planAttach(monitors, domains, cfg.kumaMonitors, sites.map((s) => s.domain)), monitors }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export async function saveAttach(plan: AttachPlan, monitors: KumaMonitor[], includeLikely: boolean): Promise<number> {
|
|
204
|
+
const matches = [...plan.exact, ...(includeLikely ? plan.likely : [])]
|
|
205
|
+
await saveConfig({ kumaMonitors: applyAttach(loadConfig().kumaMonitors, matches, monitors) })
|
|
206
|
+
return matches.length
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function attach(p: Parsed, json: boolean): Promise<number> {
|
|
210
|
+
if (!loadConfig().uptimeKuma) return fail(json, "kuma_not_configured", "No Uptime Kuma connection configured in SpinupTUI.")
|
|
211
|
+
const includeLikely = p.flags.has("--include-likely")
|
|
212
|
+
let loaded: Awaited<ReturnType<typeof loadAttachPlan>>
|
|
213
|
+
try {
|
|
214
|
+
loaded = await loadAttachPlan()
|
|
215
|
+
} catch (err) {
|
|
216
|
+
return fail(json, "kuma_error", (err as Error).message)
|
|
217
|
+
}
|
|
218
|
+
const { plan, monitors } = loaded
|
|
219
|
+
if (!p.flags.has("--apply")) {
|
|
220
|
+
out(json, { ok: true, ...plan }, attachText(plan, includeLikely))
|
|
221
|
+
return 0
|
|
222
|
+
}
|
|
223
|
+
const attached = await saveAttach(plan, monitors, includeLikely)
|
|
224
|
+
out(json, { ok: true, attached }, `Attached ${attached} monitor(s). Nothing was changed in Kuma.`)
|
|
225
|
+
return 0
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- ticks ----------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
async function tick(dryRun: boolean) {
|
|
231
|
+
const cfg = loadConfig()
|
|
232
|
+
const client = new SpinupWPClient(cfg)
|
|
233
|
+
return sentinelTick(sentinelSettings(cfg), realDeps(cfg, client), { dryRun })
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function tickCmd(p: Parsed, json: boolean): Promise<number> {
|
|
237
|
+
const s = sentinelSettings(loadConfig())
|
|
238
|
+
const dryRun = p.flags.has("--dry-run")
|
|
239
|
+
if (!s.enabled && !dryRun) return fail(json, "not_configured", "Sentinel isn't set up.", "spinuptui sentinel setup --plan")
|
|
240
|
+
const r = await tick(dryRun)
|
|
241
|
+
console.log(JSON.stringify(r))
|
|
242
|
+
if (r.ok) return 0
|
|
243
|
+
return r.needsPerson ? BLOCKED_EXIT : 1
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function workerCmd(): Promise<number> {
|
|
247
|
+
const cfg = loadConfig()
|
|
248
|
+
const s = sentinelSettings(cfg)
|
|
249
|
+
if (!s.enabled || s.shadow) return 0
|
|
250
|
+
await runWorker(s, realWorkerDeps(cfg, s))
|
|
251
|
+
return 0
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function digestCmd(p: Parsed, json: boolean): Promise<number> {
|
|
255
|
+
const cfg = loadConfig()
|
|
256
|
+
const s = sentinelSettings(cfg)
|
|
257
|
+
const dryRun = p.flags.has("--dry-run")
|
|
258
|
+
if (!s.enabled && !dryRun) return fail(json, "not_configured", "Sentinel isn't set up.", "spinuptui sentinel setup --plan")
|
|
259
|
+
const data = await gatherDigest(s, cfg, new SpinupWPClient(cfg), { dryRun })
|
|
260
|
+
const digest = composeDigest(data, loadFlags())
|
|
261
|
+
if (dryRun) {
|
|
262
|
+
console.log(digest.text)
|
|
263
|
+
return 0
|
|
264
|
+
}
|
|
265
|
+
if (s.shadow) {
|
|
266
|
+
logShadow(`would send the digest:\n${digest.text}`)
|
|
267
|
+
return 0
|
|
268
|
+
}
|
|
269
|
+
recordDigest(digest, data)
|
|
270
|
+
const sent = await sendPage(cfg, { text: digest.text, silent: digest.silent }, s.channel)
|
|
271
|
+
if (sent === "sent") return 0
|
|
272
|
+
console.error(`The digest wasn't sent: ${sent}`)
|
|
273
|
+
return 1
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---- incidents ------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
function list(p: Parsed, json: boolean): number {
|
|
279
|
+
const open = p.flags.has("--open")
|
|
280
|
+
const rows = loadIncidents().filter((i) => !open || !i.closedAt)
|
|
281
|
+
if (json) console.log(JSON.stringify({ ok: true, incidents: rows }))
|
|
282
|
+
else if (!rows.length) console.log("No incidents.")
|
|
283
|
+
else for (const i of rows) console.log(`${i.id} ${describeIncident(i)}`)
|
|
284
|
+
return 0
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function show(pos: string[]): number {
|
|
288
|
+
const i = pos[0] ? loadIncident(pos[0]) : null
|
|
289
|
+
if (!i) return fail(false, "unknown_incident", `No incident "${pos[0] ?? ""}".`)
|
|
290
|
+
const { evidence, ...rest } = i
|
|
291
|
+
console.log(JSON.stringify(rest, null, 2))
|
|
292
|
+
if (evidence) console.log(`\n${evidence}`)
|
|
293
|
+
return 0
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function diagnose(pos: string[], json: boolean): number {
|
|
297
|
+
const i = pos[0] ? loadIncident(pos[0]) : null
|
|
298
|
+
if (!i) return fail(json, "unknown_incident", `No incident "${pos[0] ?? ""}".`)
|
|
299
|
+
saveIncident({ ...i, state: "queued", attempts: 0, why: undefined })
|
|
300
|
+
out(json, { ok: true, id: i.id }, `Queued ${i.id}. The next tick starts it.`)
|
|
301
|
+
return 0
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function post(pos: string[], p: Parsed, json: boolean): Promise<number> {
|
|
305
|
+
const cfg = loadConfig()
|
|
306
|
+
const s = sentinelSettings(cfg)
|
|
307
|
+
const i = pos[0] ? loadIncident(pos[0]) : null
|
|
308
|
+
if (!i) return fail(json, "unknown_incident", `No incident "${pos[0] ?? ""}".`)
|
|
309
|
+
if (!i.reportPath) return fail(json, "no_report", `${i.id} has no report yet.`)
|
|
310
|
+
if (i.post === "posted") return fail(json, "already_posted", `Already posted: ${i.postUrl}`)
|
|
311
|
+
const board = s.reportTo ? cfg.beacon?.boards?.[s.reportTo] : undefined
|
|
312
|
+
if (!board) return fail(json, "no_board", "No board is configured.", "spinuptui sentinel setup --report-to <board> --apply")
|
|
313
|
+
saveIncident({ ...i, post: "posting" })
|
|
314
|
+
const r = await postToBoard(board, readFileSync(join(s.workspace, i.reportPath), "utf8"))
|
|
315
|
+
if (!r.ok) {
|
|
316
|
+
saveIncident({ ...i, post: "failed", postError: r.message })
|
|
317
|
+
return fail(json, r.reason, r.message)
|
|
318
|
+
}
|
|
319
|
+
saveIncident({ ...i, post: "posted", postId: r.id, postUrl: r.url, postError: undefined })
|
|
320
|
+
out(json, { ok: true, url: r.url }, `Posted: ${r.url}`)
|
|
321
|
+
return 0
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ---- pause and go-live -------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
const MAX_PAUSE_MS = 7 * 24 * 3_600_000
|
|
327
|
+
|
|
328
|
+
export function pauseMs(value: string): number | null {
|
|
329
|
+
const m = /^(\d+)([mhd])$/.exec(value.trim())
|
|
330
|
+
if (!m || !+m[1]!) return null
|
|
331
|
+
const ms = +m[1]! * { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "m" | "h" | "d"]
|
|
332
|
+
return ms <= MAX_PAUSE_MS ? ms : null
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function pause(p: Parsed, json: boolean): number {
|
|
336
|
+
const ms = pauseMs(p.flags.get("--for") ?? "")
|
|
337
|
+
if (ms === null) return fail(json, "usage", "--for is required, up to 7d: 30m, 2h, 3d.")
|
|
338
|
+
const until = new Date(Date.now() + ms)
|
|
339
|
+
savePause({ until: until.toISOString() })
|
|
340
|
+
out(json, { ok: true, until: until.toISOString() }, `Sentinel is paused until ${until.toLocaleString()}. It resumes by itself.`)
|
|
341
|
+
return 0
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function resume(json: boolean): Promise<number> {
|
|
345
|
+
const was = loadPause()
|
|
346
|
+
clearPause()
|
|
347
|
+
out(json, { ok: true, wasPaused: !!was }, was ? `Sentinel is watching again. ${was.events ?? 0} event(s) passed during the pause.` : "Sentinel wasn't paused.")
|
|
348
|
+
return 0
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Leaves shadow mode at the exact point the old watcher stopped: everything up to
|
|
352
|
+
// `since` was its, everything after is Sentinel's. Shadow records go, and any
|
|
353
|
+
// event after that time is forgotten so the next tick treats it as new.
|
|
354
|
+
async function goLive(p: Parsed, json: boolean): Promise<number> {
|
|
355
|
+
const since = p.flags.get("--since") ?? ""
|
|
356
|
+
if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(since)) return fail(json, "usage", '--since "YYYY-MM-DD HH:MM:SS" is required, in UTC: the old watcher\'s lastEventTime.')
|
|
357
|
+
const cfg = loadConfig()
|
|
358
|
+
if (!cfg.sentinel?.enabled) return fail(json, "not_configured", "Sentinel isn't set up.", "spinuptui sentinel setup --plan")
|
|
359
|
+
if (cfg.sentinel.mode !== "shadow") return fail(json, "not_shadow", "Sentinel is already live; go-live only leaves shadow mode.")
|
|
360
|
+
const cutoff = eventMs(since)
|
|
361
|
+
for (const i of loadIncidents()) if (i.state === "shadow" || i.refersTo) deleteIncident(i.id)
|
|
362
|
+
const seen: Seen = {}
|
|
363
|
+
for (const [k, t] of Object.entries(loadSeen())) if (eventMs(t) <= cutoff) seen[k] = t
|
|
364
|
+
saveSeen(seen)
|
|
365
|
+
const ts = loadTickState()
|
|
366
|
+
saveTickState({ ...ts, started: true, lastCleanAt: new Date(cutoff).toISOString() })
|
|
367
|
+
await saveConfig({ sentinel: { ...cfg.sentinel, mode: "live" } })
|
|
368
|
+
out(json, { ok: true, since }, `Sentinel is live. Events after ${since} UTC are its to handle.`)
|
|
369
|
+
return 0
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ---- expectations ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
function buildExpectation(id: string, f: Map<string, string>): Expectation {
|
|
375
|
+
const base = {
|
|
376
|
+
id,
|
|
377
|
+
...(f.get("--domain") ? { domain: f.get("--domain") } : {}),
|
|
378
|
+
...(f.get("--server") ? { server: f.get("--server") } : {}),
|
|
379
|
+
...(f.get("--label") ? { label: f.get("--label") } : {}),
|
|
380
|
+
...(f.get("--note") ? { note: f.get("--note") } : {}),
|
|
381
|
+
...(f.get("--expires") ? { expires: f.get("--expires") } : {}),
|
|
382
|
+
}
|
|
383
|
+
const n = (name: string) => (f.has(name) ? Number(f.get(name)) : undefined)
|
|
384
|
+
switch (f.get("--type")) {
|
|
385
|
+
case "log-count":
|
|
386
|
+
return { ...base, type: "log-count", match: f.get("--match") ?? "", ...(f.has("--min") ? { min: n("--min") } : {}), ...(f.has("--trend") ? { trend: true } : {}) } as Expectation
|
|
387
|
+
case "load-shape":
|
|
388
|
+
return { ...base, type: "load-shape", threshold: n("--threshold") ?? 0 } as Expectation
|
|
389
|
+
case "cache-hit":
|
|
390
|
+
return { ...base, type: "cache-hit", host: f.get("--host") ?? "", paths: (f.get("--paths") ?? "").split(",").filter(Boolean) } as Expectation
|
|
391
|
+
case "warmer":
|
|
392
|
+
return { ...base, type: "warmer", logGlob: f.get("--log-glob") ?? "" } as Expectation
|
|
393
|
+
default:
|
|
394
|
+
return { ...base, type: f.get("--type") ?? "", url: f.get("--url") ?? "", expect: n("--expect") ?? 0 } as Expectation
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function expect(pos: string[], p: Parsed, json: boolean): Promise<number> {
|
|
399
|
+
const [action, id] = pos
|
|
400
|
+
const all = loadExpectations()
|
|
401
|
+
switch (action) {
|
|
402
|
+
case "list":
|
|
403
|
+
if (json) console.log(JSON.stringify({ ok: true, expectations: all }))
|
|
404
|
+
else if (!all.length) console.log("No expectations yet.")
|
|
405
|
+
else for (const e of all) console.log(`${e.id} ${e.type} ${e.domain ?? ("url" in e ? e.url : "")}${e.expires ? ` expires ${e.expires}` : ""}${e.label ? ` (${e.label})` : ""}`)
|
|
406
|
+
return 0
|
|
407
|
+
case "add": {
|
|
408
|
+
if (!id) return fail(json, "usage", USAGE)
|
|
409
|
+
const e = buildExpectation(id, p.flags)
|
|
410
|
+
const problems = validateExpectation(e)
|
|
411
|
+
if (all.some((x) => x.id === id)) problems.push(`there's already an expectation named ${id}`)
|
|
412
|
+
if (problems.length) return fail(json, "invalid", problems.join("\n"))
|
|
413
|
+
saveExpectations([...all, e])
|
|
414
|
+
out(json, { ok: true, id }, `Added ${id}. It runs with the next digest; to try it now: spinuptui sentinel expect check ${id}. Saved in ${moduleWorkDir("sentinel")}.`)
|
|
415
|
+
return 0
|
|
416
|
+
}
|
|
417
|
+
case "remove":
|
|
418
|
+
if (!all.some((x) => x.id === id)) return fail(json, "unknown_expectation", `No expectation "${id ?? ""}".`)
|
|
419
|
+
saveExpectations(all.filter((x) => x.id !== id))
|
|
420
|
+
out(json, { ok: true, id }, `Removed ${id}.`)
|
|
421
|
+
return 0
|
|
422
|
+
case "check": {
|
|
423
|
+
const e = all.find((x) => x.id === id)
|
|
424
|
+
if (!e) return fail(json, "unknown_expectation", `No expectation "${id ?? ""}".`)
|
|
425
|
+
const cfg = loadConfig()
|
|
426
|
+
const r = await runExpectation(e, expectDeps(cfg, new SpinupWPClient(cfg)))
|
|
427
|
+
out(json, { ok: true, ...r }, `${r.label}: ${r.state} — ${r.text}`)
|
|
428
|
+
return 0
|
|
429
|
+
}
|
|
430
|
+
case "ack": {
|
|
431
|
+
const flags = loadFlags()
|
|
432
|
+
if (!id || !flags[id]) return fail(json, "unknown_flag", `No flag is set for "${id ?? ""}".`)
|
|
433
|
+
saveFlags({ ...flags, [id]: { ...flags[id]!, acked: true } })
|
|
434
|
+
out(json, { ok: true, id }, `Acknowledged ${id}. It stays counted in the digest until it clears.`)
|
|
435
|
+
return 0
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return fail(json, "usage", USAGE)
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function status(json: boolean): Promise<number> {
|
|
442
|
+
const s = await sentinelModule.status()
|
|
443
|
+
const newest = Object.values(loadSeen()).sort().pop() ?? null
|
|
444
|
+
if (json) console.log(JSON.stringify({ ok: true, lastEventTime: newest, ...s }))
|
|
445
|
+
else {
|
|
446
|
+
console.log(`Sentinel: ${s.state} — ${s.summary}`)
|
|
447
|
+
for (const c of s.checks) console.log(` ${c.ok ? "✓" : "✗"} ${c.name}: ${c.detail}`)
|
|
448
|
+
if (s.remedy) console.log(` → ${s.remedy}`)
|
|
449
|
+
}
|
|
450
|
+
return 0
|
|
451
|
+
}
|
package/src/lib/heartbeat.ts
CHANGED
|
@@ -80,6 +80,11 @@ export function tickHeartbeat(ranCount: number, errors: string[], pager: string
|
|
|
80
80
|
return { status: "up", msg: errors.length ? `tick ok, ${what}; ${errors.length} problem${errors.length === 1 ? "" : "s"}: ${errors[0]}` : `tick ok, ${what}` }
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// What a tick that found the previous one still running tells Kuma.
|
|
84
|
+
export function stillRunningHeartbeat(pager: string | null): { status: "up" | "down"; msg: string } {
|
|
85
|
+
return pager ? { status: "down", msg: `Station is up, but ${pager}` } : { status: "up", msg: "tick still running" }
|
|
86
|
+
}
|
|
87
|
+
|
|
83
88
|
// ---- setup through SpinupTUI's own Kuma login --------------------------------------
|
|
84
89
|
//
|
|
85
90
|
// With a Kuma login saved (the one site monitoring uses), SpinupTUI creates the
|
package/src/lib/schedule.ts
CHANGED
|
@@ -80,6 +80,7 @@ export function declSchedule(decl: { every?: string; at?: string }): Schedule {
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export interface ScheduleState {
|
|
83
|
+
last_status?: string
|
|
83
84
|
last_run?: string
|
|
84
85
|
last_scheduled?: string
|
|
85
86
|
retry_after?: string
|
|
@@ -143,7 +144,9 @@ export function nextRunText(job: DueInput, state: ScheduleState, now: Date): str
|
|
|
143
144
|
if (job.expires && dayStart(now) > parseDate(job.expires).getTime()) return "expired"
|
|
144
145
|
if (job.when) {
|
|
145
146
|
const when = parseWhen(job.when)
|
|
146
|
-
|
|
147
|
+
if (state.last_scheduled !== fmt(when)) return fmt(when)
|
|
148
|
+
// It ran but couldn't do its work, so it stays visibly unfinished.
|
|
149
|
+
return state.last_status === "blocked" ? "blocked" : "done"
|
|
147
150
|
}
|
|
148
151
|
const sched = job.schedule ?? parseEvery(job.every!)
|
|
149
152
|
if (sched.type === "interval") return `every ${sched.minutes} min`
|