spinuptui 1.3.0-alpha.1 → 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.
Files changed (41) hide show
  1. package/CHANGELOG.md +32 -1
  2. package/README.md +13 -3
  3. package/package.json +1 -1
  4. package/src/config.ts +25 -0
  5. package/src/index.tsx +26 -38
  6. package/src/lib/cliHome.ts +107 -0
  7. package/src/lib/cliScheduler.ts +630 -0
  8. package/src/lib/flock.ts +51 -0
  9. package/src/lib/heartbeat.test.ts +86 -0
  10. package/src/lib/heartbeat.ts +245 -0
  11. package/src/lib/home.test.ts +102 -0
  12. package/src/lib/home.ts +442 -0
  13. package/src/lib/schedule.ts +156 -0
  14. package/src/lib/scheduler.test.ts +353 -0
  15. package/src/lib/schedulerGit.ts +119 -0
  16. package/src/lib/schedulerInstall.ts +325 -0
  17. package/src/lib/schedulerJobs.ts +290 -0
  18. package/src/lib/schedulerRun.ts +247 -0
  19. package/src/lib/schedulerTick.ts +322 -0
  20. package/src/lib/spinupweb.test.ts +22 -0
  21. package/src/lib/spinupweb.ts +11 -0
  22. package/src/lib/stackCache.test.ts +43 -0
  23. package/src/lib/stackCache.ts +35 -1
  24. package/src/modules/beacon.ts +2 -1
  25. package/src/modules/contract.ts +22 -1
  26. package/src/modules/index.ts +2 -7
  27. package/src/modules/scheduler.ts +159 -0
  28. package/src/ui/App.tsx +19 -1
  29. package/src/ui/Details.tsx +7 -1
  30. package/src/ui/Onboarding.tsx +71 -3
  31. package/src/ui/main.tsx +42 -0
  32. package/src/ui/store.tsx +87 -3
  33. package/src/ui/views/AccountSlugPrompt.tsx +102 -0
  34. package/src/ui/views/Browser.tsx +5 -5
  35. package/src/ui/views/HeartbeatSetup.tsx +284 -0
  36. package/src/ui/views/HomeSync.tsx +299 -0
  37. package/src/ui/views/Modules.tsx +69 -5
  38. package/src/ui/views/SchedulerInstall.tsx +172 -0
  39. package/src/ui/views/Search.tsx +5 -6
  40. package/src/ui/views/ServerActions.tsx +6 -4
  41. package/src/ui/views/Stacks.tsx +22 -9
package/src/ui/store.tsx CHANGED
@@ -51,7 +51,7 @@ import { carrySiteSettings, type SettingsCarryResult } from "../lib/cloneSetting
51
51
  import { syncAdditionalDomains } from "../lib/cloneDomains.ts"
52
52
  import { parseRepo, deployKeysSettingsUrl, ghAvailable, ghDeployKeyPresent, ghAddDeployKey, generateDeployKeypair, type RepoHost } from "../lib/gitDeployKey.ts"
53
53
  import { keychainAvailable, setSudoPassword, getSudoPassword, deleteSudoPassword } from "../lib/keychain.ts"
54
- import { StackCache, siteSignature, type CachedProbe } from "../lib/stackCache.ts"
54
+ import { StackCache, siteSignature, isRecheckDue, type CachedProbe } from "../lib/stackCache.ts"
55
55
  import { resolvePhpEolDates, refreshPhpEolDates, isPhpEol as isPhpEolWith, offeredPhpVersions as offeredPhpVersionsWith, type PhpEolDates } from "../lib/phpEol.ts"
56
56
  import { resolveUbuntuEolDates, refreshUbuntuEolDates, isUbuntuEol as isUbuntuEolWith, type UbuntuEolDates } from "../lib/ubuntuEol.ts"
57
57
  import { wpCoreBusy } from "./wpCoreJobs.tsx"
