spinuptui 1.2.0 → 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 +31 -1
- package/README.md +13 -1
- package/package.json +1 -1
- package/src/config.ts +45 -0
- package/src/index.tsx +37 -38
- package/src/lib/appUpdate.test.ts +26 -0
- package/src/lib/appUpdate.ts +33 -7
- package/src/lib/beacon.test.ts +140 -0
- package/src/lib/beacon.ts +349 -0
- package/src/lib/beaconSetup.test.ts +65 -0
- package/src/lib/beaconSetup.ts +143 -0
- package/src/lib/cliBeacon.ts +290 -0
- package/src/lib/cliScheduler.ts +534 -0
- package/src/lib/flock.ts +51 -0
- package/src/lib/prompt.ts +47 -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 +52 -0
- package/src/modules/contract.ts +82 -0
- package/src/modules/index.ts +29 -0
- package/src/modules/scheduler.ts +146 -0
- package/src/ui/App.tsx +15 -1
- package/src/ui/Explain.tsx +15 -2
- package/src/ui/Header.tsx +2 -0
- package/src/ui/Help.tsx +1 -1
- package/src/ui/main.tsx +42 -0
- package/src/ui/store.tsx +13 -1
- package/src/ui/views/BeaconSetup.tsx +268 -0
- package/src/ui/views/Modules.tsx +232 -0
- package/src/ui/views/SchedulerInstall.tsx +172 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Every module SpinupTUI ships, in dependency order, plus the ones still to come
|
|
2
|
+
// (shown as "coming soon" on the Modules screen and in `spinuptui modules`).
|
|
3
|
+
|
|
4
|
+
import type { ModuleDefinition, UpcomingModule } from "./contract.ts"
|
|
5
|
+
import { beaconModule } from "./beacon.ts"
|
|
6
|
+
import { schedulerModule } from "./scheduler.ts"
|
|
7
|
+
|
|
8
|
+
export const MODULES: ModuleDefinition[] = [beaconModule, schedulerModule]
|
|
9
|
+
|
|
10
|
+
// Outcome copy only — the same lines feed the landing page. An entry moves to
|
|
11
|
+
// MODULES (and out of here) when the module ships.
|
|
12
|
+
export const UPCOMING_MODULES: UpcomingModule[] = [
|
|
13
|
+
{
|
|
14
|
+
id: "sentinel",
|
|
15
|
+
name: "Sentinel",
|
|
16
|
+
summary: "Watches the fleet, works out what actually broke, and writes it up.",
|
|
17
|
+
dependsOn: ["beacon", "scheduler"],
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
id: "samurai",
|
|
21
|
+
name: "Samurai",
|
|
22
|
+
summary: "Defends your sites at the nginx layer, proactively and when Sentinel spots an attack, with your approval.",
|
|
23
|
+
dependsOn: ["beacon", "scheduler", "sentinel"],
|
|
24
|
+
},
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
export async function allModuleStatuses() {
|
|
28
|
+
return Promise.all(MODULES.map(async (m) => ({ name: m.name, about: m.summary, dependsOn: m.dependsOn, ...(await m.status()) })))
|
|
29
|
+
}
|
|
@@ -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
|
@@ -41,6 +41,9 @@ import { ProviderConnect } from "./views/ProviderConnect.tsx"
|
|
|
41
41
|
import { DnsRecords } from "./views/DnsRecords.tsx"
|
|
42
42
|
import { KumaSite } from "./views/KumaSite.tsx"
|
|
43
43
|
import { Accounts } from "./views/Accounts.tsx"
|
|
44
|
+
import { Modules } from "./views/Modules.tsx"
|
|
45
|
+
import { BeaconSetup } from "./views/BeaconSetup.tsx"
|
|
46
|
+
import { SchedulerInstall } from "./views/SchedulerInstall.tsx"
|
|
44
47
|
|
|
45
48
|
const MIN_SPLASH_MS = 1200
|
|
46
49
|
|
|
@@ -89,7 +92,9 @@ export function App() {
|
|
|
89
92
|
store.dnsInventoryServer !== null ||
|
|
90
93
|
store.connectZoneTarget !== null ||
|
|
91
94
|
store.dnsRecordsTarget !== null ||
|
|
92
|
-
store.kumaSite !== null
|
|
95
|
+
store.kumaSite !== null ||
|
|
96
|
+
store.beaconSetupOpen ||
|
|
97
|
+
store.schedulerInstallOpen
|
|
93
98
|
useEffect(() => {
|
|
94
99
|
store.setOverlayOpen(overlayActive)
|
|
95
100
|
}, [overlayActive, store])
|
|
@@ -205,6 +210,10 @@ export function App() {
|
|
|
205
210
|
// The site-monitoring (Uptime Kuma) overlay owns the keyboard while open.
|
|
206
211
|
if (store.kumaSite) return
|
|
207
212
|
|
|
213
|
+
// Beacon's guided setup (Modules tab) owns the keyboard while open.
|
|
214
|
+
if (store.beaconSetupOpen) return
|
|
215
|
+
if (store.schedulerInstallOpen) return
|
|
216
|
+
|
|
208
217
|
// A (Shift+a): the Accounts overlay — the header advertises it from every tab.
|
|
209
218
|
if (key.shift && key.name === "a") return setShowAccounts(true)
|
|
210
219
|
|
|
@@ -221,6 +230,8 @@ export function App() {
|
|
|
221
230
|
return store.setRoute("search")
|
|
222
231
|
case "5":
|
|
223
232
|
return store.setRoute("events")
|
|
233
|
+
case "6":
|
|
234
|
+
return store.setRoute("modules")
|
|
224
235
|
case "r":
|
|
225
236
|
return void store.refresh()
|
|
226
237
|
case "?":
|
|
@@ -260,6 +271,7 @@ export function App() {
|
|
|
260
271
|
{store.route === "stacks" && <Stacks rows={contentRows} />}
|
|
261
272
|
{store.route === "search" && <Search rows={contentRows} />}
|
|
262
273
|
{store.route === "events" && <Events rows={contentRows} />}
|
|
274
|
+
{store.route === "modules" && <Modules rows={contentRows} />}
|
|
263
275
|
</box>
|
|
264
276
|
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
|
|
265
277
|
{showExplain && <ExplainOverlay route={store.route} />}
|
|
@@ -289,6 +301,8 @@ export function App() {
|
|
|
289
301
|
{store.connectZoneTarget && <ProviderConnect />}
|
|
290
302
|
{store.dnsRecordsTarget && <DnsRecords />}
|
|
291
303
|
{store.kumaSite && <KumaSite />}
|
|
304
|
+
{store.beaconSetupOpen && <BeaconSetup />}
|
|
305
|
+
{store.schedulerInstallOpen && <SchedulerInstall />}
|
|
292
306
|
{showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
|
|
293
307
|
<ToastStack />
|
|
294
308
|
</box>
|
package/src/ui/Explain.tsx
CHANGED
|
@@ -27,7 +27,7 @@ const GUIDES: Record<Route, Guide> = {
|
|
|
27
27
|
["Recent activity", "The latest deploys and operations"],
|
|
28
28
|
],
|
|
29
29
|
actions: [
|
|
30
|
-
["1–
|
|
30
|
+
["1–6", "Switch tabs"],
|
|
31
31
|
["r", "Refresh from the API"],
|
|
32
32
|
],
|
|
33
33
|
},
|
|
@@ -91,10 +91,23 @@ const GUIDES: Record<Route, Guide> = {
|
|
|
91
91
|
title: "Events",
|
|
92
92
|
summary: "A live feed of deploys, reboots, and operations across your account.",
|
|
93
93
|
actions: [
|
|
94
|
-
["1–
|
|
94
|
+
["1–6", "Switch tabs"],
|
|
95
95
|
["r", "Refresh"],
|
|
96
96
|
],
|
|
97
97
|
},
|
|
98
|
+
modules: {
|
|
99
|
+
title: "Modules",
|
|
100
|
+
summary: "Modules are the parts of SpinupTUI that keep working after you close it: paging you, running follow-ups, watching the fleet. Each card says what it does and whether it's set up on this machine.",
|
|
101
|
+
panes: [
|
|
102
|
+
["Modules", "Every module, with its status here: ● set up · ○ not set up · coming soon"],
|
|
103
|
+
["Detail", "What the selected module does, its health checks, and what to do next"],
|
|
104
|
+
],
|
|
105
|
+
actions: [
|
|
106
|
+
["⏎ / s", "Set up the selected module (Beacon: connect a Telegram bot)"],
|
|
107
|
+
["t", "Send a test page through Beacon"],
|
|
108
|
+
["1–6", "Switch tabs"],
|
|
109
|
+
],
|
|
110
|
+
},
|
|
98
111
|
}
|
|
99
112
|
|
|
100
113
|
export function ExplainOverlay({ route }: { route: Route }) {
|
package/src/ui/Header.tsx
CHANGED
|
@@ -15,6 +15,7 @@ const TABS: { route: Route; key: string; label: string }[] = [
|
|
|
15
15
|
{ route: "stacks", key: "3", label: "Stacks" },
|
|
16
16
|
{ route: "search", key: "4", label: "Search" },
|
|
17
17
|
{ route: "events", key: "5", label: "Events" },
|
|
18
|
+
{ route: "modules", key: "6", label: "Modules" },
|
|
18
19
|
]
|
|
19
20
|
|
|
20
21
|
// A one-line "what is this view, and what can I do here" subtitle per tab —
|
|
@@ -26,6 +27,7 @@ const SUBTITLES: Record<Route, string> = {
|
|
|
26
27
|
stacks: "Your fleet grouped by app type · d identify an app (SSH) · S find local copies on your disk",
|
|
27
28
|
search: "Jump to any server or site by name · Tab hands focus to the result's actions",
|
|
28
29
|
events: "Recent deploys, reboots, and operations across your account",
|
|
30
|
+
modules: "What keeps working after you close SpinupTUI · ⏎ set up the selected module",
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
// SpinupWP allows 60 API requests a minute. Show what's left of this minute so
|
package/src/ui/Help.tsx
CHANGED
|
@@ -15,7 +15,7 @@ type Section = { title: string; keys: [string, string][] }
|
|
|
15
15
|
const GLOBAL: Section = {
|
|
16
16
|
title: "Global",
|
|
17
17
|
keys: [
|
|
18
|
-
["1 …
|
|
18
|
+
["1 … 6", "Switch tabs"],
|
|
19
19
|
["r", "Refresh from the API"],
|
|
20
20
|
["/", "Global search"],
|
|
21
21
|
["A", "Switch SpinupWP accounts"],
|
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
|
@@ -60,7 +60,7 @@ import { planDbSync, runDbSync, isDbSyncInFlight, type DbSyncProgress, type Sync
|
|
|
60
60
|
import { planLocalSetup, runLocalSetup, isLocalSetupInFlight, type LocalSetupProgress, type LocalSetupPlanResult } from "../lib/localSetup.ts"
|
|
61
61
|
import { planMediaFallback, type MediaFallbackResult } from "../lib/mediaFallback.ts"
|
|
62
62
|
|
|
63
|
-
export type Route = "dashboard" | "servers" | "stacks" | "search" | "events"
|
|
63
|
+
export type Route = "dashboard" | "servers" | "stacks" | "search" | "events" | "modules"
|
|
64
64
|
|
|
65
65
|
// Progress of a PHP-version upgrade, tracked in the store so it survives the
|
|
66
66
|
// modal being closed. `status` mirrors the SpinupWP event status
|
|
@@ -745,6 +745,12 @@ interface StoreValue extends DataState {
|
|
|
745
745
|
// Whether the local-copy discovery overlay is open.
|
|
746
746
|
discoverOpen: boolean
|
|
747
747
|
setDiscoverOpen: (v: boolean) => void
|
|
748
|
+
// Beacon's guided setup overlay, opened from the Modules tab.
|
|
749
|
+
beaconSetupOpen: boolean
|
|
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
|
|
748
754
|
// Whether the "needs a local copy" (forgotten) report overlay is open, and an
|
|
749
755
|
// optional stack filter (set from the selected Stacks group when opened).
|
|
750
756
|
forgottenOpen: boolean
|
|
@@ -1114,6 +1120,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1114
1120
|
const [mediaFallbackSite, setMediaFallbackSite] = useState<Site | null>(null)
|
|
1115
1121
|
const [localRoots, setLocalRoots] = useState<string[]>(() => [...cfgRef.current.localRoots])
|
|
1116
1122
|
const [discoverOpen, setDiscoverOpen] = useState(false)
|
|
1123
|
+
const [beaconSetupOpen, setBeaconSetupOpen] = useState(false)
|
|
1124
|
+
const [schedulerInstallOpen, setSchedulerInstallOpen] = useState(false)
|
|
1117
1125
|
const [forgottenOpen, setForgottenOpen] = useState(false)
|
|
1118
1126
|
const [forgottenStack, setForgottenStack] = useState<Stack | null>(null)
|
|
1119
1127
|
const [linkReturnToForgotten, setLinkReturnToForgotten] = useState(false)
|
|
@@ -4534,6 +4542,10 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4534
4542
|
addLocalRoot,
|
|
4535
4543
|
discoverOpen,
|
|
4536
4544
|
setDiscoverOpen,
|
|
4545
|
+
beaconSetupOpen,
|
|
4546
|
+
setBeaconSetupOpen,
|
|
4547
|
+
schedulerInstallOpen,
|
|
4548
|
+
setSchedulerInstallOpen,
|
|
4537
4549
|
forgottenOpen,
|
|
4538
4550
|
setForgottenOpen,
|
|
4539
4551
|
forgottenStack,
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// Beacon setup overlay, opened with ⏎ on Beacon in the Modules tab. The same
|
|
2
|
+
// guided flow as `spinuptui beacon setup` (lib/beaconSetup.ts): paste a bot
|
|
3
|
+
// token, message the bot so Beacon can find the chat, then a test page — and
|
|
4
|
+
// only then is anything saved.
|
|
5
|
+
|
|
6
|
+
import { useEffect, useRef, useState } from "react"
|
|
7
|
+
import { useKeyboard } from "@opentui/react"
|
|
8
|
+
import { theme } from "../../lib/theme.ts"
|
|
9
|
+
import { Panel, Centered, Spinner, SecretInput } from "../components.tsx"
|
|
10
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
11
|
+
import { useStore } from "../store.tsx"
|
|
12
|
+
import { openUrl } from "../../lib/open.ts"
|
|
13
|
+
import {
|
|
14
|
+
BOTFATHER_STEPS,
|
|
15
|
+
BOTFATHER_URL,
|
|
16
|
+
botLink,
|
|
17
|
+
channelExists,
|
|
18
|
+
discoverChat,
|
|
19
|
+
saveBeaconChannel,
|
|
20
|
+
sendConnectedMessage,
|
|
21
|
+
setupChannelName,
|
|
22
|
+
verifyBotToken,
|
|
23
|
+
type FoundChat,
|
|
24
|
+
} from "../../lib/beaconSetup.ts"
|
|
25
|
+
|
|
26
|
+
type Phase = "token" | "verifying" | "waiting" | "found" | "saving" | "done" | "error"
|
|
27
|
+
|
|
28
|
+
const WIDTH = 70
|
|
29
|
+
|
|
30
|
+
export function BeaconSetup() {
|
|
31
|
+
const { setBeaconSetupOpen, setInputMode } = useStore()
|
|
32
|
+
const [name] = useState(setupChannelName)
|
|
33
|
+
const [replacing] = useState(() => channelExists(name))
|
|
34
|
+
const [phase, setPhase] = useState<Phase>("token")
|
|
35
|
+
const [token, setToken] = useState("")
|
|
36
|
+
const [username, setUsername] = useState("")
|
|
37
|
+
const [chat, setChat] = useState<FoundChat | null>(null)
|
|
38
|
+
const [error, setError] = useState<string | null>(null)
|
|
39
|
+
const [retryFrom, setRetryFrom] = useState<Phase>("token")
|
|
40
|
+
const abort = useRef<AbortController | null>(null)
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
setInputMode(phase === "token")
|
|
44
|
+
return () => setInputMode(false)
|
|
45
|
+
}, [phase, setInputMode])
|
|
46
|
+
|
|
47
|
+
// Stop any long-poll when the overlay goes away.
|
|
48
|
+
useEffect(() => () => abort.current?.abort(), [])
|
|
49
|
+
|
|
50
|
+
const close = () => {
|
|
51
|
+
abort.current?.abort()
|
|
52
|
+
setInputMode(false)
|
|
53
|
+
setBeaconSetupOpen(false)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const fail = (message: string, from: Phase) => {
|
|
57
|
+
setError(message)
|
|
58
|
+
setRetryFrom(from)
|
|
59
|
+
setPhase("error")
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const listen = (tok: string) => {
|
|
63
|
+
abort.current?.abort()
|
|
64
|
+
const ctrl = new AbortController()
|
|
65
|
+
abort.current = ctrl
|
|
66
|
+
setChat(null)
|
|
67
|
+
setPhase("waiting")
|
|
68
|
+
void discoverChat(tok, { signal: ctrl.signal }).then((r) => {
|
|
69
|
+
if (ctrl.signal.aborted) return
|
|
70
|
+
if (r.ok) {
|
|
71
|
+
setChat(r.chat)
|
|
72
|
+
setPhase("found")
|
|
73
|
+
} else fail(r.message, "waiting")
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const submitToken = () => {
|
|
78
|
+
const tok = token.trim()
|
|
79
|
+
if (!tok) return
|
|
80
|
+
setPhase("verifying")
|
|
81
|
+
void verifyBotToken(tok).then((me) => {
|
|
82
|
+
if (!me.ok) return fail(me.error ?? "Telegram rejected the token.", "token")
|
|
83
|
+
setUsername(me.username ?? "")
|
|
84
|
+
listen(tok)
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const save = () => {
|
|
89
|
+
if (!chat) return
|
|
90
|
+
const channel = { type: "telegram" as const, botToken: token.trim(), chatId: chat.chatId }
|
|
91
|
+
setPhase("saving")
|
|
92
|
+
void sendConnectedMessage(name, channel).then(async (r) => {
|
|
93
|
+
if (!r.ok) return fail(`Couldn't send the test message: ${r.message}. Nothing was saved.`, "found")
|
|
94
|
+
await saveBeaconChannel(name, channel, false)
|
|
95
|
+
setToken("") // don't keep the secret in component state once it's saved
|
|
96
|
+
setPhase("done")
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
useKeyboard((key) => {
|
|
101
|
+
const k = key.name ?? ""
|
|
102
|
+
if (k === "escape") return close()
|
|
103
|
+
if (phase === "token") return // SecretInput owns typing; Enter submits
|
|
104
|
+
if (phase === "waiting") {
|
|
105
|
+
if (k === "o" && username) return openUrl(botLink(username))
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
if (phase === "found") {
|
|
109
|
+
if (k === "return") return save()
|
|
110
|
+
if (k === "r") return listen(token.trim())
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
if (phase === "error") {
|
|
114
|
+
if (k === "r") {
|
|
115
|
+
setError(null)
|
|
116
|
+
if (retryFrom === "waiting") return listen(token.trim())
|
|
117
|
+
if (retryFrom === "found" && chat) return setPhase("found")
|
|
118
|
+
setToken("") // a rejected token is replaced, not edited
|
|
119
|
+
return setPhase("token")
|
|
120
|
+
}
|
|
121
|
+
if (k === "q") return close()
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
if (phase === "done" && (k === "return" || k === "q")) return close()
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<box
|
|
129
|
+
style={{
|
|
130
|
+
position: "absolute",
|
|
131
|
+
top: 0,
|
|
132
|
+
left: 0,
|
|
133
|
+
width: "100%",
|
|
134
|
+
height: "100%",
|
|
135
|
+
flexDirection: "column",
|
|
136
|
+
backgroundColor: theme.bg,
|
|
137
|
+
zIndex: 200,
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1 }}>
|
|
141
|
+
<text content="Beacon " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
142
|
+
<text content="connect a Telegram pager" fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
143
|
+
<box style={{ flexGrow: 1 }} />
|
|
144
|
+
<text content={`channel “${name}”`} fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
145
|
+
</box>
|
|
146
|
+
<Centered>{body()}</Centered>
|
|
147
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
148
|
+
</box>
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
function body() {
|
|
152
|
+
if (phase === "token") {
|
|
153
|
+
return (
|
|
154
|
+
<Panel title=" 1 · Your bot " active>
|
|
155
|
+
<box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
|
|
156
|
+
<text content="Beacon pages you through your own Telegram bot. Making one is free:" fg={theme.textDim} />
|
|
157
|
+
<box style={{ height: 1 }} />
|
|
158
|
+
{BOTFATHER_STEPS.map((s, i) => (
|
|
159
|
+
<text key={i} content={` ${i + 1}. ${s}`} fg={theme.text} />
|
|
160
|
+
))}
|
|
161
|
+
<box style={{ height: 1 }} />
|
|
162
|
+
<text content="Then paste the token here (it's hidden as you type):" fg={theme.textDim} />
|
|
163
|
+
<SecretInput focused value={token} placeholder="123456789:AAE…" onChange={setToken} onSubmit={submitToken} width={WIDTH - 2} />
|
|
164
|
+
{replacing && (
|
|
165
|
+
<>
|
|
166
|
+
<box style={{ height: 1 }} />
|
|
167
|
+
<text content={`⚠ This replaces the existing “${name}” channel once the new one is tested.`} fg={theme.warn} />
|
|
168
|
+
</>
|
|
169
|
+
)}
|
|
170
|
+
<box style={{ height: 1 }} />
|
|
171
|
+
<text content={`⏎ continue · Esc cancel · BotFather: ${BOTFATHER_URL}`} fg={theme.textFaint} wrapMode="none" />
|
|
172
|
+
</box>
|
|
173
|
+
</Panel>
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (phase === "verifying" || phase === "saving") {
|
|
178
|
+
return (
|
|
179
|
+
<box style={{ flexDirection: "row" }}>
|
|
180
|
+
<Spinner />
|
|
181
|
+
<text content={phase === "verifying" ? " Checking the token with Telegram…" : " Sending a test message…"} fg={theme.textDim} />
|
|
182
|
+
</box>
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (phase === "waiting") {
|
|
187
|
+
return (
|
|
188
|
+
<Panel title=" 2 · Say hello to your bot " active>
|
|
189
|
+
<box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
|
|
190
|
+
<box style={{ flexDirection: "row" }}>
|
|
191
|
+
<text content="✓ Bot " fg={theme.good} wrapMode="none" />
|
|
192
|
+
<text content={username ? `@${username}` : "found"} fg={theme.accent} wrapMode="none" />
|
|
193
|
+
</box>
|
|
194
|
+
<box style={{ height: 1 }} />
|
|
195
|
+
<text content="Now open Telegram and send your bot any message, so Beacon knows which" fg={theme.text} />
|
|
196
|
+
<text content="chat to page." fg={theme.text} />
|
|
197
|
+
{username && <text content={` ${botLink(username)} (press o to open it)`} fg={theme.accent} wrapMode="none" />}
|
|
198
|
+
<box style={{ height: 1 }} />
|
|
199
|
+
<text content="To page a group instead, add the bot to the group and send /start there." fg={theme.textFaint} />
|
|
200
|
+
<box style={{ height: 1 }} />
|
|
201
|
+
<box style={{ flexDirection: "row" }}>
|
|
202
|
+
<Spinner />
|
|
203
|
+
<text content=" Waiting for your message (up to 5 minutes)…" fg={theme.textDim} />
|
|
204
|
+
</box>
|
|
205
|
+
</box>
|
|
206
|
+
</Panel>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (phase === "found" && chat) {
|
|
211
|
+
return (
|
|
212
|
+
<Panel title=" 3 · Confirm the chat " active>
|
|
213
|
+
<box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
|
|
214
|
+
<box style={{ flexDirection: "row" }}>
|
|
215
|
+
<text content="✓ Message received from " fg={theme.good} wrapMode="none" />
|
|
216
|
+
<text content={chat.label} fg={theme.accent} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
217
|
+
</box>
|
|
218
|
+
<text content={` a ${chat.kind} chat`} fg={theme.textFaint} />
|
|
219
|
+
<box style={{ height: 1 }} />
|
|
220
|
+
<text content="Beacon will page this chat. Press ⏎ to send a test message and save." fg={theme.text} />
|
|
221
|
+
<text content="Wrong chat? Press r, then message the bot from the right one." fg={theme.textDim} />
|
|
222
|
+
</box>
|
|
223
|
+
</Panel>
|
|
224
|
+
)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (phase === "done") {
|
|
228
|
+
return (
|
|
229
|
+
<Panel title=" Beacon connected " active>
|
|
230
|
+
<box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
|
|
231
|
+
<text content={`● Channel “${name}” is set up, and a test message is on its way.`} fg={theme.good} />
|
|
232
|
+
<box style={{ height: 1 }} />
|
|
233
|
+
<text content="Anything on this machine can page you now:" fg={theme.textDim} />
|
|
234
|
+
<text content={` spinuptui beacon send "web1 is down"`} fg={theme.accent} wrapMode="none" />
|
|
235
|
+
<text content={` some-report | spinuptui beacon send --silent`} fg={theme.accent} wrapMode="none" />
|
|
236
|
+
<box style={{ height: 1 }} />
|
|
237
|
+
<text content="⏎ done" fg={theme.textFaint} />
|
|
238
|
+
</box>
|
|
239
|
+
</Panel>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<Panel title=" Not connected yet " active>
|
|
245
|
+
<box style={{ flexDirection: "column", width: WIDTH, paddingTop: 1, paddingBottom: 1 }}>
|
|
246
|
+
<text content={`✕ ${error ?? "Something went wrong."}`} fg={theme.bad} />
|
|
247
|
+
<box style={{ height: 1 }} />
|
|
248
|
+
<text content="Nothing was saved. r — try again · Esc — close" fg={theme.textDim} />
|
|
249
|
+
</box>
|
|
250
|
+
</Panel>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function hints() {
|
|
255
|
+
if (phase === "token") return [{ key: "⏎", label: "continue" }, { key: "esc", label: "cancel" }]
|
|
256
|
+
if (phase === "waiting")
|
|
257
|
+
return [...(username ? [{ key: "o", label: "open the bot" }] : []), { key: "esc", label: "cancel" }]
|
|
258
|
+
if (phase === "found")
|
|
259
|
+
return [
|
|
260
|
+
{ key: "⏎", label: "test & save" },
|
|
261
|
+
{ key: "r", label: "different chat" },
|
|
262
|
+
{ key: "esc", label: "cancel" },
|
|
263
|
+
]
|
|
264
|
+
if (phase === "error") return [{ key: "r", label: "try again" }, { key: "esc", label: "close" }]
|
|
265
|
+
if (phase === "done") return [{ key: "⏎", label: "done" }]
|
|
266
|
+
return [{ key: "esc", label: "cancel" }]
|
|
267
|
+
}
|
|
268
|
+
}
|