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.
- package/CHANGELOG.md +32 -1
- package/README.md +13 -3
- package/package.json +1 -1
- package/src/config.ts +25 -0
- package/src/index.tsx +26 -38
- package/src/lib/cliHome.ts +107 -0
- package/src/lib/cliScheduler.ts +630 -0
- package/src/lib/flock.ts +51 -0
- 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/schedule.ts +156 -0
- package/src/lib/scheduler.test.ts +353 -0
- package/src/lib/schedulerGit.ts +119 -0
- package/src/lib/schedulerInstall.ts +325 -0
- package/src/lib/schedulerJobs.ts +290 -0
- package/src/lib/schedulerRun.ts +247 -0
- package/src/lib/schedulerTick.ts +322 -0
- 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/beacon.ts +2 -1
- package/src/modules/contract.ts +22 -1
- package/src/modules/index.ts +2 -7
- package/src/modules/scheduler.ts +159 -0
- package/src/ui/App.tsx +19 -1
- package/src/ui/Details.tsx +7 -1
- package/src/ui/Onboarding.tsx +71 -3
- package/src/ui/main.tsx +42 -0
- package/src/ui/store.tsx +87 -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 +69 -5
- package/src/ui/views/SchedulerInstall.tsx +172 -0
- 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/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
|
|
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
|
+
}
|
package/src/ui/views/Browser.tsx
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
287
|
-
|
|
286
|
+
const id = sites[siteIndex].id
|
|
287
|
+
if (openInSpinupWP((slug) => siteWebUrl(id, slug))) setFlash("Opening in SpinupWP…")
|
|
288
288
|
} else if (server) {
|
|
289
|
-
|
|
290
|
-
|
|
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
|
+
}
|