@@ -748,6 +748,15 @@ interface StoreValue extends DataState {
748
748
  // Beacon's guided setup overlay, opened from the Modules tab.
749
749
  beaconSetupOpen: boolean
750
750
  setBeaconSetupOpen: (v: boolean) => void
751
+ // Scheduler's install plan + confirm overlay, opened from the Modules tab.
752
+ schedulerInstallOpen: boolean
753
+ setSchedulerInstallOpen: (v: boolean) => void
754
+ // The SpinupTUI folder: where it is, and sharing it across machines (Modules tab).
755
+ homeSyncOpen: boolean
756
+ setHomeSyncOpen: (v: boolean) => void
757
+ // The station heartbeat (Kuma push monitor) overlay, from the Modules tab.
758
+ heartbeatSetupOpen: boolean
759
+ setHeartbeatSetupOpen: (v: boolean) => void
751
760
  // Whether the "needs a local copy" (forgotten) report overlay is open, and an
752
761
  // optional stack filter (set from the selected Stacks group when opened).
753
762
  forgottenOpen: boolean
@@ -822,6 +831,14 @@ interface StoreValue extends DataState {
822
831
  enableLocalSync: () => void
823
832
  // SpinupWP account slug (from env/config) for building web deep links.
824
833
  accountSlug: string | null
834
+ // Open a page in the SpinupWP web app. Deep links need the account slug, which
835
+ // the API doesn't expose; with none saved this asks for it (slugPrompt) and
836
+ // opens the page once it's given, rather than landing on the dashboard root.
837
+ // Returns true when the page opened straight away.
838
+ openInSpinupWP: (build: (slug: string) => string) => boolean
839
+ slugPrompt: { build: (slug: string) => string } | null
840
+ setSlugPrompt: (p: { build: (slug: string) => string } | null) => void
841
+ saveAccountSlug: (slug: string) => void
825
842
  // The account (profile) this store is mounted for — see config.ts "Accounts".
826
843
  // Switching remounts the whole store, so these never change under a view.
827
844
  profileId: string
@@ -1035,6 +1052,24 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1035
1052
  })
1036
1053
  }, [])
1037
1054
  const [profileLabel, setProfileLabel] = useState(cfgRef.current.profileLabel)
1055
+ const [accountSlug, setAccountSlug] = useState(cfgRef.current.accountSlug)
1056
+ const [slugPrompt, setSlugPrompt] = useState<{ build: (slug: string) => string } | null>(null)
1057
+ const saveAccountSlug = useCallback((slug: string) => {
1058
+ cfgRef.current.accountSlug = slug
1059
+ setAccountSlug(slug)
1060
+ void saveProfileConfig({ accountSlug: slug })
1061
+ }, [])
1062
+ const openInSpinupWP = useCallback(
1063
+ (build: (slug: string) => string) => {
1064
+ if (accountSlug) {
1065
+ openUrl(build(accountSlug))
1066
+ return true
1067
+ }
1068
+ setSlugPrompt({ build })
1069
+ return false
1070
+ },
1071
+ [accountSlug],
1072
+ )
1038
1073
  const [route, setRoute] = useState<Route>("dashboard")
1039
1074
  const [inputMode, setInputMode] = useState(false)
1040
1075
  const [overlayOpen, setOverlayOpen] = useState(false)
@@ -1118,6 +1153,9 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1118
1153
  const [localRoots, setLocalRoots] = useState<string[]>(() => [...cfgRef.current.localRoots])
1119
1154
  const [discoverOpen, setDiscoverOpen] = useState(false)
1120
1155
  const [beaconSetupOpen, setBeaconSetupOpen] = useState(false)
1156
+ const [schedulerInstallOpen, setSchedulerInstallOpen] = useState(false)
1157
+ const [homeSyncOpen, setHomeSyncOpen] = useState(false)
1158
+ const [heartbeatSetupOpen, setHeartbeatSetupOpen] = useState(false)
1121
1159
  const [forgottenOpen, setForgottenOpen] = useState(false)
1122
1160
  const [forgottenStack, setForgottenStack] = useState<Stack | null>(null)
1123
1161
  const [linkReturnToForgotten, setLinkReturnToForgotten] = useState(false)
@@ -1254,7 +1292,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1254
1292
  }, [])
1255
1293
 
