spinuptui 1.3.0-alpha.1 → 1.3.0-alpha.2
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 +13 -1
- package/README.md +6 -1
- package/package.json +1 -1
- package/src/config.ts +22 -0
- package/src/index.tsx +18 -39
- package/src/lib/cliScheduler.ts +534 -0
- package/src/lib/flock.ts +51 -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 +288 -0
- package/src/lib/schedulerRun.ts +246 -0
- package/src/lib/schedulerTick.ts +307 -0
- 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 +146 -0
- package/src/ui/App.tsx +5 -1
- package/src/ui/main.tsx +42 -0
- package/src/ui/store.tsx +6 -0
- package/src/ui/views/Modules.tsx +41 -5
- package/src/ui/views/SchedulerInstall.tsx +172 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// Scheduler's module declaration: the station's clock. Its status is the
|
|
2
|
+
// one-question answer to "is the station alive?" — how long ago the dispatcher
|
|
3
|
+
// last ticked.
|
|
4
|
+
|
|
5
|
+
import { existsSync } from "node:fs"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
import { tryLock } from "../lib/flock.ts"
|
|
8
|
+
import { loadConfig } from "../config.ts"
|
|
9
|
+
import { beaconFromConfig } from "../lib/beacon.ts"
|
|
10
|
+
import { nextRunText, parseWhen } from "../lib/schedule.ts"
|
|
11
|
+
import { listSources, loadJobs, loadState, readLastTick, type Source } from "../lib/schedulerJobs.ts"
|
|
12
|
+
import { readClaim, stationName } from "../lib/schedulerGit.ts"
|
|
13
|
+
import { moduleStateDir, moduleWorkDir, type ModuleCheck, type ModuleDefinition, type ModuleStatus } from "./contract.ts"
|
|
14
|
+
|
|
15
|
+
// Three missed ticks.
|
|
16
|
+
export const TICK_STALE_MS = 15 * 60_000
|
|
17
|
+
const RECENT_FAILURE_MS = 24 * 60 * 60_000
|
|
18
|
+
|
|
19
|
+
function claimText(s: Source): string {
|
|
20
|
+
const holder = readClaim(s)
|
|
21
|
+
if (s.queueOnly) return holder ? `run by ${holder}; released here, this machine only queues` : "released here; this machine only queues"
|
|
22
|
+
if (!holder) return "unclaimed: the next dispatcher tick here claims it"
|
|
23
|
+
return holder === stationName() ? "run by this station" : `run by ${holder}; this machine only queues`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function tickRunning(): boolean {
|
|
27
|
+
try {
|
|
28
|
+
const lock = tryLock(join(moduleStateDir("scheduler"), "tick.lock"))
|
|
29
|
+
if (!lock) return true
|
|
30
|
+
lock.release()
|
|
31
|
+
} catch {
|
|
32
|
+
// No lock support: assume nothing is running.
|
|
33
|
+
}
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function age(ms: number): string {
|
|
38
|
+
const m = Math.round(ms / 60_000)
|
|
39
|
+
if (m < 1) return "just now"
|
|
40
|
+
if (m < 120) return `${m} min ago`
|
|
41
|
+
const h = Math.round(m / 60)
|
|
42
|
+
return h < 48 ? `${h} h ago` : `${Math.round(h / 24)} days ago`
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const schedulerModule: ModuleDefinition = {
|
|
46
|
+
id: "scheduler",
|
|
47
|
+
name: "Scheduler",
|
|
48
|
+
summary: "Follow-ups that run themselves: the fix you shipped today gets checked tomorrow without you remembering.",
|
|
49
|
+
dependsOn: ["beacon"],
|
|
50
|
+
configKey: "scheduler",
|
|
51
|
+
stateDir: () => moduleStateDir("scheduler"),
|
|
52
|
+
workDir: () => moduleWorkDir("scheduler"),
|
|
53
|
+
jobs: [],
|
|
54
|
+
async status(): Promise<ModuleStatus> {
|
|
55
|
+
const cfg = loadConfig()
|
|
56
|
+
const d = cfg.scheduler?.dispatcher
|
|
57
|
+
const checks: ModuleCheck[] = []
|
|
58
|
+
const installed = !!d && existsSync(d.unitPath)
|
|
59
|
+
checks.push({
|
|
60
|
+
name: "dispatcher",
|
|
61
|
+
ok: installed,
|
|
62
|
+
detail: installed ? `${d!.label}, every 5 min` : d ? `unit file missing (${d.unitPath})` : "not installed",
|
|
63
|
+
})
|
|
64
|
+
const last = readLastTick()
|
|
65
|
+
const lastAge = last ? Date.now() - Date.parse(last.at) : null
|
|
66
|
+
const fresh = lastAge !== null && lastAge < TICK_STALE_MS
|
|
67
|
+
// A tick still holding its lock is alive: a long agent job can take 30
|
|
68
|
+
// minutes, and the station isn't dead while it runs.
|
|
69
|
+
const running = !fresh && tickRunning()
|
|
70
|
+
checks.push({
|
|
71
|
+
name: "last tick",
|
|
72
|
+
ok: !installed || fresh || running,
|
|
73
|
+
detail:
|
|
74
|
+
(last ? `${age(lastAge!)} (${last.via})${last.errors.length ? `, ${last.errors.length} problem${last.errors.length === 1 ? "" : "s"}: ${last.errors[0]}` : ""}` : "never") +
|
|
75
|
+
(running ? "; a tick is running now" : ""),
|
|
76
|
+
})
|
|
77
|
+
const now = new Date()
|
|
78
|
+
let active = 0
|
|
79
|
+
const failed: string[] = []
|
|
80
|
+
for (const s of listSources(cfg)) {
|
|
81
|
+
const exists = existsSync(s.path)
|
|
82
|
+
// The local folder appears on first use, so its absence is normal.
|
|
83
|
+
if (s.name !== "local" || exists) checks.push({ name: `source ${s.name}`, ok: exists, detail: exists ? `${s.path}${s.git ? ` (git, ${claimText(s)})` : ""}` : `${s.path} does not exist` })
|
|
84
|
+
if (!exists) continue
|
|
85
|
+
for (const j of loadJobs(s)) {
|
|
86
|
+
if (j.problems.length) {
|
|
87
|
+
failed.push(`${s.name}/${j.name} invalid`)
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
const st = loadState(s, j.header.id!)
|
|
91
|
+
let next = "?"
|
|
92
|
+
try {
|
|
93
|
+
next = nextRunText(j.header, st, now)
|
|
94
|
+
} catch {
|
|
95
|
+
// validateJob already accepted it; leave "?".
|
|
96
|
+
}
|
|
97
|
+
if (next !== "expired" && next !== "done") active++
|
|
98
|
+
// A failure was paged when it happened; it stays news for a day, not
|
|
99
|
+
// until the job expires.
|
|
100
|
+
const recent = st.last_run ? now.getTime() - parseWhen(st.last_run).getTime() < RECENT_FAILURE_MS : st.last_status === "auth"
|
|
101
|
+
if (st.last_status && st.last_status !== "ok" && next !== "expired" && recent) failed.push(`${s.name}/${j.header.id} ${st.last_status}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
checks.push({ name: "jobs", ok: failed.length === 0, detail: `${active} active${failed.length ? `; not ok in the last day: ${failed.slice(0, 3).join(", ")}` : ""}` })
|
|
105
|
+
const beacon = beaconFromConfig(cfg)
|
|
106
|
+
checks.push({ name: "digests", ok: beacon.channels.length > 0, detail: beacon.channels.length ? `via Beacon (${beacon.defaultChannel ?? "no default"})` : "Beacon isn't set up, so runs aren't reported" })
|
|
107
|
+
|
|
108
|
+
if (!installed && !d) {
|
|
109
|
+
return {
|
|
110
|
+
id: "scheduler",
|
|
111
|
+
state: "not_configured",
|
|
112
|
+
summary: "Dispatcher not installed",
|
|
113
|
+
checks,
|
|
114
|
+
remedy: "spinuptui scheduler install --plan",
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const failing = checks.find((c) => !c.ok)
|
|
118
|
+
return {
|
|
119
|
+
id: "scheduler",
|
|
120
|
+
state: failing ? "degraded" : "ok",
|
|
121
|
+
summary: failing ? `${failing.name}: ${failing.detail}` : `ticking (${checks[1]!.detail.split(" (")[0]}), ${active} active job${active === 1 ? "" : "s"}`,
|
|
122
|
+
checks,
|
|
123
|
+
...(failing ? { remedy: failing.name === "dispatcher" || failing.name === "last tick" ? "spinuptui scheduler install --apply" : "spinuptui scheduler list" } : {}),
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The next few follow-ups due, soonest first, for the Modules screen.
|
|
129
|
+
export function upcomingRuns(limit = 5): { source: string; id: string; next: string }[] {
|
|
130
|
+
const now = new Date()
|
|
131
|
+
const out: { source: string; id: string; next: string }[] = []
|
|
132
|
+
for (const s of listSources(loadConfig())) {
|
|
133
|
+
if (!existsSync(s.path)) continue
|
|
134
|
+
for (const j of loadJobs(s)) {
|
|
135
|
+
if (j.problems.length) continue
|
|
136
|
+
try {
|
|
137
|
+
const next = nextRunText(j.header, loadState(s, j.header.id!), now)
|
|
138
|
+
if (next !== "expired" && next !== "done") out.push({ source: s.name, id: j.header.id!, next })
|
|
139
|
+
} catch {
|
|
140
|
+
// Skip a job whose schedule can't be read.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Fixed times sort before "every N min", which has no single next time.
|
|
145
|
+
return out.sort((a, b) => (a.next.startsWith("every") ? 1 : 0) - (b.next.startsWith("every") ? 1 : 0) || a.next.localeCompare(b.next)).slice(0, limit)
|
|
146
|
+
}
|
package/src/ui/App.tsx
CHANGED
|
@@ -43,6 +43,7 @@ import { KumaSite } from "./views/KumaSite.tsx"
|
|
|
43
43
|
import { Accounts } from "./views/Accounts.tsx"
|
|
44
44
|
import { Modules } from "./views/Modules.tsx"
|
|
45
45
|
import { BeaconSetup } from "./views/BeaconSetup.tsx"
|
|
46
|
+
import { SchedulerInstall } from "./views/SchedulerInstall.tsx"
|
|
46
47
|
|
|
47
48
|
const MIN_SPLASH_MS = 1200
|
|
48
49
|
|
|
@@ -92,7 +93,8 @@ export function App() {
|
|
|
92
93
|
store.connectZoneTarget !== null ||
|
|
93
94
|
store.dnsRecordsTarget !== null ||
|
|
94
95
|
store.kumaSite !== null ||
|
|
95
|
-
store.beaconSetupOpen
|
|
96
|
+
store.beaconSetupOpen ||
|
|
97
|
+
store.schedulerInstallOpen
|
|
96
98
|
useEffect(() => {
|
|
97
99
|
store.setOverlayOpen(overlayActive)
|
|
98
100
|
}, [overlayActive, store])
|
|
@@ -210,6 +212,7 @@ export function App() {
|
|
|
210
212
|
|
|
211
213
|
// Beacon's guided setup (Modules tab) owns the keyboard while open.
|
|
212
214
|
if (store.beaconSetupOpen) return
|
|
215
|
+
if (store.schedulerInstallOpen) return
|
|
213
216
|
|
|
214
217
|
// A (Shift+a): the Accounts overlay — the header advertises it from every tab.
|
|
215
218
|
if (key.shift && key.name === "a") return setShowAccounts(true)
|
|
@@ -299,6 +302,7 @@ export function App() {
|
|
|
299
302
|
{store.dnsRecordsTarget && <DnsRecords />}
|
|
300
303
|
{store.kumaSite && <KumaSite />}
|
|
301
304
|
{store.beaconSetupOpen && <BeaconSetup />}
|
|
305
|
+
{store.schedulerInstallOpen && <SchedulerInstall />}
|
|
302
306
|
{showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
|
|
303
307
|
<ToastStack />
|
|
304
308
|
</box>
|
package/src/ui/main.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The dashboard: boots the OpenTUI renderer and mounts either the first-run
|
|
2
|
+
// onboarding wizard (no token yet) or the main app wrapped in the data store.
|
|
3
|
+
// Loaded by src/index.tsx with a dynamic import, only once no headless command
|
|
4
|
+
// has claimed the arguments.
|
|
5
|
+
|
|
6
|
+
import { useCallback, useMemo, useState } from "react"
|
|
7
|
+
import { createCliRenderer } from "@opentui/core"
|
|
8
|
+
import { createRoot } from "@opentui/react"
|
|
9
|
+
import { hasToken, pinActiveProfile, setActiveProfile } from "../config.ts"
|
|
10
|
+
import { StoreProvider } from "./store.tsx"
|
|
11
|
+
import { App } from "./App.tsx"
|
|
12
|
+
import { AccountsContext, type AccountsApi } from "./accounts.tsx"
|
|
13
|
+
import { resetWpCoreJobs } from "./wpCoreJobs.tsx"
|
|
14
|
+
import { Onboarding } from "./Onboarding.tsx"
|
|
15
|
+
import { isDevMode } from "../dev/devMode.ts"
|
|
16
|
+
|
|
17
|
+
export async function startDashboard({ forceLogin }: { forceLogin: boolean }): Promise<void> {
|
|
18
|
+
function Root() {
|
|
19
|
+
const [configured, setConfigured] = useState(isDevMode() ? true : forceLogin ? false : hasToken())
|
|
20
|
+
// The store is keyed by account: switching remounts it (see ui/accounts.tsx).
|
|
21
|
+
const [account, setAccount] = useState(() => pinActiveProfile())
|
|
22
|
+
const switchAccount = useCallback(async (id: string) => {
|
|
23
|
+
await setActiveProfile(id)
|
|
24
|
+
resetWpCoreJobs()
|
|
25
|
+
setAccount(id)
|
|
26
|
+
}, [])
|
|
27
|
+
const accounts = useMemo<AccountsApi>(() => ({ switchAccount }), [switchAccount])
|
|
28
|
+
if (!configured) {
|
|
29
|
+
return <Onboarding onComplete={() => setConfigured(true)} />
|
|
30
|
+
}
|
|
31
|
+
return (
|
|
32
|
+
<AccountsContext.Provider value={accounts}>
|
|
33
|
+
<StoreProvider key={account}>
|
|
34
|
+
<App />
|
|
35
|
+
</StoreProvider>
|
|
36
|
+
</AccountsContext.Provider>
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const renderer = await createCliRenderer()
|
|
41
|
+
createRoot(renderer).render(<Root />)
|
|
42
|
+
}
|
package/src/ui/store.tsx
CHANGED
|
@@ -748,6 +748,9 @@ 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
|
|
751
754
|
// Whether the "needs a local copy" (forgotten) report overlay is open, and an
|
|
752
755
|
// optional stack filter (set from the selected Stacks group when opened).
|
|
753
756
|
forgottenOpen: boolean
|
|
@@ -1118,6 +1121,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1118
1121
|
const [localRoots, setLocalRoots] = useState<string[]>(() => [...cfgRef.current.localRoots])
|
|
1119
1122
|
const [discoverOpen, setDiscoverOpen] = useState(false)
|
|
1120
1123
|
const [beaconSetupOpen, setBeaconSetupOpen] = useState(false)
|
|
1124
|
+
const [schedulerInstallOpen, setSchedulerInstallOpen] = useState(false)
|
|
1121
1125
|
const [forgottenOpen, setForgottenOpen] = useState(false)
|
|
1122
1126
|
const [forgottenStack, setForgottenStack] = useState<Stack | null>(null)
|
|
1123
1127
|
const [linkReturnToForgotten, setLinkReturnToForgotten] = useState(false)
|
|
@@ -4540,6 +4544,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4540
4544
|
setDiscoverOpen,
|
|
4541
4545
|
beaconSetupOpen,
|
|
4542
4546
|
setBeaconSetupOpen,
|
|
4547
|
+
schedulerInstallOpen,
|
|
4548
|
+
setSchedulerInstallOpen,
|
|
4543
4549
|
forgottenOpen,
|
|
4544
4550
|
setForgottenOpen,
|
|
4545
4551
|
forgottenStack,
|
package/src/ui/views/Modules.tsx
CHANGED
|
@@ -16,6 +16,7 @@ import { MODULES, UPCOMING_MODULES } from "../../modules/index.ts"
|
|
|
16
16
|
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
|
+
import { upcomingRuns } from "../../modules/scheduler.ts"
|
|
19
20
|
|
|
20
21
|
interface Card {
|
|
21
22
|
id: ModuleId
|
|
@@ -42,16 +43,16 @@ function stateLabel(card: Card, status: ModuleStatus | undefined): { dot: string
|
|
|
42
43
|
|
|
43
44
|
export function Modules({ rows }: { rows: number }) {
|
|
44
45
|
const store = useStore()
|
|
45
|
-
const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen } = store
|
|
46
|
+
const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen, schedulerInstallOpen, setSchedulerInstallOpen } = store
|
|
46
47
|
const [selected, setSelected] = useState(0)
|
|
47
48
|
const [statuses, setStatuses] = useState<Map<ModuleId, ModuleStatus>>(new Map())
|
|
48
49
|
const [testing, setTesting] = useState(false)
|
|
49
50
|
|
|
50
|
-
// Recompute on open and whenever
|
|
51
|
-
//
|
|
51
|
+
// Recompute on open and whenever a setup overlay closes (it may have changed
|
|
52
|
+
// the config). status() is cheap and never sends anything.
|
|
52
53
|
const [tick, setTick] = useState(0)
|
|
53
54
|
useEffect(() => {
|
|
54
|
-
if (beaconSetupOpen) return
|
|
55
|
+
if (beaconSetupOpen || schedulerInstallOpen) return
|
|
55
56
|
let live = true
|
|
56
57
|
void Promise.all(MODULES.map((m) => m.status())).then((list) => {
|
|
57
58
|
if (live) setStatuses(new Map(list.map((s) => [s.id, s])))
|
|
@@ -59,7 +60,7 @@ export function Modules({ rows }: { rows: number }) {
|
|
|
59
60
|
return () => {
|
|
60
61
|
live = false
|
|
61
62
|
}
|
|
62
|
-
}, [beaconSetupOpen, tick])
|
|
63
|
+
}, [beaconSetupOpen, schedulerInstallOpen, tick])
|
|
63
64
|
|
|
64
65
|
const isActive = route === "modules" && !inputMode && !overlayOpen
|
|
65
66
|
const card = CARDS[selected]!
|
|
@@ -88,6 +89,7 @@ export function Modules({ rows }: { rows: number }) {
|
|
|
88
89
|
case "return":
|
|
89
90
|
case "s":
|
|
90
91
|
if (card.id === "beacon") return setBeaconSetupOpen(true)
|
|
92
|
+
if (card.id === "scheduler") return setSchedulerInstallOpen(true)
|
|
91
93
|
return
|
|
92
94
|
case "t":
|
|
93
95
|
if (card.id === "beacon" && status && status.state !== "not_configured") return sendTest()
|
|
@@ -140,6 +142,7 @@ export function Modules({ rows }: { rows: number }) {
|
|
|
140
142
|
hints={[
|
|
141
143
|
{ key: "↑↓/jk", label: "select" },
|
|
142
144
|
...(card.id === "beacon" ? [{ key: "⏎", label: beaconReady ? "reconfigure" : "set up" }] : []),
|
|
145
|
+
...(card.id === "scheduler" ? [{ key: "⏎", label: status && status.state !== "not_configured" ? "reinstall" : "install" }] : []),
|
|
143
146
|
...(beaconReady ? [{ key: "t", label: testing ? "sending…" : "test page" }] : []),
|
|
144
147
|
]}
|
|
145
148
|
/>
|
|
@@ -191,6 +194,39 @@ function ModuleDetail({ card, status }: { card: Card; status: ModuleStatus | und
|
|
|
191
194
|
{card.id === "beacon" && status.state !== "not_configured" && (
|
|
192
195
|
<text content="⏎ reconfigure the pager · t send a test page · CLI: spinuptui beacon status" fg={theme.textFaint} />
|
|
193
196
|
)}
|
|
197
|
+
{card.id === "scheduler" && <SchedulerDetail installed={status.state !== "not_configured"} />}
|
|
194
198
|
</box>
|
|
195
199
|
)
|
|
196
200
|
}
|
|
201
|
+
|
|
202
|
+
function SchedulerDetail({ installed }: { installed: boolean }) {
|
|
203
|
+
const [runs] = useState(() => upcomingRuns())
|
|
204
|
+
return (
|
|
205
|
+
<>
|
|
206
|
+
<box style={{ height: 1 }} />
|
|
207
|
+
{!installed && (
|
|
208
|
+
<>
|
|
209
|
+
<text
|
|
210
|
+
content="Scheduler runs follow-ups on this machine while you're away: queue a check for tomorrow at 8 and the result arrives on your pager. It's also the clock the other modules run on, so turning it on is what makes this machine a station."
|
|
211
|
+
fg={theme.textDim}
|
|
212
|
+
/>
|
|
213
|
+
<box style={{ height: 1 }} />
|
|
214
|
+
<text content="Press ⏎ to see exactly what it installs, then confirm." fg={theme.accent} />
|
|
215
|
+
<box style={{ height: 1 }} />
|
|
216
|
+
</>
|
|
217
|
+
)}
|
|
218
|
+
<text content={runs.length ? "Next up:" : "No follow-ups queued."} fg={theme.textDim} />
|
|
219
|
+
{runs.map((r) => (
|
|
220
|
+
<box key={`${r.source}/${r.id}`} style={{ flexDirection: "row" }}>
|
|
221
|
+
<text content={` ${r.next.padEnd(17)} `} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
222
|
+
<text content={`${r.source}/${r.id}`} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
223
|
+
</box>
|
|
224
|
+
))}
|
|
225
|
+
<box style={{ height: 1 }} />
|
|
226
|
+
<text
|
|
227
|
+
content={`${installed ? "⏎ reinstall (repairs the unit) · " : ""}CLI: spinuptui scheduler add | list | status`}
|
|
228
|
+
fg={theme.textFaint}
|
|
229
|
+
/>
|
|
230
|
+
</>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Scheduler's install overlay (Modules tab, ⏎ on Scheduler): shows exactly what
|
|
2
|
+
// `spinuptui scheduler install --plan` would write and run, and applies it on
|
|
3
|
+
// `y` — the confirm-before-firing pattern. Applying ends with the proving tick
|
|
4
|
+
// (a tick that really ran under launchd), so "done" here means the station's
|
|
5
|
+
// clock is running, not just that a file was written.
|
|
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 { applyInstall, binHasScheduler, NO_SCHEDULER, planInstall, type ApplyResult, type PlanResult } from "../../lib/schedulerInstall.ts"
|
|
15
|
+
|
|
16
|
+
type Phase = "confirm" | "applying" | "done" | "error"
|
|
17
|
+
|
|
18
|
+
export function SchedulerInstall() {
|
|
19
|
+
const { setSchedulerInstallOpen } = useStore()
|
|
20
|
+
const [plan, setPlan] = useState<PlanResult>(() => planInstall())
|
|
21
|
+
// Same refusal as `install --plan`: don't offer a plan whose binary has no
|
|
22
|
+
// Scheduler (it would launch the dashboard under launchd).
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (!plan.ok) return
|
|
25
|
+
void binHasScheduler(plan).then((has) => {
|
|
26
|
+
if (!has) {
|
|
27
|
+
setPlan(NO_SCHEDULER)
|
|
28
|
+
setPhase("error")
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
// Checked once, for the plan computed on open.
|
|
32
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
33
|
+
}, [])
|
|
34
|
+
const [phase, setPhase] = useState<Phase>(plan.ok ? "confirm" : "error")
|
|
35
|
+
const [stage, setStage] = useState("")
|
|
36
|
+
const [result, setResult] = useState<ApplyResult | null>(null)
|
|
37
|
+
const [live, setLive] = useState(true)
|
|
38
|
+
useEffect(() => () => setLive(false), [])
|
|
39
|
+
|
|
40
|
+
const close = () => setSchedulerInstallOpen(false)
|
|
41
|
+
|
|
42
|
+
const apply = () => {
|
|
43
|
+
if (!plan.ok) return
|
|
44
|
+
setPhase("applying")
|
|
45
|
+
void applyInstall(plan, (s) => live && setStage(s)).then((r) => {
|
|
46
|
+
setResult(r)
|
|
47
|
+
setPhase(r.ok ? "done" : "error")
|
|
48
|
+
})
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
useKeyboard((key) => {
|
|
52
|
+
const name = key.name ?? ""
|
|
53
|
+
if (name === "escape" || name === "q") return close()
|
|
54
|
+
if (phase === "confirm" && name === "y" && plan.ok && plan.platform === "darwin") return apply()
|
|
55
|
+
if (phase === "error" && name === "r" && plan.ok) return setPhase("confirm")
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<box
|
|
60
|
+
style={{
|
|
61
|
+
position: "absolute",
|
|
62
|
+
top: 0,
|
|
63
|
+
left: 0,
|
|
64
|
+
width: "100%",
|
|
65
|
+
height: "100%",
|
|
66
|
+
flexDirection: "column",
|
|
67
|
+
backgroundColor: theme.bg,
|
|
68
|
+
zIndex: 210,
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
72
|
+
<text content="⏱ Scheduler " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
73
|
+
<text content="install the station's clock" fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
74
|
+
</box>
|
|
75
|
+
<Centered>{body()}</Centered>
|
|
76
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
77
|
+
</box>
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
function body() {
|
|
81
|
+
if (phase === "applying") {
|
|
82
|
+
return (
|
|
83
|
+
<box style={{ flexDirection: "column", alignItems: "center" }}>
|
|
84
|
+
<box style={{ flexDirection: "row" }}>
|
|
85
|
+
<Spinner />
|
|
86
|
+
<text content={` ${stage || "Installing…"}`} fg={theme.textDim} wrapMode="none" />
|
|
87
|
+
</box>
|
|
88
|
+
<box style={{ height: 1 }} />
|
|
89
|
+
<text content="Install finishes with a real tick from launchd (up to a minute)." fg={theme.textFaint} wrapMode="none" />
|
|
90
|
+
</box>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
if (phase === "done" && result?.ok) {
|
|
94
|
+
return (
|
|
95
|
+
<Panel title=" Installed " active>
|
|
96
|
+
<box style={{ flexDirection: "column", width: 70, paddingTop: 1, paddingBottom: 1 }}>
|
|
97
|
+
<text content={`✓ ${result.label} is ticking: its first run under launchd was at ${new Date(result.tickAt).toLocaleTimeString()}.`} fg={theme.good} />
|
|
98
|
+
<box style={{ height: 1 }} />
|
|
99
|
+
<text content="Queue a follow-up from any session:" fg={theme.textDim} />
|
|
100
|
+
<text content={' spinuptui scheduler add <id> --kind spinuptui --when "YYYY-MM-DD 08:00" \\'} fg={theme.accent} wrapMode="none" />
|
|
101
|
+
<text content={' --expires YYYY-MM-DD --created-by "…" -- incidents example.com'} fg={theme.accent} wrapMode="none" />
|
|
102
|
+
<box style={{ height: 1 }} />
|
|
103
|
+
{result.notes.map((n) => (
|
|
104
|
+
<text key={n} content={`Note: ${n}`} fg={theme.textFaint} />
|
|
105
|
+
))}
|
|
106
|
+
<text content="Esc to close" fg={theme.textFaint} />
|
|
107
|
+
</box>
|
|
108
|
+
</Panel>
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
if (phase === "error" || !plan.ok) {
|
|
112
|
+
const r = !plan.ok ? plan : result && !result.ok ? result : null
|
|
113
|
+
return (
|
|
114
|
+
<Panel title=" Can't install " active>
|
|
115
|
+
<box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
|
|
116
|
+
<text content={`✕ ${r?.message ?? "Something went wrong."}`} fg={theme.bad} />
|
|
117
|
+
{r?.remedy && (
|
|
118
|
+
<>
|
|
119
|
+
<box style={{ height: 1 }} />
|
|
120
|
+
<text content={r.remedy} fg={theme.textDim} />
|
|
121
|
+
</>
|
|
122
|
+
)}
|
|
123
|
+
<box style={{ height: 1 }} />
|
|
124
|
+
<text content={plan.ok ? "Press r to see the plan again · Esc to close" : "Esc to close"} fg={theme.textFaint} />
|
|
125
|
+
</box>
|
|
126
|
+
</Panel>
|
|
127
|
+
)
|
|
128
|
+
}
|
|
129
|
+
const darwin = plan.platform === "darwin"
|
|
130
|
+
return (
|
|
131
|
+
<Panel title=" Install Scheduler's dispatcher " active>
|
|
132
|
+
<box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
|
|
133
|
+
<text content="Runs at login and every 5 minutes, unattended:" fg={theme.text} />
|
|
134
|
+
<text content={` ${truncate(plan.programArguments.join(" "), 72)}`} fg={theme.accent} wrapMode="none" />
|
|
135
|
+
<box style={{ height: 1 }} />
|
|
136
|
+
<text content={`Unit: ${truncate(plan.unitPath, 68)}`} fg={theme.textDim} wrapMode="none" />
|
|
137
|
+
<text content={`PATH: ${truncate(plan.env.PATH ?? "", 68)}`} fg={theme.textDim} wrapMode="none" />
|
|
138
|
+
<box style={{ height: 1 }} />
|
|
139
|
+
{plan.commands.map((c) => (
|
|
140
|
+
<text key={c} content={`· ${truncate(c, 72)}`} fg={theme.textDim} wrapMode="none" />
|
|
141
|
+
))}
|
|
142
|
+
<box style={{ height: 1 }} />
|
|
143
|
+
{plan.notes.map((n) => (
|
|
144
|
+
<text key={n} content={`Note: ${n}`} fg={theme.textFaint} />
|
|
145
|
+
))}
|
|
146
|
+
<box style={{ height: 1 }} />
|
|
147
|
+
<text
|
|
148
|
+
content={darwin ? "Press y to install · Esc to cancel" : "Installing on Linux isn't supported yet · Esc to close"}
|
|
149
|
+
fg={darwin ? theme.text : theme.warn}
|
|
150
|
+
wrapMode="none"
|
|
151
|
+
/>
|
|
152
|
+
</box>
|
|
153
|
+
</Panel>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function hints() {
|
|
158
|
+
if (phase === "confirm" && plan.ok && plan.platform === "darwin") {
|
|
159
|
+
return [
|
|
160
|
+
{ key: "y", label: "install" },
|
|
161
|
+
{ key: "esc", label: "cancel" },
|
|
162
|
+
]
|
|
163
|
+
}
|
|
164
|
+
if (phase === "error" && plan.ok) {
|
|
165
|
+
return [
|
|
166
|
+
{ key: "r", label: "back to plan" },
|
|
167
|
+
{ key: "esc", label: "close" },
|
|
168
|
+
]
|
|
169
|
+
}
|
|
170
|
+
return [{ key: "esc", label: "close" }]
|
|
171
|
+
}
|
|
172
|
+
}
|