spinuptui 1.3.0-alpha.2 → 1.3.0-alpha.3
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 +20 -1
- package/README.md +10 -5
- package/package.json +1 -1
- package/src/config.ts +3 -0
- package/src/index.tsx +9 -0
- package/src/lib/cliHome.ts +107 -0
- package/src/lib/cliScheduler.ts +99 -3
- package/src/lib/heartbeat.test.ts +86 -0
- package/src/lib/heartbeat.ts +245 -0
- package/src/lib/home.test.ts +102 -0
- package/src/lib/home.ts +442 -0
- package/src/lib/schedulerJobs.ts +2 -0
- package/src/lib/schedulerRun.ts +3 -2
- package/src/lib/schedulerTick.ts +17 -2
- package/src/lib/spinupweb.test.ts +22 -0
- package/src/lib/spinupweb.ts +11 -0
- package/src/lib/stackCache.test.ts +43 -0
- package/src/lib/stackCache.ts +35 -1
- package/src/modules/scheduler.ts +13 -0
- package/src/ui/App.tsx +15 -1
- package/src/ui/Details.tsx +7 -1
- package/src/ui/Onboarding.tsx +71 -3
- package/src/ui/store.tsx +81 -3
- package/src/ui/views/AccountSlugPrompt.tsx +102 -0
- package/src/ui/views/Browser.tsx +5 -5
- package/src/ui/views/HeartbeatSetup.tsx +284 -0
- package/src/ui/views/HomeSync.tsx +299 -0
- package/src/ui/views/Modules.tsx +34 -6
- package/src/ui/views/Search.tsx +5 -6
- package/src/ui/views/ServerActions.tsx +6 -4
- package/src/ui/views/Stacks.tsx +22 -9
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,23 @@ version; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.3.0-alpha.3] - 2026-10-27
|
|
15
|
+
|
|
16
|
+
A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew are on 1.2.1.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **Station heartbeat: find out when the station itself stops.** If the machine running Scheduler is off, the installed `spinuptui` is broken, or its LaunchAgent is gone, nothing on that machine can page you. Now every dispatcher tick pings an Uptime Kuma push monitor that you run somewhere else (a small VPS is easiest), and when the pings stop, Kuma alerts you through its own notifications. If SpinupTUI has your Kuma login (from site monitoring), `spinuptui scheduler heartbeat setup --plan|--apply` creates the monitor for you; otherwise make a push monitor by hand and run `spinuptui scheduler heartbeat set '<push URL>'`. Both ping once before saving. In the app, `h` on the Modules tab's Scheduler card walks through the same setup and later shows the last ping, sends a test ping, or removes it. The ping reports **down** when the station is running but can't page you, because Beacon isn't set up or its last page failed, so a broken pager reaches you too. `scheduler status` and the Modules tab show the last ping. See [docs/scheduler.md](docs/scheduler.md#know-when-the-station-itself-stops).
|
|
20
|
+
- **Your SpinupTUI folder explains itself, and can be shared across machines in one step.** `~/SpinupTUI/`, where Scheduler keeps your jobs and reports, now gets a `README.md` when it's created, covering what each part is, which parts are yours to edit, and how to move or share it. `spinuptui home` shows where it is and whether it's shared. `spinuptui home sync --plan|--apply` shares it through a private git repository: it creates one on GitHub (`--github <name>`, using the GitHub CLI) or pushes to an empty one you made (`--remote <url>`). Then you can queue jobs from any machine and read the reports anywhere. On a second machine, `--clone <url>` sets up the same folder. It won't share a folder inside iCloud, Dropbox or another sync service, push to a repository that already has commits, or use a public GitHub repository. The Modules tab shows where jobs and reports land, and `f` on Scheduler walks through the same steps. Queuing a job now prints the full path of the job file and where its reports will go.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- **Scheduler no longer reports a problem on every tick before you've queued anything locally.** `~/SpinupTUI/scheduler/` is created on first use, so its absence is normal; `scheduler status` used to list "source local … does not exist" as a problem from the last tick.
|
|
24
|
+
|
|
25
|
+
## [1.2.1] - 2026-10-27
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
- **WordPress versions no longer go stale.** The version shown for a site was read once, when the site was first identified, and never again, so sites updated outside SpinupTUI (auto-updates, wp-admin, deploys) kept showing the old version, and `r` didn't help. Versions older than a day are now re-checked in the background when the site list loads, one site at a time per server, stopping at a server's first failure so unattended SSH can't trip fail2ban. A reading that couldn't be refreshed shows its age in the Stacks list, and the Details pane always says when it was taken.
|
|
29
|
+
- **`w` opens the site or server you picked, not the SpinupWP dashboard.** Deep links need the account slug, which SpinupWP's API doesn't provide and first-run setup never asked for, so unless you had added `accountSlug` to `config.json` by hand, `w` quietly fell back to the dashboard. Now `w` asks for the slug the first time it needs it (pasting any dashboard URL works), saves it to the current account and opens the right page. First-run setup also asks for it, as an optional step after the token.
|
|
30
|
+
|
|
14
31
|
## [1.3.0-alpha.2] - 2026-10-26
|
|
15
32
|
|
|
16
33
|
A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew stay on 1.2.0.
|
|
@@ -1517,7 +1534,9 @@ Initial tagged release.
|
|
|
1517
1534
|
### Notes
|
|
1518
1535
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1519
1536
|
|
|
1520
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.
|
|
1537
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.3...HEAD
|
|
1538
|
+
[1.3.0-alpha.3]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.2...v1.3.0-alpha.3
|
|
1539
|
+
[1.2.1]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...v1.2.1
|
|
1521
1540
|
[1.3.0-alpha.2]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.1...v1.3.0-alpha.2
|
|
1522
1541
|
[1.3.0-alpha.1]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...v1.3.0-alpha.1
|
|
1523
1542
|
[1.2.0]: https://github.com/mwender/spinupwp-tui/compare/v1.1.2...v1.2.0
|
package/README.md
CHANGED
|
@@ -84,9 +84,11 @@
|
|
|
84
84
|
bot connection for you. → [docs/beacon.md](docs/beacon.md)
|
|
85
85
|
Then **Scheduler**: follow-ups that run themselves. Queue "check this
|
|
86
86
|
tomorrow at 8" from any session, and the result arrives through Beacon. Jobs
|
|
87
|
-
and reports live in `~/SpinupTUI/scheduler/`;
|
|
88
|
-
|
|
89
|
-
5 minutes and is the clock the other
|
|
87
|
+
and reports live in `~/SpinupTUI/scheduler/`; `spinuptui home sync` (or `f`)
|
|
88
|
+
shares that folder through a private git repository so you can queue from
|
|
89
|
+
any machine. One LaunchAgent ticks every 5 minutes and is the clock the other
|
|
90
|
+
modules run on, and a station heartbeat (`h`) pings your Uptime Kuma each
|
|
91
|
+
tick, so a station that stops is noticed from elsewhere. → [docs/scheduler.md](docs/scheduler.md)
|
|
90
92
|
- **Non-interactive CLI subcommands for external tooling** — `spinuptui ssh <domain>`
|
|
91
93
|
resolves a domain to a live SSH target with a live connectivity probe;
|
|
92
94
|
`spinuptui ssh-exec <domain> -- <command>` runs a command over SSH, but only
|
|
@@ -315,8 +317,9 @@ These can be set in `config.json` or via an environment variable:
|
|
|
315
317
|
- **`accountSlug`** / `SPINUPWP_ACCOUNT_SLUG` — your SpinupWP account/team slug
|
|
316
318
|
(the first path segment in a SpinupWP URL, e.g. `acme-agency` in
|
|
317
319
|
`https://spinupwp.app/acme-agency/servers/12345`). The API
|
|
318
|
-
doesn't expose it, so
|
|
319
|
-
|
|
320
|
+
doesn't expose it, so it's needed for the `w` deep links into the web app.
|
|
321
|
+
You don't have to set it by hand: first-run setup asks for it (optional), and
|
|
322
|
+
`w` asks the first time it's needed, then saves it to the current account.
|
|
320
323
|
- **`sshUser`** / `SPINUPWP_SSH_USER` — override the SSH user for the health view
|
|
321
324
|
and stack probes (see "Server health" below).
|
|
322
325
|
- **`localSync`** / `SPINUPWP_LOCAL_SYNC` — opt-in for the **Pull production →
|
|
@@ -382,6 +385,8 @@ These can be set in `config.json` or via an environment variable:
|
|
|
382
385
|
| `A` | Accounts — switch, add, rename, or remove SpinupWP accounts (from anywhere) |
|
|
383
386
|
| `⏎` / `s` | Set up the selected module — Beacon opens its guided Telegram setup, Scheduler its install plan (Modules tab) |
|
|
384
387
|
| `t` | Send a test page through Beacon (Modules tab, once Beacon is set up) |
|
|
388
|
+
| `f` | Your SpinupTUI folder: where jobs and reports live, and sharing it across machines (Modules tab, Scheduler) |
|
|
389
|
+
| `h` | Station heartbeat: set up the Uptime Kuma push monitor, see the last ping, test or remove it (Modules tab, Scheduler) |
|
|
385
390
|
| `r` | Refresh data from the API |
|
|
386
391
|
| `i` | Explain the current screen (what each pane and key does) |
|
|
387
392
|
| `?` | Toggle the help overlay |
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -142,6 +142,9 @@ export interface SchedulerStored {
|
|
|
142
142
|
queueOnly?: string[]
|
|
143
143
|
// Written by `scheduler install --apply`, cleared by uninstall.
|
|
144
144
|
dispatcher?: { label: string; unitPath: string; bin: string; installedAt: string }
|
|
145
|
+
// The station heartbeat: an Uptime Kuma push monitor, pinged at the end of
|
|
146
|
+
// every dispatcher tick (lib/heartbeat.ts). Set by `scheduler heartbeat set`.
|
|
147
|
+
heartbeat?: { url: string; setAt: string }
|
|
145
148
|
}
|
|
146
149
|
|
|
147
150
|
export interface ServerProviderRef {
|
package/src/index.tsx
CHANGED
|
@@ -112,6 +112,10 @@ Usage:
|
|
|
112
112
|
Queue a follow-up for this station to run later and report
|
|
113
113
|
on through Beacon (\`spinuptui scheduler\` for the rest)
|
|
114
114
|
spinuptui scheduler list | status | install --plan|--apply
|
|
115
|
+
spinuptui home Where the SpinupTUI folder (your jobs and reports) is
|
|
116
|
+
spinuptui home sync --plan|--apply --github <name> | --remote <url> | --clone <url>
|
|
117
|
+
Share that folder across machines through a private git
|
|
118
|
+
repository, or set it up from one on a second machine
|
|
115
119
|
spinuptui modules List SpinupTUI's modules and their status (--json)
|
|
116
120
|
spinuptui --version Print the version
|
|
117
121
|
spinuptui --help Show this help
|
|
@@ -312,6 +316,11 @@ if (command === "modules") {
|
|
|
312
316
|
process.exit(await runModules(args.slice(1)))
|
|
313
317
|
}
|
|
314
318
|
|
|
319
|
+
if (command === "home") {
|
|
320
|
+
const { runHome } = await import("./lib/cliHome.ts")
|
|
321
|
+
process.exit(await runHome(args.slice(args.indexOf("home") + 1)))
|
|
322
|
+
}
|
|
323
|
+
|
|
315
324
|
if (command === "scheduler") {
|
|
316
325
|
process.exit(await runScheduler(args.slice(args.indexOf("scheduler") + 1)))
|
|
317
326
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// `spinuptui home`: where the SpinupTUI folder is and whether it's shared, and
|
|
2
|
+
// `home sync`, which shares it across machines (lib/home.ts). Headless like the
|
|
3
|
+
// module commands: --json prints one object on stdout.
|
|
4
|
+
|
|
5
|
+
import { applySync, describeShare, ensureHome, homeStatus, planSync, tilde, type SyncOptions } from "./home.ts"
|
|
6
|
+
|
|
7
|
+
const USAGE = `Usage:
|
|
8
|
+
spinuptui home [--json] where the SpinupTUI folder is, and whether it's shared
|
|
9
|
+
spinuptui home sync --plan | --apply [--json]
|
|
10
|
+
--github <name> create a private GitHub repository for it (needs gh)
|
|
11
|
+
--remote <url> or push it to an empty private repository you made
|
|
12
|
+
--clone <url> on another machine: set the same folder up here
|
|
13
|
+
|
|
14
|
+
The SpinupTUI folder (~/SpinupTUI) holds your Scheduler jobs and reports and,
|
|
15
|
+
later, other modules' work. Shared through a private git repository, you can
|
|
16
|
+
queue jobs from any machine and read the reports anywhere.`
|
|
17
|
+
|
|
18
|
+
const VALUE_FLAGS = new Set(["--github", "--remote", "--clone"])
|
|
19
|
+
|
|
20
|
+
function fail(json: boolean, reason: string, message: string, remedy?: string): number {
|
|
21
|
+
if (json) console.log(JSON.stringify({ ok: false, reason, message, ...(remedy ? { remedy } : {}) }))
|
|
22
|
+
else {
|
|
23
|
+
console.error(message)
|
|
24
|
+
if (remedy) console.error(remedy)
|
|
25
|
+
}
|
|
26
|
+
return 1
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function runHome(argv: string[]): Promise<number> {
|
|
30
|
+
const flags = new Map<string, string>()
|
|
31
|
+
const pos: string[] = []
|
|
32
|
+
for (let i = 0; i < argv.length; i++) {
|
|
33
|
+
const a = argv[i]!
|
|
34
|
+
if (VALUE_FLAGS.has(a)) flags.set(a, argv[++i] ?? "")
|
|
35
|
+
else if (a.startsWith("-")) flags.set(a, "")
|
|
36
|
+
else pos.push(a)
|
|
37
|
+
}
|
|
38
|
+
const json = flags.has("--json")
|
|
39
|
+
if (flags.has("--help") || flags.has("-h")) {
|
|
40
|
+
console.log(USAGE)
|
|
41
|
+
return 0
|
|
42
|
+
}
|
|
43
|
+
if (pos.length === 0) return status(json)
|
|
44
|
+
if (pos[0] === "sync") return sync(flags, json)
|
|
45
|
+
return fail(json, "usage", USAGE)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function status(json: boolean): Promise<number> {
|
|
49
|
+
const s = await homeStatus()
|
|
50
|
+
if (json) {
|
|
51
|
+
console.log(JSON.stringify({ ok: true, ...s }))
|
|
52
|
+
return 0
|
|
53
|
+
}
|
|
54
|
+
console.log(`SpinupTUI folder: ${tilde(s.path)}${s.exists ? "" : " (created the first time a module writes to it)"}`)
|
|
55
|
+
console.log(` ${s.git && s.remote ? "✓" : "·"} ${describeShare(s)}`)
|
|
56
|
+
if (s.station) console.log(` · its jobs run on ${s.station === s.thisMachine ? "this machine" : s.station}`)
|
|
57
|
+
if (s.syncedBy) console.log(` ✗ inside ${s.syncedBy}: ${s.git ? "two programs syncing the same .git can corrupt it. Move it (homeDir in config.json)." : "fine on its own, but move it before sharing it with git."}`)
|
|
58
|
+
if (!s.git && !s.insideRepo) console.log(`\nTo queue jobs from other machines: spinuptui home sync --plan`)
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function sync(flags: Map<string, string>, json: boolean): Promise<number> {
|
|
63
|
+
const apply = flags.has("--apply")
|
|
64
|
+
if (apply === flags.has("--plan")) return fail(json, "usage", "Pass --plan to see what would happen, or --apply to do it.\n\n" + USAGE)
|
|
65
|
+
const opts: SyncOptions = {}
|
|
66
|
+
for (const [flag, key] of [
|
|
67
|
+
["--github", "github"],
|
|
68
|
+
["--remote", "remote"],
|
|
69
|
+
["--clone", "clone"],
|
|
70
|
+
] as const) {
|
|
71
|
+
if (!flags.has(flag)) continue
|
|
72
|
+
if (!flags.get(flag)) return fail(json, "usage", `${flag} needs a value.`)
|
|
73
|
+
opts[key] = flags.get(flag)
|
|
74
|
+
}
|
|
75
|
+
const plan = await planSync(opts)
|
|
76
|
+
if (plan.blockers.length) {
|
|
77
|
+
const b = plan.blockers[0]!
|
|
78
|
+
if (json) console.log(JSON.stringify({ ok: false, reason: b.reason, message: b.message, ...(b.remedy ? { remedy: b.remedy } : {}), blockers: plan.blockers }))
|
|
79
|
+
else for (const x of plan.blockers) fail(false, x.reason, x.message, x.remedy)
|
|
80
|
+
return 1
|
|
81
|
+
}
|
|
82
|
+
const steps = plan.steps.map((s) => s.label)
|
|
83
|
+
if (!apply) {
|
|
84
|
+
if (json) console.log(JSON.stringify({ ok: true, applied: false, mode: plan.mode, home: plan.home, steps, notes: plan.notes }))
|
|
85
|
+
else if (!steps.length) console.log(`${tilde(plan.home)} is already shared and up to date. Nothing to do.`)
|
|
86
|
+
else {
|
|
87
|
+
console.log(`Plan for ${tilde(plan.home)}:`)
|
|
88
|
+
steps.forEach((s, i) => console.log(` ${i + 1}. ${s}`))
|
|
89
|
+
for (const n of plan.notes) console.log(`\n${n}`)
|
|
90
|
+
console.log(`\nRun with --apply to do it.`)
|
|
91
|
+
}
|
|
92
|
+
return 0
|
|
93
|
+
}
|
|
94
|
+
const r = await applySync(plan)
|
|
95
|
+
if (json) console.log(JSON.stringify({ ok: r.ok, applied: true, mode: plan.mode, home: plan.home, done: r.done, ...(r.failed ? { failed: r.failed } : {}) }))
|
|
96
|
+
else {
|
|
97
|
+
for (const d of r.done) console.log(`✓ ${d}`)
|
|
98
|
+
if (r.failed) console.error(`✗ ${r.failed.step}: ${r.failed.message}\nSteps above it are done; fix this and run it again, and it picks up where it stopped.`)
|
|
99
|
+
else if (steps.length) {
|
|
100
|
+
// A clone brings the other machine's README; make sure there is one.
|
|
101
|
+
ensureHome()
|
|
102
|
+
const s = await homeStatus()
|
|
103
|
+
console.log(`\n${tilde(s.path)} is ${describeShare(s)}.`)
|
|
104
|
+
} else console.log(`${tilde(plan.home)} is already shared and up to date. Nothing to do.`)
|
|
105
|
+
}
|
|
106
|
+
return r.ok ? 0 : 1
|
|
107
|
+
}
|
package/src/lib/cliScheduler.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// result object on stdout, progress goes to stderr, failures use the
|
|
4
4
|
// { ok: false, reason, message, remedy? } shape and exit 1.
|
|
5
5
|
|
|
6
|
-
import { existsSync,
|
|
6
|
+
import { existsSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs"
|
|
7
7
|
import { join, relative, resolve } from "node:path"
|
|
8
8
|
import { loadConfig, saveConfig } from "../config.ts"
|
|
9
9
|
import { schedulerModule } from "../modules/scheduler.ts"
|
|
@@ -21,12 +21,16 @@ import {
|
|
|
21
21
|
loadState,
|
|
22
22
|
LOCAL_SOURCE,
|
|
23
23
|
parseJobText,
|
|
24
|
+
readLastTick,
|
|
24
25
|
renderJob,
|
|
26
|
+
runsDir,
|
|
25
27
|
validateJob,
|
|
26
28
|
type Source,
|
|
27
29
|
} from "./schedulerJobs.ts"
|
|
28
30
|
import { applyInstall, applyUninstall, binHasScheduler, NO_SCHEDULER, planInstall, planUninstall } from "./schedulerInstall.ts"
|
|
29
31
|
import { tick } from "./schedulerTick.ts"
|
|
32
|
+
import { prepareWorkDir, tilde } from "./home.ts"
|
|
33
|
+
import { applyStationPlan, connectHeartbeat, loadStationPlan, pagerProblem, removeHeartbeat, sendHeartbeat, stationPlanText, tickHeartbeat } from "./heartbeat.ts"
|
|
30
34
|
import { git, gitPull, readClaim, removeClaim, stationName, writeClaim } from "./schedulerGit.ts"
|
|
31
35
|
|
|
32
36
|
const VALUE_FLAGS = new Set([
|
|
@@ -80,6 +84,9 @@ const USAGE = `Usage:
|
|
|
80
84
|
spinuptui scheduler source release <name> stop running it here; another station takes it
|
|
81
85
|
spinuptui scheduler tick [--here]
|
|
82
86
|
spinuptui scheduler install | uninstall --plan | --apply [--bin <path>]
|
|
87
|
+
spinuptui scheduler heartbeat [setup --plan|--apply [--notify <name>] | set '<kuma-push-url>' | test | remove]
|
|
88
|
+
ping an Uptime Kuma push monitor on another machine every
|
|
89
|
+
tick, so a station that stops (or can't page) is noticed
|
|
83
90
|
|
|
84
91
|
Schedules: --every 30m | 6h | "daily 08:00" | "weekdays 08:00" | "mon,thu 08:00" (station local time).
|
|
85
92
|
Add options: --workdir <dir> --timeout <min> --notify beacon|none --to <channel>
|
|
@@ -161,6 +168,8 @@ export async function runScheduler(argv: string[]): Promise<number> {
|
|
|
161
168
|
return doTick(p, json)
|
|
162
169
|
case "install":
|
|
163
170
|
return install(p, json)
|
|
171
|
+
case "heartbeat":
|
|
172
|
+
return heartbeat(p.positionals.slice(1), p.flags, json)
|
|
164
173
|
case "uninstall":
|
|
165
174
|
return uninstall(p, json)
|
|
166
175
|
}
|
|
@@ -295,7 +304,7 @@ async function add(pos: string[], p: Parsed, json: boolean): Promise<number> {
|
|
|
295
304
|
const problems = validateJob(parsed.header, parsed.body, `${id}.md`)
|
|
296
305
|
if (problems.length) return fail(json, "invalid_job", `Not added: ${problems.join("; ")}`)
|
|
297
306
|
|
|
298
|
-
|
|
307
|
+
prepareWorkDir(jobsDir(s))
|
|
299
308
|
writeFileSync(`${path}.tmp`, text)
|
|
300
309
|
renameSync(`${path}.tmp`, path)
|
|
301
310
|
let pushed: boolean | null = null
|
|
@@ -308,7 +317,7 @@ async function add(pos: string[], p: Parsed, json: boolean): Promise<number> {
|
|
|
308
317
|
}
|
|
309
318
|
const result = { ok: true, source: s.name, id, path, pushed }
|
|
310
319
|
if (json) console.log(JSON.stringify(result))
|
|
311
|
-
else console.log(`Queued ${
|
|
320
|
+
else console.log(`Queued ${tilde(path)}${pushed ? " and pushed it" : ""}. The station runs it within 5 minutes of it falling due; reports go in ${tilde(join(runsDir(s), id))}/.`)
|
|
312
321
|
return 0
|
|
313
322
|
}
|
|
314
323
|
|
|
@@ -532,3 +541,90 @@ async function uninstall(p: Parsed, json: boolean): Promise<number> {
|
|
|
532
541
|
else console.log(`Removed ${plan.label}. Jobs and reports are still in place; \`install --apply\` brings the dispatcher back.`)
|
|
533
542
|
return 0
|
|
534
543
|
}
|
|
544
|
+
|
|
545
|
+
// ---- the station heartbeat ------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
async function heartbeat(pos: string[], flags: Map<string, string>, json: boolean): Promise<number> {
|
|
548
|
+
const [action, arg] = pos
|
|
549
|
+
const cfg = loadConfig()
|
|
550
|
+
const stored = cfg.scheduler ?? {}
|
|
551
|
+
const current = stored.heartbeat
|
|
552
|
+
const host = stationName()
|
|
553
|
+
if (!action) {
|
|
554
|
+
const last = readLastTick()?.heartbeat ?? null
|
|
555
|
+
if (json) console.log(JSON.stringify({ ok: true, set: !!current, ...(current ? { host: new URL(current.url).host, setAt: current.setAt } : {}), last }))
|
|
556
|
+
else if (!current) {
|
|
557
|
+
console.log("No heartbeat set. Nothing outside this machine notices if it stops ticking.")
|
|
558
|
+
if (cfg.uptimeKuma) console.log(`Set it up in your Uptime Kuma (${cfg.uptimeKuma.url}): spinuptui scheduler heartbeat setup --plan`)
|
|
559
|
+
else {
|
|
560
|
+
console.log("Make a Push monitor in Uptime Kuma (on another machine; heartbeat interval 900s), then:")
|
|
561
|
+
console.log(" spinuptui scheduler heartbeat set '<its push URL>'")
|
|
562
|
+
}
|
|
563
|
+
} else {
|
|
564
|
+
console.log(`Heartbeat: pings ${new URL(current.url).host} at the end of every dispatcher tick (set ${current.setAt.slice(0, 10)}).`)
|
|
565
|
+
console.log(last ? `Last ping: ${last.ok ? "accepted" : `failed (${last.error})`}, status ${last.status}.` : "No dispatcher tick has pinged it yet.")
|
|
566
|
+
const pager = pagerProblem(cfg)
|
|
567
|
+
if (pager) console.log(`Pings say "down" right now: ${pager}.`)
|
|
568
|
+
}
|
|
569
|
+
return 0
|
|
570
|
+
}
|
|
571
|
+
if (action === "setup") return heartbeatSetup(flags, json)
|
|
572
|
+
if (action === "set") {
|
|
573
|
+
if (!arg) return fail(json, "usage", "Usage: spinuptui scheduler heartbeat set '<kuma-push-url>'")
|
|
574
|
+
const r = await connectHeartbeat(arg, host)
|
|
575
|
+
if (!r.ok) return fail(json, "ping_failed", r.message, r.remedy ? `${r.remedy} Quote the URL: it contains ? and &.` : undefined)
|
|
576
|
+
return heartbeatSaved(json, "set", r.status)
|
|
577
|
+
}
|
|
578
|
+
if (action === "test") {
|
|
579
|
+
if (!current) return fail(json, "not_set", "No heartbeat set to test.")
|
|
580
|
+
const t = tickHeartbeat(0, [], pagerProblem(cfg))
|
|
581
|
+
const r = await sendHeartbeat(current.url, t.status, `${host}: test ping (${t.msg})`)
|
|
582
|
+
if (!r.ok) return fail(json, "ping_failed", `Kuma didn't take the ping: ${r.error}`, "Check that the monitor is active and that this machine can reach Kuma.")
|
|
583
|
+
if (json) console.log(JSON.stringify({ ok: true, action, status: t.status }))
|
|
584
|
+
else console.log(`Kuma took the ping (status ${t.status}).${t.status === "down" ? ` It says "down" because ${pagerProblem(cfg)}.` : ""}`)
|
|
585
|
+
return 0
|
|
586
|
+
}
|
|
587
|
+
if (action === "remove") {
|
|
588
|
+
if (!current) return fail(json, "not_set", "No heartbeat set.")
|
|
589
|
+
await removeHeartbeat()
|
|
590
|
+
if (json) console.log(JSON.stringify({ ok: true, removed: true }))
|
|
591
|
+
else console.log("Removed. Pause or delete the monitor in Kuma too, or it will report this station as down.")
|
|
592
|
+
return 0
|
|
593
|
+
}
|
|
594
|
+
return fail(json, "usage", "Usage: spinuptui scheduler heartbeat [setup --plan|--apply | set '<kuma-push-url>' | test | remove]")
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function heartbeatSaved(json: boolean, action: string, status: "up" | "down", extra: Record<string, unknown> = {}, lead = "Saved. Kuma took the ping"): number {
|
|
598
|
+
const dispatcher = !!loadConfig().scheduler?.dispatcher
|
|
599
|
+
if (json) console.log(JSON.stringify({ ok: true, action, status, dispatcher, ...extra }))
|
|
600
|
+
else {
|
|
601
|
+
console.log(`${lead} (status ${status}).`)
|
|
602
|
+
if (status === "down") console.log(`It says "down" because ${pagerProblem()}. Fix that and the next tick reports "up".`)
|
|
603
|
+
console.log(dispatcher ? "Every dispatcher tick pings it from now on." : "This machine has no dispatcher yet, so nothing pings it until you install one: spinuptui scheduler install --plan")
|
|
604
|
+
}
|
|
605
|
+
return 0
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function heartbeatSetup(flags: Map<string, string>, json: boolean): Promise<number> {
|
|
609
|
+
const apply = flags.has("--apply")
|
|
610
|
+
if (apply === flags.has("--plan")) return fail(json, "usage", "Pass --plan to see what would be created in Kuma, or --apply to create it.")
|
|
611
|
+
const host = stationName()
|
|
612
|
+
const loaded = await loadStationPlan(host, flags.get("--notify") || undefined)
|
|
613
|
+
if (!loaded.ok) return fail(json, "kuma_failed", loaded.message, loaded.remedy)
|
|
614
|
+
const plan = loaded.plan
|
|
615
|
+
if (!plan.existing && plan.needChoice) return fail(json, "choose_notification", plan.needChoice, "e.g. spinuptui scheduler heartbeat setup --plan --notify \"<name>\"")
|
|
616
|
+
const { steps, notes } = stationPlanText(plan)
|
|
617
|
+
if (!apply) {
|
|
618
|
+
if (json) console.log(JSON.stringify({ ok: true, applied: false, kuma: plan.kumaUrl, monitor: plan.name, existing: plan.existing?.id ?? null, notifications: plan.chosen.map((n) => n.name), steps, notes }))
|
|
619
|
+
else {
|
|
620
|
+
console.log("Plan:")
|
|
621
|
+
steps.forEach((s, i) => console.log(` ${i + 1}. ${s}`))
|
|
622
|
+
for (const n of notes) console.log(`\n${n}`)
|
|
623
|
+
console.log("\nRun with --apply to do it.")
|
|
624
|
+
}
|
|
625
|
+
return 0
|
|
626
|
+
}
|
|
627
|
+
const r = await applyStationPlan(plan, host)
|
|
628
|
+
if (!r.ok) return fail(json, "setup_failed", r.message, r.remedy)
|
|
629
|
+
return heartbeatSaved(json, "setup", r.status, { monitorId: r.monitorId, created: r.created !== null }, `${r.created ? `Created "${plan.name}" (#${r.created}) and` : `Reused "${plan.name}" and`} sent the first ping`)
|
|
630
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import type { AppConfig } from "../config.ts"
|
|
3
|
+
import { chooseNotifications, pagerProblem, parsePushUrl, pingUrl, sendHeartbeat, stationMonitorPayload, tickHeartbeat } from "./heartbeat.ts"
|
|
4
|
+
|
|
5
|
+
const PUSH = "https://kuma.example.com/api/push/AbC123?status=up&msg=OK&ping="
|
|
6
|
+
|
|
7
|
+
describe("push URLs", () => {
|
|
8
|
+
test("accepts Kuma's push URL with or without its query", () => {
|
|
9
|
+
expect(parsePushUrl(PUSH)?.toString()).toBe("https://kuma.example.com/api/push/AbC123")
|
|
10
|
+
expect(parsePushUrl("https://kuma.example.com/api/push/AbC123")).not.toBeNull()
|
|
11
|
+
expect(parsePushUrl("https://kuma.example.com/dashboard/12")).toBeNull()
|
|
12
|
+
expect(parsePushUrl("ftp://kuma.example.com/api/push/x")).toBeNull()
|
|
13
|
+
expect(parsePushUrl("not a url")).toBeNull()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test("each ping sets status, msg and ping", () => {
|
|
17
|
+
const u = new URL(pingUrl(PUSH, "down", "pager broken", 1234.6))
|
|
18
|
+
expect(u.searchParams.get("status")).toBe("down")
|
|
19
|
+
expect(u.searchParams.get("msg")).toBe("pager broken")
|
|
20
|
+
expect(u.searchParams.get("ping")).toBe("1235")
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe("what a tick reports", () => {
|
|
25
|
+
test("up, with what ran and the first problem", () => {
|
|
26
|
+
expect(tickHeartbeat(0, [], null)).toEqual({ status: "up", msg: "tick ok, nothing due" })
|
|
27
|
+
expect(tickHeartbeat(2, ["git push failed"], null)).toEqual({ status: "up", msg: "tick ok, 2 jobs ran; 1 problem: git push failed" })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test("down when the pager is broken", () => {
|
|
31
|
+
expect(tickHeartbeat(1, [], "Beacon's last page failed (HTTP 401)").status).toBe("down")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("pagerProblem: no channel, or the last page failed", () => {
|
|
35
|
+
const none = { beacon: null } as unknown as AppConfig
|
|
36
|
+
const one = { beacon: { defaultChannel: "pager", channels: { pager: { type: "telegram", botToken: "t", chatId: "1" } } } } as unknown as AppConfig
|
|
37
|
+
const entry = (ok: boolean) => ({ at: "", channel: "pager", ok, parts: 1, chars: 1, preview: "", ...(ok ? {} : { error: "HTTP 401" }) })
|
|
38
|
+
expect(pagerProblem(none, null)).toMatch(/isn't set up/)
|
|
39
|
+
expect(pagerProblem(one, null)).toBeNull()
|
|
40
|
+
expect(pagerProblem(one, entry(true))).toBeNull()
|
|
41
|
+
expect(pagerProblem(one, entry(false))).toMatch(/HTTP 401/)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe("sendHeartbeat against a fake Kuma", () => {
|
|
46
|
+
test("ok, Kuma's refusal, and an unreachable host", async () => {
|
|
47
|
+
const seen: URL[] = []
|
|
48
|
+
const server = Bun.serve({
|
|
49
|
+
port: 0,
|
|
50
|
+
fetch(req) {
|
|
51
|
+
const u = new URL(req.url)
|
|
52
|
+
seen.push(u)
|
|
53
|
+
return Response.json(u.pathname.endsWith("/good") ? { ok: true } : { ok: false, msg: "Monitor not found or not active." })
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
try {
|
|
57
|
+
const base = `http://127.0.0.1:${server.port}/api/push`
|
|
58
|
+
expect(await sendHeartbeat(`${base}/good`, "up", "tick ok", 10)).toEqual({ ok: true, status: "up", msg: "tick ok" })
|
|
59
|
+
expect(seen[0]!.searchParams.get("status")).toBe("up")
|
|
60
|
+
const bad = await sendHeartbeat(`${base}/gone`, "up", "tick ok")
|
|
61
|
+
expect(bad.ok).toBe(false)
|
|
62
|
+
expect(bad.error).toBe("Monitor not found or not active.")
|
|
63
|
+
} finally {
|
|
64
|
+
server.stop(true)
|
|
65
|
+
}
|
|
66
|
+
expect((await sendHeartbeat("http://127.0.0.1:1/api/push/x", "up", "m")).ok).toBe(false)
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe("station monitor setup", () => {
|
|
71
|
+
const n = (id: number, name: string, isDefault = false) => ({ id, name, active: true, isDefault })
|
|
72
|
+
|
|
73
|
+
test("who it alerts", () => {
|
|
74
|
+
expect(chooseNotifications([n(1, "Pager")]).chosen.map((x) => x.id)).toEqual([1])
|
|
75
|
+
expect(chooseNotifications([n(1, "Email"), n(2, "Telegram", true)]).chosen.map((x) => x.id)).toEqual([2])
|
|
76
|
+
expect(chooseNotifications([n(1, "Email"), n(2, "Telegram")]).needChoice).toMatch(/--notify/)
|
|
77
|
+
expect(chooseNotifications([n(1, "Email"), n(2, "Telegram")], "telegram").chosen.map((x) => x.id)).toEqual([2])
|
|
78
|
+
expect(chooseNotifications([n(1, "Email")], "2").needChoice).toMatch(/No active Kuma notification/)
|
|
79
|
+
expect(chooseNotifications([]).needChoice).toMatch(/no active notifications/)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test("a push monitor that waits three ticks", () => {
|
|
83
|
+
const p = stationMonitorPayload("SpinupTUI station: x", "tok")
|
|
84
|
+
expect(p).toMatchObject({ type: "push", pushToken: "tok", interval: 900, maxretries: 0 })
|
|
85
|
+
})
|
|
86
|
+
})
|