1256
1294
  const probeOne = useCallback(
1257
- async (site: Site) => {
1295
+ async (site: Site): Promise<boolean> => {
1258
1296
  const cache = cacheRef.current!
1259
1297
  setProbingIds((prev) => new Set(prev).add(site.id))
1260
1298
  setProbeErrors((prev) => {
@@ -1276,6 +1314,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1276
1314
  next.delete(site.id)
1277
1315
  return next
1278
1316
  })
1317
+ return outcome.ok
1279
1318
  },
1280
1319
  [servers],
1281
1320
  )
@@ -1309,6 +1348,41 @@ export function StoreProvider({ children }: { children: ReactNode }) {
1309
1348
 
1310
1349
  const isProbeStale = useCallback((site: Site) => cacheRef.current!.isStale(site.id, siteSignature(site)), [])
1311
1350
 
1351
+ // Re-check WordPress versions that have gone old, whenever the site list loads
1352
+ // (launch, and every `r`). Only sites already probed successfully: an unprobed
1353
+ // site's stack is the user's call (`d`/`D`), and it may not be reachable at all.
1354
+ // Each site gets at most one automatic attempt per max-age window, recorded in
1355
+ // the cache so it holds across launches: a site whose probe now fails keeps its
1356
+ // old reading (shown with its age) instead of being retried on every launch or
1357
+ // refresh. Dev Mode's servers aren't real.
1358
+ //
1359
+ // Unattended SSH must not trip fail2ban, which bans the source IP after a few
1360
+ // failed logins on one server. So each server's sites go one at a time, and the
1361
+ // first failure on a server ends that server's run for this window: at most one
1362
+ // failed login per server per day. Servers run in parallel, a few at a time.
1363
+ useEffect(() => {
1364
+ if (isDevMode() || servers.length === 0) return
1365
+ const cache = cacheRef.current!
1366
+ const now = Date.now()
1367
+ const due = sites.filter((s) => isRecheckDue(cache.get(s.id), now))
1368
+ if (due.length === 0) return
1369
+ // Marked up front, all of them: a reload mid-run must not queue these again,
1370
+ // and a server's sites after its first failure are skipped for the window too.
1371
+ void cache.noteRecheck(due.map((s) => s.id))
1372
+ const byServer = new Map<number, Site[]>()
1373
+ for (const s of due) byServer.set(s.server_id, [...(byServer.get(s.server_id) ?? []), s])
1374
+ const queues = [...byServer.values()]
1375
+ let cursor = 0
1376
+ const worker = async () => {
1377
+ while (cursor < queues.length) {
1378
+ for (const site of queues[cursor++]) if (!(await probeOne(site))) break
1379
+ }
1380
+ }
1381
+ for (let i = 0; i < Math.min(3, queues.length); i++) void worker()
1382
+ // probeOne changes identity with `servers`, which reloads together with
1383
+ // `sites` — this should only re-run when the site list itself is reloaded.
1384
+ }, [sites, servers])
1385
+
1312
1386
  const isPhpEol = useCallback((version: string | null | undefined) => isPhpEolWith(version, phpEolDates), [phpEolDates])
1313
1387
  const offeredPhpVersions = useCallback(
1314
1388
  (current?: string | null) => offeredPhpVersionsWith(phpEolDates, current),
@@ -4540,6 +4614,12 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4540
4614
  setDiscoverOpen,
4541
4615
  beaconSetupOpen,
4542
4616
  setBeaconSetupOpen,
4617
+ schedulerInstallOpen,
4618
+ setSchedulerInstallOpen,
4619
+ homeSyncOpen,
4620
+ setHomeSyncOpen,
4621
+ heartbeatSetupOpen,
4622
+ setHeartbeatSetupOpen,
4543
4623
  forgottenOpen,
4544
4624
  setForgottenOpen,
4545
4625
  forgottenStack,
@@ -4581,7 +4661,11 @@ export function StoreProvider({ children }: { children: ReactNode }) {
4581
4661
  enableLocalSyncSite,
4582
4662
  setEnableLocalSyncSite,
4583
4663
  enableLocalSync,
4584
- accountSlug: cfgRef.current.accountSlug,
4664
+ accountSlug,
4665
+ openInSpinupWP,
4666
+ slugPrompt,
4667
+ setSlugPrompt,
4668
+ saveAccountSlug,
4585
4669
  profileId: cfgRef.current.profileId,
4586
4670
  profileLabel,
4587
4671
  setProfileLabel,
@@ -0,0 +1,102 @@
1
+ // Ask for the SpinupWP account slug the first time `w` needs it.
2
+ //
3
+ // Deep links into the web app are account-scoped (spinupwp.app/{slug}/sites/{id})
4
+ // and the API doesn't expose the slug, so without one `w` could only open the
5
+ // dashboard root. Instead of that, `w` opens this prompt: the user pastes the
6
+ // slug (or any dashboard URL), it's saved to the current account, and the page
7
+ // they asked for opens. Every later `w` goes straight there.
8
+
9
+ import { useEffect, useState } from "react"
10
+ import { useKeyboard } from "@opentui/react"
11
+ import { theme } from "../../lib/theme.ts"
12
+ import { openUrl } from "../../lib/open.ts"
13
+ import { parseAccountSlug } from "../../lib/spinupweb.ts"
14
+ import { Panel, Centered } from "../components.tsx"
15
+ import { StatusBar } from "../StatusBar.tsx"
16
+ import { useStore } from "../store.tsx"
17
+ import { toast } from "../toast.ts"
18
+
19
+ const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
20
+
21
+ export function AccountSlugPrompt() {
22
+ const { slugPrompt, setSlugPrompt, saveAccountSlug, setInputMode } = useStore()
23
+ const [value, setValue] = useState("")
24
+ const [error, setError] = useState<string | null>(null)
25
+
26
+ // The input owns the keyboard while this is open.
27
+ useEffect(() => {
28
+ setInputMode(true)
29
+ return () => setInputMode(false)
30
+ }, [setInputMode])
31
+
32
+ const close = () => setSlugPrompt(null)
33
+
34
+ const submit = () => {
35
+ if (!slugPrompt) return
36
+ const slug = parseAccountSlug(value)
37
+ if (!slug) {
38
+ setError(value.trim() ? "That doesn't look like a slug — paste the part after spinupwp.app/, or the whole URL." : "Paste your account slug, or Esc to cancel.")
39
+ return
40
+ }
41
+ saveAccountSlug(slug)
42
+ openUrl(slugPrompt.build(slug))
43
+ toast.success(`Saved “${slug}” — opening in SpinupWP…`)
44
+ close()
45
+ }
46
+
47
+ // Esc reaches us even with the input focused (it doesn't consume it).
48
+ useKeyboard((key) => {
49
+ if (key.name === "escape") close()
50
+ })
51
+
52
+ if (!slugPrompt) return null
53
+
54
+ return (
55
+ <box
56
+ style={{
57
+ position: "absolute",
58
+ top: 0,
59
+ left: 0,
60
+ width: "100%",
61
+ height: "100%",
62
+ flexDirection: "column",
63
+ backgroundColor: theme.bg,
64
+ zIndex: 300,
65
+ }}
66
+ >
67
+ <Centered>
68
+ <Panel title=" Open in SpinupWP " active>
69
+ <box style={{ flexDirection: "column", width: 68, paddingTop: 1, paddingBottom: 1 }}>
70
+ <text content="To open the right page, SpinupTUI needs your SpinupWP account slug:" fg={theme.text} wrapMode="none" />
71
+ <text content="the part after spinupwp.app/ in your dashboard's address." fg={theme.text} wrapMode="none" />
72
+ <box style={{ height: 1 }} />
73
+ <text content="SpinupWP's API doesn't provide it, so this asks once and saves it." fg={theme.textDim} wrapMode="none" />
74
+ <text content="Pasting any dashboard URL works too." fg={theme.textDim} wrapMode="none" />
75
+ <box style={{ height: 1 }} />
76
+ <text content="Account slug" fg={theme.accent} />
77
+ <input
78
+ focused
79
+ value={value}
80
+ placeholder="acme-agency"
81
+ onInput={(v: string) => {
82
+ setValue(v)
83
+ setError(null)
84
+ }}
85
+ onSubmit={submit}
86
+ style={INPUT_STYLE}
87
+ />
88
+ <box style={{ height: 1 }} />
89
+ {error ? <text content={"✕ " + error} fg={theme.bad} wrapMode="none" /> : <text content="Saved to this account in config.json." fg={theme.textFaint} wrapMode="none" />}
90
+ </box>
91
+ </Panel>
92
+ </Centered>
93
+ <StatusBar
94
+ hints={[
95
+ { key: "⏎", label: "save & open" },
96
+ { key: "esc", label: "cancel" },
97
+ ]}
98
+ showGlobal={false}
99
+ />
100
+ </box>
101
+ )
102
+ }
@@ -24,7 +24,7 @@ type Focus = "servers" | "sites"
24
24
 
25
25
  export function Browser({ rows }: { rows: number }) {
26
26
  const store = useStore()
27
- const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setDbSyncSite, localSync, localLinks, setDbBackupSite, setMediaFallbackSite, setEnableLocalSyncSite } = store
27
+ const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, openInSpinupWP, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setDbSyncSite, localSync, localLinks, setDbBackupSite, setMediaFallbackSite, setEnableLocalSyncSite } = store
28
28
 
29
29
  const [serverIndex, setServerIndex] = useState(0)
30
30
  const [siteIndex, setSiteIndex] = useState(0)
@@ -283,11 +283,11 @@ export function Browser({ rows }: { rows: number }) {
283
283
  // Open the focused server/site in the SpinupWP web app — useful for
284
284
  // actions the API can't do (e.g. running a pending server upgrade).
285
285
  if (focus === "sites" && sites[siteIndex]) {
286
- openUrl(siteWebUrl(sites[siteIndex].id, accountSlug))
287
- setFlash(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
286
+ const id = sites[siteIndex].id
287
+ if (openInSpinupWP((slug) => siteWebUrl(id, slug))) setFlash("Opening in SpinupWP…")
288
288
  } else if (server) {
289
- openUrl(serverWebUrl(server.id, accountSlug))
290
- setFlash(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
289
+ const id = server.id
290
+ if (openInSpinupWP((slug) => serverWebUrl(id, slug))) setFlash("Opening in SpinupWP…")
291
291
  }
292
292
  setTimeout(() => setFlash(null), 2000)
293
293
  return
@@ -0,0 +1,284 @@
1
+ // The station heartbeat overlay (Modules tab, `h` on Scheduler): the same flow
2
+ // as `spinuptui scheduler heartbeat setup`, with the confirm-before-firing
3
+ // pattern. With a saved Kuma login it finds or creates this station's push
4
+ // monitor; without one it takes a push URL made by hand. Once set, it shows the
5
+ // last ping and can send a test or remove it.
6
+
7
+ import { useEffect, useState } from "react"
8
+ import { useKeyboard } from "@opentui/react"
9
+ import { theme } from "../../lib/theme.ts"
10
+ import { truncate } from "../../lib/format.ts"
11
+ import { Panel, Spinner, Centered } from "../components.tsx"
12
+ import { StatusBar } from "../StatusBar.tsx"
13
+ import { useStore } from "../store.tsx"
14
+ import { loadConfig } from "../../config.ts"
15
+ import { readLastTick } from "../../lib/schedulerJobs.ts"
16
+ import { stationName } from "../../lib/schedulerGit.ts"
17
+ import {
18
+ applyStationPlan,
19
+ connectHeartbeat,
20
+ loadStationPlan,
21
+ pagerProblem,
22
+ removeHeartbeat,
23
+ sendHeartbeat,
24
+ stationPlanText,
25
+ tickHeartbeat,
26
+ type StationMonitorPlan,
27
+ } from "../../lib/heartbeat.ts"
28
+
29
+ type Phase = "status" | "loading" | "choose" | "confirm" | "manual" | "working" | "done" | "error" | "confirmRemove"
30
+
31
+ const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
32
+ const WIDTH = 76
33
+
34
+ export function HeartbeatSetup() {
35
+ const { setHeartbeatSetupOpen, setInputMode } = useStore()
36
+ const host = stationName()
37
+ const [current, setCurrent] = useState(() => loadConfig().scheduler?.heartbeat ?? null)
38
+ const hasKuma = !!loadConfig().uptimeKuma
39
+ const [phase, setPhase] = useState<Phase>(current ? "status" : hasKuma ? "loading" : "manual")
40
+ const [plan, setPlan] = useState<StationMonitorPlan | null>(null)
41
+ const [pick, setPick] = useState(0)
42
+ const [url, setUrl] = useState("")
43
+ const [message, setMessage] = useState<{ text: string; remedy?: string; good?: boolean } | null>(null)
44
+
45
+ const close = () => setHeartbeatSetupOpen(false)
46
+
47
+ const startPlan = () => {
48
+ setPhase("loading")
49
+ void loadStationPlan(host).then((r) => {
50
+ if (!r.ok) {
51
+ setMessage({ text: r.message, remedy: r.remedy })
52
+ return setPhase("error")
53
+ }
54
+ setPlan(r.plan)
55
+ setPick(0)
56
+ setPhase(!r.plan.existing && r.plan.needChoice ? "choose" : "confirm")
57
+ })
58
+ }
59
+
60
+ useEffect(() => {
61
+ if (phase === "loading" && !plan) startPlan()
62
+ // Only the first load; later loads go through startPlan directly.
63
+ // eslint-disable-next-line react-hooks/exhaustive-deps
64
+ }, [])
65
+
66
+ useEffect(() => {
67
+ if (phase !== "manual") return
68
+ setInputMode(true)
69
+ return () => setInputMode(false)
70
+ }, [phase, setInputMode])
71
+
72
+ const finish = (text: string, status: "up" | "down") => {
73
+ setCurrent(loadConfig().scheduler?.heartbeat ?? null)
74
+ const why = status === "down" ? ` It says "down" because ${pagerProblem()}; the next tick says "up" once that's fixed.` : ""
75
+ const tail = loadConfig().scheduler?.dispatcher ? " Every dispatcher tick pings it from now on." : " This machine has no dispatcher yet: install Scheduler (⏎ on its card) so ticks ping it."
76
+ setMessage({ text: text + why + tail, good: true })
77
+ setPhase("done")
78
+ }
79
+
80
+ const apply = () => {
81
+ if (!plan) return
82
+ setPhase("working")
83
+ void applyStationPlan(plan, host).then((r) => {
84
+ if (!r.ok) {
85
+ setMessage({ text: r.message, remedy: r.remedy })
86
+ return setPhase("error")
87
+ }
88
+ finish(`${r.created ? `Created "${plan.name}" (#${r.created})` : `Reused "${plan.name}"`} and sent the first ping (status ${r.status}).`, r.status)
89
+ })
90
+ }
91
+
92
+ const submitUrl = () => {
93
+ if (!url.trim()) return
94
+ setPhase("working")
95
+ void connectHeartbeat(url.trim(), host).then((r) => {
96
+ if (!r.ok) {
97
+ setMessage({ text: r.message, remedy: r.remedy })
98
+ return setPhase("error")
99
+ }
100
+ finish(`Saved. Kuma took the ping (status ${r.status}).`, r.status)
101
+ })
102
+ }
103
+
104
+ const test = () => {
105
+ if (!current) return
106
+ setPhase("working")
107
+ const t = tickHeartbeat(0, [], pagerProblem())
108
+ void sendHeartbeat(current.url, t.status, `${host}: test ping (${t.msg})`).then((r) => {
109
+ setMessage(r.ok ? { text: `Kuma took the test ping (status ${t.status}).`, good: true } : { text: `Kuma didn't take the ping: ${r.error}`, remedy: "Check that the monitor is active and that this machine can reach Kuma." })
110
+ setPhase(r.ok ? "done" : "error")
111
+ })
112
+ }
113
+
114
+ useKeyboard((key) => {
115
+ const name = key.name ?? ""
116
+ if (phase === "manual") {
117
+ if (name === "escape") close()
118
+ return
119
+ }
120
+ if (phase === "working" || phase === "loading") return
121
+ if (name === "escape" || name === "q") return phase === "confirmRemove" ? setPhase("status") : close()
122
+ if (phase === "status") {
123
+ if (name === "t") return test()
124
+ if (name === "s" && hasKuma) return startPlan()
125
+ if (name === "x") return setPhase("confirmRemove")
126
+ }
127
+ if (phase === "confirmRemove" && name === "y") {
128
+ void removeHeartbeat().then(() => {
129
+ setCurrent(null)
130
+ setMessage({ text: "Removed. Pause or delete the monitor in Kuma too, or it will report this station as down.", good: true })
131
+ setPhase("done")
132
+ })
133
+ return
134
+ }
135
+ if (phase === "choose" && plan) {
136
+ if (name === "up" || name === "k") return setPick((i) => Math.max(0, i - 1))
137
+ if (name === "down" || name === "j") return setPick((i) => Math.min(plan.available.length - 1, i + 1))
138
+ if (name === "return") {
139
+ setPlan({ ...plan, chosen: [plan.available[pick]!], needChoice: null })
140
+ return setPhase("confirm")
141
+ }
142
+ }
143
+ if (phase === "confirm" && name === "y") return apply()
144
+ if (phase === "error" || phase === "done") {
145
+ if (name === "u") {
146
+ setUrl("")
147
+ return setPhase("manual")
148
+ }
149
+ if (name === "r" && hasKuma) return startPlan()
150
+ }
151
+ })
152
+
153
+ return (
154
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
155
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
156
+ <text content="♥ Station heartbeat " fg={theme.brand} style={{ flexShrink: 0 }} />
157
+ <text content="know when this machine stops, from somewhere else" fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
158
+ </box>
159
+ <Centered>{body()}</Centered>
160
+ <StatusBar hints={hints()} showGlobal={false} />
161
+ </box>
162
+ )
163
+
164
+ function intro() {
165
+ return (
166
+ <>
167
+ <text content="If this machine is off, its spinuptui is broken, or Scheduler's LaunchAgent is gone, nothing here can page you. So every Scheduler tick pings an Uptime Kuma push monitor running on another machine, and when the pings stop, Kuma alerts you itself." fg={theme.textDim} />
168
+ <box style={{ height: 1 }} />
169
+ </>
170
+ )
171
+ }
172
+
173
+ function body() {
174
+ if (phase === "loading" || phase === "working") {
175
+ return (
176
+ <box style={{ flexDirection: "row" }}>
177
+ <Spinner />
178
+ <text content={phase === "loading" ? " Asking Uptime Kuma…" : " Talking to Uptime Kuma…"} fg={theme.textDim} wrapMode="none" />
179
+ </box>
180
+ )
181
+ }
182
+ if (phase === "status" && current) {
183
+ const last = readLastTick()?.heartbeat ?? null
184
+ const pager = pagerProblem()
185
+ return (
186
+ <Panel title=" Station heartbeat " active>
187
+ <box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
188
+ <text content={`✓ Pings ${new URL(current.url).host} at the end of every Scheduler tick.`} fg={theme.good} wrapMode="none" />
189
+ <text content={`Set ${current.setAt.slice(0, 10)}.`} fg={theme.textDim} />
190
+ <box style={{ height: 1 }} />
191
+ <text
192
+ content={last ? `Last ping: ${last.ok ? "accepted" : `failed (${truncate(last.error ?? "", 50)})`}, status ${last.status}.` : "No Scheduler tick has pinged it yet."}
193
+ fg={last && !last.ok ? theme.bad : theme.text}
194
+ />
195
+ {pager && <text content={`Pings say "down" right now: ${pager}.`} fg={theme.warn} />}
196
+ </box>
197
+ </Panel>
198
+ )
199
+ }
200
+ if (phase === "choose" && plan) {
201
+ return (
202
+ <Panel title=" Who should Kuma alert? " active>
203
+ <box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
204
+ {intro()}
205
+ <text content="This Kuma has several notifications and none is the default. Pick the one that should wake you when this station goes quiet:" fg={theme.text} />
206
+ <box style={{ height: 1 }} />
207
+ {plan.available.map((n, i) => (
208
+ <text key={n.id} content={`${i === pick ? "▸ " : " "}${n.name}`} fg={i === pick ? theme.accent : theme.text} wrapMode="none" />
209
+ ))}
210
+ </box>
211
+ </Panel>
212
+ )
213
+ }
214
+ if (phase === "confirm" && plan) {
215
+ const { steps, notes } = stationPlanText(plan)
216
+ return (
217
+ <Panel title=" The plan " active>
218
+ <box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
219
+ {intro()}
220
+ {steps.map((s, i) => (
221
+ <text key={s} content={`${i + 1}. ${s}`} fg={theme.text} />
222
+ ))}
223
+ <box style={{ height: 1 }} />
224
+ {notes.map((n) => (
225
+ <text key={n} content={n} fg={theme.textFaint} />
226
+ ))}
227
+ <box style={{ height: 1 }} />
228
+ <text content="Press y to do it · Esc to cancel" fg={theme.text} wrapMode="none" />
229
+ </box>
230
+ </Panel>
231
+ )
232
+ }
233
+ if (phase === "manual") {
234
+ return (
235
+ <Panel title=" Connect a Kuma push monitor " active>
236
+ <box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
237
+ {intro()}
238
+ <text content="SpinupTUI has no Kuma login to create the monitor with (connect one with M on any site). Or make it yourself: in Kuma, Add New Monitor → Monitor Type: Push, Heartbeat Interval 900 seconds, with a notification. Then paste its push URL:" fg={theme.text} />
239
+ <box style={{ height: 1 }} />
240
+ <input focused value={url} placeholder="https://kuma.example.com/api/push/…" onInput={(v: string) => setUrl(v)} onSubmit={submitUrl} style={INPUT_STYLE} />
241
+ <box style={{ height: 1 }} />
242
+ <text content="SpinupTUI pings it once and saves it only if Kuma accepts the ping." fg={theme.textFaint} />
243
+ </box>
244
+ </Panel>
245
+ )
246
+ }
247
+ if (phase === "confirmRemove") {
248
+ return (
249
+ <Panel title=" Remove the heartbeat? " active>
250
+ <box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
251
+ <text content="Scheduler stops pinging Kuma. The monitor stays in Kuma and will report this station as down, so pause or delete it there too." fg={theme.text} />
252
+ <box style={{ height: 1 }} />
253
+ <text content="Press y to remove · Esc to keep it" fg={theme.text} wrapMode="none" />
254
+ </box>
255
+ </Panel>
256
+ )
257
+ }
258
+ const good = phase === "done" && message?.good
259
+ return (
260
+ <Panel title={good ? " Done " : " Didn't work "} active>
261
+ <box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
262
+ <text content={`${good ? "✓" : "✕"} ${message?.text ?? ""}`} fg={good ? theme.good : theme.bad} />
263
+ {message?.remedy && (
264
+ <>
265
+ <box style={{ height: 1 }} />
266
+ <text content={message.remedy} fg={theme.textDim} />
267
+ </>
268
+ )}
269
+ </box>
270
+ </Panel>
271
+ )
272
+ }
273
+
274
+ function hints() {
275
+ const esc = { key: "esc", label: "close" }
276
+ if (phase === "status") return [{ key: "t", label: "test ping" }, ...(hasKuma ? [{ key: "s", label: "redo setup" }] : []), { key: "x", label: "remove" }, esc]
277
+ if (phase === "choose") return [{ key: "↑↓", label: "choose" }, { key: "⏎", label: "use it" }, esc]
278
+ if (phase === "confirm") return [{ key: "y", label: "do it" }, { key: "esc", label: "cancel" }]
279
+ if (phase === "manual") return [{ key: "⏎", label: "ping & save" }, { key: "esc", label: "cancel" }]
280
+ if (phase === "confirmRemove") return [{ key: "y", label: "remove" }, { key: "esc", label: "keep" }]
281
+ if (phase === "error") return [...(hasKuma ? [{ key: "r", label: "try again" }] : []), { key: "u", label: "paste a URL instead" }, esc]
282
+ return [esc]
283
+ }
284
+ }