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/src/ui/views/Modules.tsx
CHANGED
|
@@ -17,6 +17,7 @@ import type { ModuleId, ModuleStatus } from "../../modules/contract.ts"
|
|
|
17
17
|
import { beaconFromConfig, beaconSend, testMessage } from "../../lib/beacon.ts"
|
|
18
18
|
import { loadConfig } from "../../config.ts"
|
|
19
19
|
import { upcomingRuns } from "../../modules/scheduler.ts"
|
|
20
|
+
import { describeShare, homeStatus, tilde, type HomeStatus } from "../../lib/home.ts"
|
|
20
21
|
|
|
21
22
|
interface Card {
|
|
22
23
|
id: ModuleId
|
|
@@ -43,7 +44,7 @@ function stateLabel(card: Card, status: ModuleStatus | undefined): { dot: string
|
|
|
43
44
|
|
|
44
45
|
export function Modules({ rows }: { rows: number }) {
|
|
45
46
|
const store = useStore()
|
|
46
|
-
const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen, schedulerInstallOpen, setSchedulerInstallOpen } = store
|
|
47
|
+
const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen, schedulerInstallOpen, setSchedulerInstallOpen, homeSyncOpen, setHomeSyncOpen, heartbeatSetupOpen, setHeartbeatSetupOpen } = store
|
|
47
48
|
const [selected, setSelected] = useState(0)
|
|
48
49
|
const [statuses, setStatuses] = useState<Map<ModuleId, ModuleStatus>>(new Map())
|
|
49
50
|
const [testing, setTesting] = useState(false)
|
|
@@ -52,7 +53,7 @@ export function Modules({ rows }: { rows: number }) {
|
|
|
52
53
|
// the config). status() is cheap and never sends anything.
|
|
53
54
|
const [tick, setTick] = useState(0)
|
|
54
55
|
useEffect(() => {
|
|
55
|
-
if (beaconSetupOpen || schedulerInstallOpen) return
|
|
56
|
+
if (beaconSetupOpen || schedulerInstallOpen || homeSyncOpen || heartbeatSetupOpen) return
|
|
56
57
|
let live = true
|
|
57
58
|
void Promise.all(MODULES.map((m) => m.status())).then((list) => {
|
|
58
59
|
if (live) setStatuses(new Map(list.map((s) => [s.id, s])))
|
|
@@ -60,7 +61,7 @@ export function Modules({ rows }: { rows: number }) {
|
|
|
60
61
|
return () => {
|
|
61
62
|
live = false
|
|
62
63
|
}
|
|
63
|
-
}, [beaconSetupOpen, schedulerInstallOpen, tick])
|
|
64
|
+
}, [beaconSetupOpen, schedulerInstallOpen, homeSyncOpen, heartbeatSetupOpen, tick])
|
|
64
65
|
|
|
65
66
|
const isActive = route === "modules" && !inputMode && !overlayOpen
|
|
66
67
|
const card = CARDS[selected]!
|
|
@@ -91,6 +92,12 @@ export function Modules({ rows }: { rows: number }) {
|
|
|
91
92
|
if (card.id === "beacon") return setBeaconSetupOpen(true)
|
|
92
93
|
if (card.id === "scheduler") return setSchedulerInstallOpen(true)
|
|
93
94
|
return
|
|
95
|
+
case "f":
|
|
96
|
+
if (card.id === "scheduler") return setHomeSyncOpen(true)
|
|
97
|
+
return
|
|
98
|
+
case "h":
|
|
99
|
+
if (card.id === "scheduler") return setHeartbeatSetupOpen(true)
|
|
100
|
+
return
|
|
94
101
|
case "t":
|
|
95
102
|
if (card.id === "beacon" && status && status.state !== "not_configured") return sendTest()
|
|
96
103
|
return
|
|
@@ -142,7 +149,13 @@ export function Modules({ rows }: { rows: number }) {
|
|
|
142
149
|
hints={[
|
|
143
150
|
{ key: "↑↓/jk", label: "select" },
|
|
144
151
|
...(card.id === "beacon" ? [{ key: "⏎", label: beaconReady ? "reconfigure" : "set up" }] : []),
|
|
145
|
-
...(card.id === "scheduler"
|
|
152
|
+
...(card.id === "scheduler"
|
|
153
|
+
? [
|
|
154
|
+
{ key: "⏎", label: status && status.state !== "not_configured" ? "reinstall" : "install" },
|
|
155
|
+
{ key: "f", label: "your folder" },
|
|
156
|
+
{ key: "h", label: "heartbeat" },
|
|
157
|
+
]
|
|
158
|
+
: []),
|
|
146
159
|
...(beaconReady ? [{ key: "t", label: testing ? "sending…" : "test page" }] : []),
|
|
147
160
|
]}
|
|
148
161
|
/>
|
|
@@ -194,13 +207,23 @@ function ModuleDetail({ card, status }: { card: Card; status: ModuleStatus | und
|
|
|
194
207
|
{card.id === "beacon" && status.state !== "not_configured" && (
|
|
195
208
|
<text content="⏎ reconfigure the pager · t send a test page · CLI: spinuptui beacon status" fg={theme.textFaint} />
|
|
196
209
|
)}
|
|
197
|
-
{card.id === "scheduler" && <SchedulerDetail installed={status.state !== "not_configured"} />}
|
|
210
|
+
{card.id === "scheduler" && <SchedulerDetail installed={status.state !== "not_configured"} refreshKey={status} />}
|
|
198
211
|
</box>
|
|
199
212
|
)
|
|
200
213
|
}
|
|
201
214
|
|
|
202
|
-
function SchedulerDetail({ installed }: { installed: boolean }) {
|
|
215
|
+
function SchedulerDetail({ installed, refreshKey }: { installed: boolean; refreshKey: unknown }) {
|
|
203
216
|
const [runs] = useState(() => upcomingRuns())
|
|
217
|
+
// Level 0 of the SpinupTUI folder: always say where the work lands.
|
|
218
|
+
const [home, setHome] = useState<HomeStatus | null>(null)
|
|
219
|
+
const heartbeatSet = !!loadConfig().scheduler?.heartbeat
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
let live = true
|
|
222
|
+
void homeStatus().then((h) => live && setHome(h))
|
|
223
|
+
return () => {
|
|
224
|
+
live = false
|
|
225
|
+
}
|
|
226
|
+
}, [refreshKey])
|
|
204
227
|
return (
|
|
205
228
|
<>
|
|
206
229
|
<box style={{ height: 1 }} />
|
|
@@ -223,6 +246,11 @@ function SchedulerDetail({ installed }: { installed: boolean }) {
|
|
|
223
246
|
</box>
|
|
224
247
|
))}
|
|
225
248
|
<box style={{ height: 1 }} />
|
|
249
|
+
{home && <text content={`Jobs and reports: ${tilde(home.path)}`} fg={theme.textDim} wrapMode="none" />}
|
|
250
|
+
{home && <text content={` ${describeShare(home)}`} fg={home.git && home.remote ? theme.good : theme.textDim} wrapMode="none" />}
|
|
251
|
+
<text content={home?.git && home.remote ? "f your folder" : "f share it across machines, so you can queue jobs from anywhere"} fg={theme.accent} wrapMode="none" />
|
|
252
|
+
<text content={heartbeatSet ? "h station heartbeat" : "h get alerted from elsewhere if this machine stops (station heartbeat)"} fg={theme.accent} wrapMode="none" />
|
|
253
|
+
<box style={{ height: 1 }} />
|
|
226
254
|
<text
|
|
227
255
|
content={`${installed ? "⏎ reinstall (repairs the unit) · " : ""}CLI: spinuptui scheduler add | list | status`}
|
|
228
256
|
fg={theme.textFaint}
|
package/src/ui/views/Search.tsx
CHANGED
|
@@ -36,7 +36,7 @@ function score(haystack: string, q: string): number | null {
|
|
|
36
36
|
|
|
37
37
|
export function Search({ rows }: { rows: number }) {
|
|
38
38
|
const store = useStore()
|
|
39
|
-
const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setServerActionsServer,
|
|
39
|
+
const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setServerActionsServer, openInSpinupWP, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite, setVanityServer, vanityJob } = store
|
|
40
40
|
const [query, setQuery] = useState("")
|
|
41
41
|
const [selected, setSelected] = useState(0)
|
|
42
42
|
// "query" = typing/filtering (input focused); "actions" = input blurred so the
|
|
@@ -101,9 +101,8 @@ export function Search({ rows }: { rows: number }) {
|
|
|
101
101
|
openUrl((s.https?.enabled ? "https://" : "http://") + s.domain)
|
|
102
102
|
flashMsg(`Opening ${s.domain}…`)
|
|
103
103
|
}
|
|
104
|
-
const openInSpinup = (
|
|
105
|
-
|
|
106
|
-
flashMsg(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
|
|
104
|
+
const openInSpinup = (build: (slug: string) => string) => {
|
|
105
|
+
if (openInSpinupWP(build)) flashMsg("Opening in SpinupWP…")
|
|
107
106
|
}
|
|
108
107
|
|
|
109
108
|
useKeyboard((key) => {
|
|
@@ -141,8 +140,8 @@ export function Search({ rows }: { rows: number }) {
|
|
|
141
140
|
if (current?.kind === "site") openSite(current.site)
|
|
142
141
|
return
|
|
143
142
|
case "w":
|
|
144
|
-
if (current?.kind === "site") openInSpinup(siteWebUrl(current.site.id,
|
|
145
|
-
else if (current?.kind === "server") openInSpinup(serverWebUrl(current.server.id,
|
|
143
|
+
if (current?.kind === "site") openInSpinup((slug) => siteWebUrl(current.site.id, slug))
|
|
144
|
+
else if (current?.kind === "server") openInSpinup((slug) => serverWebUrl(current.server.id, slug))
|
|
146
145
|
return
|
|
147
146
|
case "u":
|
|
148
147
|
if (current?.kind === "site") setPhpUpgradeSite(current.site)
|
|
@@ -19,7 +19,6 @@ import { Panel, Spinner, Centered } from "../components.tsx"
|
|
|
19
19
|
import { StatusBar } from "../StatusBar.tsx"
|
|
20
20
|
import { useStore } from "../store.tsx"
|
|
21
21
|
import { moveSelection } from "../List.tsx"
|
|
22
|
-
import { openUrl } from "../../lib/open.ts"
|
|
23
22
|
import { serverWebUrl } from "../../lib/spinupweb.ts"
|
|
24
23
|
import { toast } from "../toast.ts"
|
|
25
24
|
import type { RebootInfo } from "../../lib/ssh.ts"
|
|
@@ -67,7 +66,7 @@ export function ServerActions() {
|
|
|
67
66
|
rebootInfoLoading,
|
|
68
67
|
rebootInfoErrors,
|
|
69
68
|
loadRebootInfo,
|
|
70
|
-
|
|
69
|
+
openInSpinupWP,
|
|
71
70
|
refresh,
|
|
72
71
|
} = store
|
|
73
72
|
|
|
@@ -161,8 +160,11 @@ export function ServerActions() {
|
|
|
161
160
|
return
|
|
162
161
|
case "w":
|
|
163
162
|
if (upgradeRequired && server) {
|
|
164
|
-
|
|
165
|
-
toast.success(
|
|
163
|
+
const id = server.id
|
|
164
|
+
if (openInSpinupWP((slug) => serverWebUrl(id, slug))) toast.success("Opening in SpinupWP…")
|
|
165
|
+
// No slug yet: the prompt takes over — close this overlay so its
|
|
166
|
+
// keys don't fire underneath.
|
|
167
|
+
else close()
|
|
166
168
|
}
|
|
167
169
|
return
|
|
168
170
|
}
|
package/src/ui/views/Stacks.tsx
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
17
17
|
import { useKeyboard } from "@opentui/react"
|
|
18
18
|
import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
19
|
-
import { bar, truncate } from "../../lib/format.ts"
|
|
19
|
+
import { bar, timeAgo, truncate } from "../../lib/format.ts"
|
|
20
|
+
import { isVersionOld } from "../../lib/stackCache.ts"
|
|
20
21
|
import { STACKS, effectiveStack, stackColor, type Stack } from "../../lib/stack.ts"
|
|
21
22
|
import { phpSortKey } from "../../lib/phpEol.ts"
|
|
22
23
|
import { probeKindColor, type ProbeKind } from "../../lib/probe.ts"
|
|
@@ -75,7 +76,7 @@ function nonWpSub(kind: ProbeKind | null, vanity: boolean): NonWpSub {
|
|
|
75
76
|
export function Stacks({ rows }: { rows: number }) {
|
|
76
77
|
const store = useStore()
|
|
77
78
|
const wpCoreJobs = useWpCoreJobs()
|
|
78
|
-
const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol,
|
|
79
|
+
const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, openInSpinupWP, setPhpUpgradeSite, setBulkWpGroup, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
|
|
79
80
|
store
|
|
80
81
|
|
|
81
82
|
const [groupIndex, setGroupIndex] = useState(0)
|
|
@@ -218,8 +219,8 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
218
219
|
case "w":
|
|
219
220
|
// Open the selected site in the SpinupWP web app.
|
|
220
221
|
if (focus === "sites" && groupSites[siteIndex]) {
|
|
221
|
-
|
|
222
|
-
|
|
222
|
+
const id = groupSites[siteIndex].id
|
|
223
|
+
if (openInSpinupWP((slug) => siteWebUrl(id, slug))) flashMsg("Opening in SpinupWP…")
|
|
223
224
|
}
|
|
224
225
|
return
|
|
225
226
|
case "d":
|
|
@@ -421,11 +422,23 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
421
422
|
) : vanity ? (
|
|
422
423
|
<text content="Vanity Site" fg={selected ? theme.text : VANITY_COLOR} wrapMode="none" />
|
|
423
424
|
) : cached ? (
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
425
|
+
<>
|
|
426
|
+
<text
|
|
427
|
+
content={truncate(cached.result.label, 20) + (isProbeStale(s) ? "*" : "")}
|
|
428
|
+
fg={probeKindColor(cached.result.kind, selected)}
|
|
429
|
+
wrapMode="none"
|
|
430
|
+
/>
|
|
431
|
+
{/* A version past its re-check age (the background re-probe
|
|
432
|
+
failed or hasn't run) says how old it is, so it can't
|
|
433
|
+
pass for current. */}
|
|
434
|
+
{isVersionOld(cached) && (
|
|
435
|
+
<text
|
|
436
|
+
content={" " + timeAgo(new Date(cached.detectedAt).toISOString()).replace(/ ago$/, "")}
|
|
437
|
+
fg={selected ? theme.text : theme.warn}
|
|
438
|
+
wrapMode="none"
|
|
439
|
+
/>
|
|
440
|
+
)}
|
|
441
|
+
</>
|
|
429
442
|
) : errored ? (
|
|
430
443
|
<text content="probe failed" fg={selected ? theme.text : theme.bad} wrapMode="none" />
|
|
431
444
|
) : (
|