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
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// The SpinupTUI folder overlay (Modules tab, `f`): where your jobs and reports
|
|
2
|
+
// live, and sharing that folder across machines — `spinuptui home sync` with
|
|
3
|
+
// the confirm-before-firing pattern. Pick how to share it, give a name or URL,
|
|
4
|
+
// see the plan, press y. Nothing runs git until then.
|
|
5
|
+
|
|
6
|
+
import { useEffect, useState } from "react"
|
|
7
|
+
import { useKeyboard } from "@opentui/react"
|
|
8
|
+
import { theme } from "../../lib/theme.ts"
|
|
9
|
+
import { truncate } from "../../lib/format.ts"
|
|
10
|
+
import { openUrl } from "../../lib/open.ts"
|
|
11
|
+
import { Panel, Spinner, Centered } from "../components.tsx"
|
|
12
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
13
|
+
import { useStore } from "../store.tsx"
|
|
14
|
+
import { applySync, describeShare, ensureHome, ghReady, homeStatus, planSync, tilde, type ApplyResult, type HomeStatus, type SyncOptions, type SyncPlan } from "../../lib/home.ts"
|
|
15
|
+
|
|
16
|
+
type Phase = "loading" | "pick" | "input" | "planning" | "confirm" | "blocked" | "applying" | "done" | "error"
|
|
17
|
+
|
|
18
|
+
interface Choice {
|
|
19
|
+
key: keyof SyncOptions
|
|
20
|
+
label: string
|
|
21
|
+
detail: string
|
|
22
|
+
prompt: string
|
|
23
|
+
placeholder: string
|
|
24
|
+
initial: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const INPUT_STYLE = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
|
|
28
|
+
|
|
29
|
+
export function HomeSync() {
|
|
30
|
+
const { setHomeSyncOpen, setInputMode } = useStore()
|
|
31
|
+
const [phase, setPhase] = useState<Phase>("loading")
|
|
32
|
+
const [status, setStatus] = useState<HomeStatus | null>(null)
|
|
33
|
+
const [gh, setGh] = useState<string | null>(null) // why gh can't be used; null = it can
|
|
34
|
+
const [selected, setSelected] = useState(0)
|
|
35
|
+
const [value, setValue] = useState("")
|
|
36
|
+
const [plan, setPlan] = useState<SyncPlan | null>(null)
|
|
37
|
+
const [result, setResult] = useState<ApplyResult | null>(null)
|
|
38
|
+
|
|
39
|
+
const choices: Choice[] = [
|
|
40
|
+
{
|
|
41
|
+
key: "github",
|
|
42
|
+
label: "Create a private GitHub repository for it",
|
|
43
|
+
detail: gh ? `${gh} Or make one yourself and use the next option.` : "Uses the GitHub CLI you're signed in to.",
|
|
44
|
+
prompt: "Repository name (or owner/name)",
|
|
45
|
+
placeholder: "spinuptui-home",
|
|
46
|
+
initial: "spinuptui-home",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
key: "remote",
|
|
50
|
+
label: "Use an empty private repository I've made",
|
|
51
|
+
detail: "Any git host: GitHub, GitLab, Gitea, your own server.",
|
|
52
|
+
prompt: "Repository URL",
|
|
53
|
+
placeholder: "git@github.com:you/spinuptui-home.git",
|
|
54
|
+
initial: "",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: "clone",
|
|
58
|
+
label: "Set this machine up from another machine's folder",
|
|
59
|
+
detail: "For a second machine: clones the repository you shared from the first.",
|
|
60
|
+
prompt: "Repository URL",
|
|
61
|
+
placeholder: "git@github.com:you/spinuptui-home.git",
|
|
62
|
+
initial: "",
|
|
63
|
+
},
|
|
64
|
+
]
|
|
65
|
+
const choice = choices[selected]!
|
|
66
|
+
|
|
67
|
+
const close = () => setHomeSyncOpen(false)
|
|
68
|
+
|
|
69
|
+
const makePlan = (opts: SyncOptions) => {
|
|
70
|
+
setPhase("planning")
|
|
71
|
+
void planSync(opts).then((p) => {
|
|
72
|
+
setPlan(p)
|
|
73
|
+
setPhase(p.blockers.length ? "blocked" : "confirm")
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
void Promise.all([homeStatus(), ghReady()]).then(([s, why]) => {
|
|
79
|
+
setStatus(s)
|
|
80
|
+
setGh(why)
|
|
81
|
+
// No usable gh: start on the option that works without it.
|
|
82
|
+
if (why) setSelected(1)
|
|
83
|
+
// Already shared: the plan is "push what's pending", or nothing.
|
|
84
|
+
if (s.git && s.remote) makePlan({})
|
|
85
|
+
else setPhase("pick")
|
|
86
|
+
})
|
|
87
|
+
// Once, on open.
|
|
88
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
89
|
+
}, [])
|
|
90
|
+
|
|
91
|
+
// The input owns the keyboard while it's showing.
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (phase !== "input") return
|
|
94
|
+
setInputMode(true)
|
|
95
|
+
return () => setInputMode(false)
|
|
96
|
+
}, [phase, setInputMode])
|
|
97
|
+
|
|
98
|
+
const apply = () => {
|
|
99
|
+
if (!plan) return
|
|
100
|
+
setPhase("applying")
|
|
101
|
+
void applySync(plan).then(async (r) => {
|
|
102
|
+
if (r.ok) ensureHome()
|
|
103
|
+
setResult(r)
|
|
104
|
+
setStatus(await homeStatus())
|
|
105
|
+
setPhase(r.ok ? "done" : "error")
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
useKeyboard((key) => {
|
|
110
|
+
const name = key.name ?? ""
|
|
111
|
+
if (phase === "input") {
|
|
112
|
+
if (name === "escape") setPhase("pick")
|
|
113
|
+
return
|
|
114
|
+
}
|
|
115
|
+
if (name === "escape" || name === "q") return close()
|
|
116
|
+
if (name === "o" && status?.exists) return openUrl(status.path)
|
|
117
|
+
if (phase === "pick") {
|
|
118
|
+
if (name === "up" || name === "k") return setSelected((i) => Math.max(0, i - 1))
|
|
119
|
+
if (name === "down" || name === "j") return setSelected((i) => Math.min(choices.length - 1, i + 1))
|
|
120
|
+
if (name === "return") {
|
|
121
|
+
if (choice.key === "github" && gh) return
|
|
122
|
+
setValue(choice.initial)
|
|
123
|
+
return setPhase("input")
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (phase === "confirm" && name === "y" && plan && plan.steps.length) return apply()
|
|
127
|
+
if ((phase === "blocked" || phase === "error") && name === "r" && !(status?.git && status.remote)) return setPhase("pick")
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
|
|
132
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
133
|
+
<text content="▤ Your SpinupTUI folder " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
134
|
+
<text content={status ? tilde(status.path) : ""} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
135
|
+
</box>
|
|
136
|
+
<Centered>{body()}</Centered>
|
|
137
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
138
|
+
</box>
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
function header() {
|
|
142
|
+
if (!status) return null
|
|
143
|
+
return (
|
|
144
|
+
<>
|
|
145
|
+
<text content="Your Scheduler jobs and reports live here, and later other modules' work too." fg={theme.text} />
|
|
146
|
+
<text
|
|
147
|
+
content={`${status.exists ? "" : "Created the first time a module writes to it. "}Its README.md explains each part.`}
|
|
148
|
+
fg={theme.textDim}
|
|
149
|
+
/>
|
|
150
|
+
<box style={{ height: 1 }} />
|
|
151
|
+
<box style={{ flexDirection: "row" }}>
|
|
152
|
+
<text content="Now: " fg={theme.textDim} style={{ flexShrink: 0 }} />
|
|
153
|
+
<text content={truncate(describeShare(status), 66)} fg={status.git && status.remote ? theme.good : theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
154
|
+
</box>
|
|
155
|
+
{status.station && (
|
|
156
|
+
<text content={`Its jobs run on ${status.station === status.thisMachine ? "this machine" : status.station}.`} fg={theme.textDim} wrapMode="none" />
|
|
157
|
+
)}
|
|
158
|
+
<box style={{ height: 1 }} />
|
|
159
|
+
</>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function body() {
|
|
164
|
+
if (phase === "loading" || phase === "planning" || phase === "applying") {
|
|
165
|
+
return (
|
|
166
|
+
<box style={{ flexDirection: "row" }}>
|
|
167
|
+
<Spinner />
|
|
168
|
+
<text content={phase === "applying" ? " Sharing the folder…" : phase === "planning" ? " Checking the repository…" : " Looking at the folder…"} fg={theme.textDim} wrapMode="none" />
|
|
169
|
+
</box>
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
if (phase === "pick") {
|
|
173
|
+
return (
|
|
174
|
+
<Panel title=" Share it across machines " active>
|
|
175
|
+
<box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
|
|
176
|
+
{header()}
|
|
177
|
+
<text content="Shared through a private git repository, you can queue jobs from any machine" fg={theme.text} />
|
|
178
|
+
<text content="and read the reports anywhere. Scheduler pulls it before each run and pushes" fg={theme.text} />
|
|
179
|
+
<text content="the reports after." fg={theme.text} />
|
|
180
|
+
<box style={{ height: 1 }} />
|
|
181
|
+
{choices.map((c, i) => {
|
|
182
|
+
const off = c.key === "github" && !!gh
|
|
183
|
+
const sel = i === selected
|
|
184
|
+
return (
|
|
185
|
+
<box key={c.key} style={{ flexDirection: "column" }}>
|
|
186
|
+
<text content={`${sel ? "▸ " : " "}${c.label}`} fg={off ? theme.textFaint : sel ? theme.accent : theme.text} wrapMode="none" />
|
|
187
|
+
{sel && <text content={` ${c.detail}`} fg={off ? theme.warn : theme.textDim} />}
|
|
188
|
+
</box>
|
|
189
|
+
)
|
|
190
|
+
})}
|
|
191
|
+
</box>
|
|
192
|
+
</Panel>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
if (phase === "input") {
|
|
196
|
+
return (
|
|
197
|
+
<Panel title={` ${choice.label} `} active>
|
|
198
|
+
<box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
|
|
199
|
+
<text content={choice.prompt} fg={theme.accent} />
|
|
200
|
+
<input
|
|
201
|
+
focused
|
|
202
|
+
value={value}
|
|
203
|
+
placeholder={choice.placeholder}
|
|
204
|
+
onInput={(v: string) => setValue(v)}
|
|
205
|
+
onSubmit={() => value.trim() && makePlan({ [choice.key]: value.trim() })}
|
|
206
|
+
style={INPUT_STYLE}
|
|
207
|
+
/>
|
|
208
|
+
<box style={{ height: 1 }} />
|
|
209
|
+
<text content={choice.key === "clone" ? "Nothing changes until you've seen the plan." : "Keep it private: reports name your sites and clients. Nothing changes until you've seen the plan."} fg={theme.textFaint} />
|
|
210
|
+
</box>
|
|
211
|
+
</Panel>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
if (phase === "blocked" && plan) {
|
|
215
|
+
return (
|
|
216
|
+
<Panel title=" Can't share it yet " active>
|
|
217
|
+
<box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
|
|
218
|
+
{plan.blockers.map((b) => (
|
|
219
|
+
<box key={b.reason + b.message} style={{ flexDirection: "column" }}>
|
|
220
|
+
<text content={`✕ ${b.message}`} fg={theme.bad} />
|
|
221
|
+
{b.remedy && <text content={` ${b.remedy}`} fg={theme.textDim} />}
|
|
222
|
+
<box style={{ height: 1 }} />
|
|
223
|
+
</box>
|
|
224
|
+
))}
|
|
225
|
+
</box>
|
|
226
|
+
</Panel>
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
if (phase === "confirm" && plan) {
|
|
230
|
+
if (!plan.steps.length) {
|
|
231
|
+
return (
|
|
232
|
+
<Panel title=" Shared " active>
|
|
233
|
+
<box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
|
|
234
|
+
{header()}
|
|
235
|
+
<text content="✓ Shared and up to date. Nothing to do." fg={theme.good} />
|
|
236
|
+
<box style={{ height: 1 }} />
|
|
237
|
+
<text content={`On another machine: spinuptui home sync --clone ${status?.remote ?? "<url>"} --plan`} fg={theme.textFaint} />
|
|
238
|
+
{plan.notes.map((n) => (
|
|
239
|
+
<text key={n} content={n} fg={theme.warn} />
|
|
240
|
+
))}
|
|
241
|
+
</box>
|
|
242
|
+
</Panel>
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
return (
|
|
246
|
+
<Panel title=" The plan " active>
|
|
247
|
+
<box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
|
|
248
|
+
{header()}
|
|
249
|
+
{plan.steps.map((s, i) => (
|
|
250
|
+
<text key={s.label} content={`${i + 1}. ${truncate(s.label, 70)}`} fg={theme.text} wrapMode="none" />
|
|
251
|
+
))}
|
|
252
|
+
<box style={{ height: 1 }} />
|
|
253
|
+
{plan.notes.map((n) => (
|
|
254
|
+
<text key={n} content={n} fg={theme.textFaint} />
|
|
255
|
+
))}
|
|
256
|
+
<box style={{ height: 1 }} />
|
|
257
|
+
<text content="Press y to do it · Esc to cancel" fg={theme.text} wrapMode="none" />
|
|
258
|
+
</box>
|
|
259
|
+
</Panel>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
if (phase === "done" && result) {
|
|
263
|
+
return (
|
|
264
|
+
<Panel title=" Shared " active>
|
|
265
|
+
<box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
|
|
266
|
+
{result.done.map((d) => (
|
|
267
|
+
<text key={d} content={`✓ ${truncate(d, 72)}`} fg={theme.good} wrapMode="none" />
|
|
268
|
+
))}
|
|
269
|
+
<box style={{ height: 1 }} />
|
|
270
|
+
{status && <text content={`${tilde(status.path)} is ${truncate(describeShare(status), 56)}.`} fg={theme.text} />}
|
|
271
|
+
<box style={{ height: 1 }} />
|
|
272
|
+
<text content="Esc to close" fg={theme.textFaint} />
|
|
273
|
+
</box>
|
|
274
|
+
</Panel>
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
return (
|
|
278
|
+
<Panel title=" Stopped " active>
|
|
279
|
+
<box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
|
|
280
|
+
{result?.done.map((d) => (
|
|
281
|
+
<text key={d} content={`✓ ${truncate(d, 72)}`} fg={theme.good} wrapMode="none" />
|
|
282
|
+
))}
|
|
283
|
+
<text content={`✕ ${result?.failed?.step ?? "Something went wrong"}: ${result?.failed?.message ?? ""}`} fg={theme.bad} />
|
|
284
|
+
<box style={{ height: 1 }} />
|
|
285
|
+
<text content="The steps above it are done. Fix this and run it again: it picks up where it stopped." fg={theme.textDim} />
|
|
286
|
+
</box>
|
|
287
|
+
</Panel>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function hints() {
|
|
292
|
+
const open = status?.exists ? [{ key: "o", label: "open folder" }] : []
|
|
293
|
+
if (phase === "pick") return [{ key: "↑↓", label: "choose" }, { key: "⏎", label: "next" }, ...open, { key: "esc", label: "close" }]
|
|
294
|
+
if (phase === "input") return [{ key: "⏎", label: "see the plan" }, { key: "esc", label: "back" }]
|
|
295
|
+
if (phase === "confirm" && plan?.steps.length) return [{ key: "y", label: "do it" }, ...open, { key: "esc", label: "cancel" }]
|
|
296
|
+
if (phase === "blocked" || phase === "error") return [...(status?.git && status.remote ? [] : [{ key: "r", label: "back" }]), ...open, { key: "esc", label: "close" }]
|
|
297
|
+
return [...open, { key: "esc", label: "close" }]
|
|
298
|
+
}
|
|
299
|
+
}
|
package/src/ui/views/Modules.tsx
CHANGED
|
@@ -16,6 +16,8 @@ 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"
|
|
20
|
+
import { describeShare, homeStatus, tilde, type HomeStatus } from "../../lib/home.ts"
|
|
19
21
|
|
|
20
22
|
interface Card {
|
|
21
23
|
id: ModuleId
|
|
@@ -42,16 +44,16 @@ function stateLabel(card: Card, status: ModuleStatus | undefined): { dot: string
|
|
|
42
44
|
|
|
43
45
|
export function Modules({ rows }: { rows: number }) {
|
|
44
46
|
const store = useStore()
|
|
45
|
-
const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen } = store
|
|
47
|
+
const { route, inputMode, overlayOpen, beaconSetupOpen, setBeaconSetupOpen, schedulerInstallOpen, setSchedulerInstallOpen, homeSyncOpen, setHomeSyncOpen, heartbeatSetupOpen, setHeartbeatSetupOpen } = store
|
|
46
48
|
const [selected, setSelected] = useState(0)
|
|
47
49
|
const [statuses, setStatuses] = useState<Map<ModuleId, ModuleStatus>>(new Map())
|
|
48
50
|
const [testing, setTesting] = useState(false)
|
|
49
51
|
|
|
50
|
-
// Recompute on open and whenever
|
|
51
|
-
//
|
|
52
|
+
// Recompute on open and whenever a setup overlay closes (it may have changed
|
|
53
|
+
// the config). status() is cheap and never sends anything.
|
|
52
54
|
const [tick, setTick] = useState(0)
|
|
53
55
|
useEffect(() => {
|
|
54
|
-
if (beaconSetupOpen) return
|
|
56
|
+
if (beaconSetupOpen || schedulerInstallOpen || homeSyncOpen || heartbeatSetupOpen) return
|
|
55
57
|
let live = true
|
|
56
58
|
void Promise.all(MODULES.map((m) => m.status())).then((list) => {
|
|
57
59
|
if (live) setStatuses(new Map(list.map((s) => [s.id, s])))
|
|
@@ -59,7 +61,7 @@ export function Modules({ rows }: { rows: number }) {
|
|
|
59
61
|
return () => {
|
|
60
62
|
live = false
|
|
61
63
|
}
|
|
62
|
-
}, [beaconSetupOpen, tick])
|
|
64
|
+
}, [beaconSetupOpen, schedulerInstallOpen, homeSyncOpen, heartbeatSetupOpen, tick])
|
|
63
65
|
|
|
64
66
|
const isActive = route === "modules" && !inputMode && !overlayOpen
|
|
65
67
|
const card = CARDS[selected]!
|
|
@@ -88,6 +90,13 @@ export function Modules({ rows }: { rows: number }) {
|
|
|
88
90
|
case "return":
|
|
89
91
|
case "s":
|
|
90
92
|
if (card.id === "beacon") return setBeaconSetupOpen(true)
|
|
93
|
+
if (card.id === "scheduler") return setSchedulerInstallOpen(true)
|
|
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)
|
|
91
100
|
return
|
|
92
101
|
case "t":
|
|
93
102
|
if (card.id === "beacon" && status && status.state !== "not_configured") return sendTest()
|
|
@@ -140,6 +149,13 @@ export function Modules({ rows }: { rows: number }) {
|
|
|
140
149
|
hints={[
|
|
141
150
|
{ key: "↑↓/jk", label: "select" },
|
|
142
151
|
...(card.id === "beacon" ? [{ key: "⏎", label: beaconReady ? "reconfigure" : "set up" }] : []),
|
|
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
|
+
: []),
|
|
143
159
|
...(beaconReady ? [{ key: "t", label: testing ? "sending…" : "test page" }] : []),
|
|
144
160
|
]}
|
|
145
161
|
/>
|
|
@@ -191,6 +207,54 @@ function ModuleDetail({ card, status }: { card: Card; status: ModuleStatus | und
|
|
|
191
207
|
{card.id === "beacon" && status.state !== "not_configured" && (
|
|
192
208
|
<text content="⏎ reconfigure the pager · t send a test page · CLI: spinuptui beacon status" fg={theme.textFaint} />
|
|
193
209
|
)}
|
|
210
|
+
{card.id === "scheduler" && <SchedulerDetail installed={status.state !== "not_configured"} refreshKey={status} />}
|
|
194
211
|
</box>
|
|
195
212
|
)
|
|
196
213
|
}
|
|
214
|
+
|
|
215
|
+
function SchedulerDetail({ installed, refreshKey }: { installed: boolean; refreshKey: unknown }) {
|
|
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])
|
|
227
|
+
return (
|
|
228
|
+
<>
|
|
229
|
+
<box style={{ height: 1 }} />
|
|
230
|
+
{!installed && (
|
|
231
|
+
<>
|
|
232
|
+
<text
|
|
233
|
+
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."
|
|
234
|
+
fg={theme.textDim}
|
|
235
|
+
/>
|
|
236
|
+
<box style={{ height: 1 }} />
|
|
237
|
+
<text content="Press ⏎ to see exactly what it installs, then confirm." fg={theme.accent} />
|
|
238
|
+
<box style={{ height: 1 }} />
|
|
239
|
+
</>
|
|
240
|
+
)}
|
|
241
|
+
<text content={runs.length ? "Next up:" : "No follow-ups queued."} fg={theme.textDim} />
|
|
242
|
+
{runs.map((r) => (
|
|
243
|
+
<box key={`${r.source}/${r.id}`} style={{ flexDirection: "row" }}>
|
|
244
|
+
<text content={` ${r.next.padEnd(17)} `} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
245
|
+
<text content={`${r.source}/${r.id}`} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
246
|
+
</box>
|
|
247
|
+
))}
|
|
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 }} />
|
|
254
|
+
<text
|
|
255
|
+
content={`${installed ? "⏎ reinstall (repairs the unit) · " : ""}CLI: spinuptui scheduler add | list | status`}
|
|
256
|
+
fg={theme.textFaint}
|
|
257
|
+
/>
|
|
258
|
+
</>
|
|
259
|
+
)
|
|
260
|
+
}
|
|
@@ -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
|
+
}
|
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
|
}
|