spinuptui 0.26.1 → 0.28.0

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.
@@ -0,0 +1,396 @@
1
+ // Bulk WordPress core update for a Stacks version group — `u` on a "WP x.y.z" row.
2
+ //
3
+ // check (SSH, every site, read-only) → plan → confirm → run → summary.
4
+ //
5
+ // The check comes first because the group itself is built from cached probes,
6
+ // which lag reality (sites auto-update); the plan is built from live versions.
7
+ // The plan defaults to the newest release in each site's own line (the low-risk
8
+ // security hotfix) with `m` switching to the newest release overall, and `space`
9
+ // leaves individual sites out. The run itself lives in ../wpCoreJobs.tsx (canary
10
+ // first, then a few at a time, one per server), so closing this overlay leaves it
11
+ // running; reopening shows its progress or its summary.
12
+
13
+ import { useEffect, useMemo, useState } from "react"
14
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react"
15
+ import { theme } from "../../lib/theme.ts"
16
+ import { truncate } from "../../lib/format.ts"
17
+ import { Panel, Spinner, Centered } from "../components.tsx"
18
+ import { List, moveSelection } from "../List.tsx"
19
+ import { StatusBar } from "../StatusBar.tsx"
20
+ import { useStore } from "../store.tsx"
21
+ import type { Site } from "../../api/types.ts"
22
+ import { checkWpCore, compareVersions, wpTarget, type WpCoreCheckResult, type WpTargetMode } from "../../lib/wpCore.ts"
23
+ import {
24
+ BULK_MAX_FAILURES,
25
+ BULK_MAX_PARALLEL,
26
+ clearBulk,
27
+ startBulkCoreUpdate,
28
+ stopBulk,
29
+ useBulkRun,
30
+ useWpCoreJobs,
31
+ type BulkItem,
32
+ } from "../wpCoreJobs.tsx"
33
+
34
+ const CHECK_PARALLEL = 6
35
+
36
+ type Phase = "checking" | "plan" | "confirm"
37
+
38
+ // One row of the plan list: an update we'd run (toggleable) or a site we skip, and why.
39
+ type PlanRow =
40
+ | { kind: "update"; item: Omit<BulkItem, "state" | "error"> }
41
+ | { kind: "skip"; site: Site; reason: string; bad: boolean }
42
+
43
+ const MODE_LABEL: Record<WpTargetMode, string> = {
44
+ line: "newest release in each site's line",
45
+ newest: "newest release overall",
46
+ }
47
+
48
+ export function BulkWpUpdate() {
49
+ const { bulkWpGroup: group, setBulkWpGroup, serverById, sshUser, noteWpVersion } = useStore()
50
+ const run = useBulkRun()
51
+ const coreJobs = useWpCoreJobs()
52
+ const { height } = useTerminalDimensions()
53
+
54
+ const [phase, setPhase] = useState<Phase>("checking")
55
+ const [results, setResults] = useState<Map<number, WpCoreCheckResult>>(new Map())
56
+ const [mode, setMode] = useState<WpTargetMode>("line")
57
+ const [excluded, setExcluded] = useState<Set<number>>(new Set())
58
+ const [index, setIndex] = useState(0)
59
+
60
+ const sites = group?.sites ?? []
61
+
62
+ // Live-check every site, CHECK_PARALLEL at a time. Skipped entirely while a
63
+ // bulk run exists — then this overlay just shows that run.
64
+ useEffect(() => {
65
+ if (!group || run) return
66
+ let live = true
67
+ const queue = [...group.sites]
68
+ const worker = async () => {
69
+ for (let site = queue.shift(); site && live; site = queue.shift()) {
70
+ const server = serverById(site.server_id)
71
+ const res: WpCoreCheckResult = server ? await checkWpCore(server, site, sshUser) : { ok: false, target: "", error: "Server not loaded." }
72
+ if (!live) return
73
+ // A fresh live read — correct the row's cached version while we're here.
74
+ if (res.ok) noteWpVersion(site, res.status.current)
75
+ setResults((prev) => new Map(prev).set(site.id, res))
76
+ }
77
+ }
78
+ void Promise.all(Array.from({ length: Math.min(CHECK_PARALLEL, queue.length) }, worker)).then(() => {
79
+ if (live) setPhase("plan")
80
+ })
81
+ return () => {
82
+ live = false
83
+ }
84
+ // Once per open: the group is a snapshot, and a refresh must not restart the check.
85
+ // eslint-disable-next-line react-hooks/exhaustive-deps
86
+ }, [group, run === null])
87
+
88
+ // Build the plan for the current target mode. Standard WP sites sort first so
89
+ // the canary (the first included row) is a plain install when there is one.
90
+ const rows = useMemo<PlanRow[]>(() => {
91
+ const updates: PlanRow[] = []
92
+ const skips: PlanRow[] = []
93
+ for (const site of sites) {
94
+ const res = results.get(site.id)
95
+ const server = serverById(site.server_id)
96
+ if (!res || !server) continue
97
+ if (!res.ok) {
98
+ skips.push({ kind: "skip", site, reason: `couldn't check: ${res.error}`, bad: true })
99
+ continue
100
+ }
101
+ if (coreJobs.get(site.id)?.result === null) {
102
+ skips.push({ kind: "skip", site, reason: "already updating", bad: false })
103
+ continue
104
+ }
105
+ const to = wpTarget(res.status.current, res.status.offers, mode)
106
+ if (!to) {
107
+ const newer = res.status.offers.length > 0
108
+ skips.push({ kind: "skip", site, reason: `${res.status.current} — ${newer ? "newest in its line" : "up to date"}`, bad: false })
109
+ continue
110
+ }
111
+ updates.push({
112
+ kind: "update",
113
+ item: { site, server, from: res.status.current, to, multisite: res.status.multisite, composer: res.status.composer !== null },
114
+ })
115
+ }
116
+ const key = (r: PlanRow) => (r.kind === "update" ? `${r.item.composer ? 1 : 0}${r.item.site.domain}` : r.site.domain)
117
+ updates.sort((a, b) => key(a).localeCompare(key(b)))
118
+ skips.sort((a, b) => key(a).localeCompare(key(b)))
119
+ return [...updates, ...skips]
120
+ }, [sites, results, mode, coreJobs, serverById])
121
+
122
+ const included = rows.flatMap((r) => (r.kind === "update" && !excluded.has(r.item.site.id) ? [r.item] : []))
123
+ const canaryId = included[0]?.site.id
124
+ const composerCount = included.filter((i) => i.composer).length
125
+ const updateCount = rows.filter((r) => r.kind === "update").length
126
+ const skipCount = rows.length - updateCount
127
+ const failedChecks = rows.filter((r) => r.kind === "skip" && r.bad).length
128
+ const targets = [...new Set(included.map((i) => i.to))].sort(compareVersions)
129
+
130
+ const close = () => {
131
+ if (run?.phase === "done") clearBulk() // the summary has been seen
132
+ setBulkWpGroup(null)
133
+ }
134
+
135
+ // The run view has its own cursor over the run's items.
136
+ const listLen = run ? run.items.length : rows.length
137
+
138
+ useKeyboard((key) => {
139
+ const name = key.name ?? ""
140
+ if (name === "escape" || name === "q") return close()
141
+ if (name === "up" || name === "k") return setIndex((i) => moveSelection(i, -1, listLen))
142
+ if (name === "down" || name === "j") return setIndex((i) => moveSelection(i, 1, listLen))
143
+
144
+ if (run) {
145
+ if (name === "x" && run.phase !== "done") stopBulk()
146
+ return
147
+ }
148
+ if (phase === "plan") {
149
+ if (name === "m") {
150
+ setMode((m) => (m === "line" ? "newest" : "line"))
151
+ setIndex(0)
152
+ return
153
+ }
154
+ if (name === "space") {
155
+ const r = rows[index]
156
+ if (r?.kind !== "update") return
157
+ setExcluded((prev) => {
158
+ const next = new Set(prev)
159
+ if (next.has(r.item.site.id)) next.delete(r.item.site.id)
160
+ else next.add(r.item.site.id)
161
+ return next
162
+ })
163
+ return
164
+ }
165
+ if ((name === "return" || name === "right" || name === "l") && included.length > 0) setPhase("confirm")
166
+ return
167
+ }
168
+ if (phase === "confirm") {
169
+ if (name === "y" && group) {
170
+ startBulkCoreUpdate(group.title, included, sshUser, noteWpVersion)
171
+ setIndex(0)
172
+ } else if (name === "left" || name === "h") setPhase("plan")
173
+ }
174
+ })
175
+
176
+ if (!group) return null
177
+ const viewport = Math.max(4, height - 14)
178
+
179
+ return (
180
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 210 }}>
181
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
182
+ <text content="⬆ Update WordPress " fg={theme.brand} style={{ flexShrink: 0 }} />
183
+ <text content={run ? run.title : group.title} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
184
+ <box style={{ flexGrow: 1 }} />
185
+ <text content={`${run ? run.items.length : sites.length} sites`} fg={theme.textFaint} style={{ flexShrink: 0 }} />
186
+ </box>
187
+
188
+ {run ? renderRun() : phase === "checking" ? renderChecking() : phase === "plan" ? renderPlan() : renderConfirm()}
189
+
190
+ <StatusBar hints={hints()} showGlobal={false} />
191
+ </box>
192
+ )
193
+
194
+ function renderChecking() {
195
+ return (
196
+ <Centered>
197
+ <box style={{ flexDirection: "row" }}>
198
+ <Spinner />
199
+ <text content={` Checking WordPress on each site over SSH… ${results.size} of ${sites.length}`} fg={theme.textDim} />
200
+ </box>
201
+ </Centered>
202
+ )
203
+ }
204
+
205
+ function renderPlan() {
206
+ return (
207
+ <box style={{ flexGrow: 1, flexDirection: "column", padding: 1 }}>
208
+ <Panel title=" Plan " active flexGrow={1}>
209
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
210
+ <box style={{ flexDirection: "row", height: 1 }}>
211
+ <text content="Target: " fg={theme.textDim} style={{ flexShrink: 0 }} />
212
+ <text content={MODE_LABEL[mode]} fg={theme.good} wrapMode="none" style={{ flexShrink: 0 }} />
213
+ <text content={` (m → ${MODE_LABEL[mode === "line" ? "newest" : "line"]})`} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
214
+ </box>
215
+ <text
216
+ content={`${included.length} to update${excluded.size ? ` (${updateCount - included.length} left out)` : ""} · ${skipCount} skipped${failedChecks ? ` (${failedChecks} couldn't be checked)` : ""}`}
217
+ fg={theme.text}
218
+ wrapMode="none"
219
+ />
220
+ {composerCount ? (
221
+ <text
222
+ content={`${composerCount} Bedrock/Radicle ${composerCount === 1 ? "site" : "sites"} (⚙): composer.lock still pins the old version — the next composer install reverts it.`}
223
+ fg={theme.warn}
224
+ wrapMode="none"
225
+ />
226
+ ) : null}
227
+ <box style={{ height: 1 }} />
228
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
229
+ <List
230
+ items={rows}
231
+ selectedIndex={index}
232
+ viewportRows={viewport}
233
+ focused
234
+ keyFor={(r) => (r.kind === "update" ? r.item.site.id : r.site.id)}
235
+ emptyText="No sites in this group"
236
+ renderRow={(r, selected) => {
237
+ const fg = (c: string) => (selected ? theme.text : c)
238
+ if (r.kind === "skip") {
239
+ return (
240
+ <>
241
+ <text content=" " style={{ flexShrink: 0 }} />
242
+ <text content={truncate(r.site.domain, 44)} fg={fg(theme.textFaint)} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
243
+ <text content={truncate(r.reason, 60)} fg={fg(r.bad ? theme.bad : theme.textFaint)} wrapMode="none" style={{ flexShrink: 0, marginLeft: 1 }} />
244
+ </>
245
+ )
246
+ }
247
+ const it = r.item
248
+ const on = !excluded.has(it.site.id)
249
+ return (
250
+ <>
251
+ <text content={on ? "[x] " : "[ ] "} fg={fg(on ? theme.good : theme.textFaint)} style={{ flexShrink: 0 }} />
252
+ <text content={truncate(it.site.domain, 44)} fg={fg(on ? theme.textDim : theme.textFaint)} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
253
+ {it.site.id === canaryId ? <text content="canary " fg={fg(theme.brand)} style={{ flexShrink: 0 }} /> : null}
254
+ <text content={it.composer ? "⚙ " : " "} fg={fg(theme.warn)} style={{ flexShrink: 0 }} />
255
+ <text content={`${it.from} → ${it.to}`.padStart(17)} fg={fg(on ? theme.good : theme.textFaint)} wrapMode="none" style={{ flexShrink: 0 }} />
256
+ </>
257
+ )
258
+ }}
259
+ />
260
+ </box>
261
+ </box>
262
+ </Panel>
263
+ </box>
264
+ )
265
+ }
266
+
267
+ function renderConfirm() {
268
+ const canary = included[0]
269
+ return (
270
+ <Centered>
271
+ <Panel title=" Confirm bulk update " active>
272
+ <box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
273
+ <text content={`Update ${included.length} ${included.length === 1 ? "site" : "sites"} to the ${MODE_LABEL[mode]}`} fg={theme.text} wrapMode="none" />
274
+ <text content={`(${targets.map((t) => `WordPress ${t}`).join(", ")})`} fg={theme.good} wrapMode="none" />
275
+ <box style={{ height: 1 }} />
276
+ <box style={{ flexDirection: "row" }}>
277
+ <text content="Canary: " fg={theme.textDim} />
278
+ <text content={truncate(canary?.site.domain ?? "", 44)} fg={theme.accent} wrapMode="none" />
279
+ <text content=" goes first, alone." fg={theme.textDim} wrapMode="none" />
280
+ </box>
281
+ <text content="If it fails, no other site is touched." fg={theme.textDim} wrapMode="none" />
282
+ <text
283
+ content={`Then up to ${BULK_MAX_PARALLEL} at a time, one per server. Stops after ${BULK_MAX_FAILURES} failures.`}
284
+ fg={theme.textDim}
285
+ wrapMode="none"
286
+ />
287
+ <text content="Each site: wp core update, then the database upgrade." fg={theme.textDim} wrapMode="none" />
288
+ {mode === "newest" && included.some((i) => i.from.split(".").slice(0, 2).join(".") !== i.to.split(".").slice(0, 2).join(".")) ? (
289
+ <text content="Includes major updates — test plugins and themes against them." fg={theme.warn} wrapMode="none" />
290
+ ) : null}
291
+ {composerCount ? (
292
+ <text
293
+ content={`${composerCount} Bedrock/Radicle: composer.lock still pins the old version until you composer update.`}
294
+ fg={theme.warn}
295
+ wrapMode="none"
296
+ />
297
+ ) : null}
298
+ <box style={{ height: 1 }} />
299
+ <text content="Press y to start · ← to go back · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
300
+ </box>
301
+ </Panel>
302
+ </Centered>
303
+ )
304
+ }
305
+
306
+ function renderRun() {
307
+ const r = run!
308
+ const count = (s: BulkItem["state"]) => r.items.filter((i) => i.state === s).length
309
+ const done = count("done")
310
+ const failed = count("failed")
311
+ const headline =
312
+ r.phase === "canary"
313
+ ? `Canary: updating ${r.items[0].site.domain} first…`
314
+ : r.phase === "done"
315
+ ? `Finished — ${done} of ${r.items.length} updated${failed ? `, ${failed} failed` : ""}${count("notRun") ? `, ${count("notRun")} not run` : ""}.`
316
+ : `${r.phase === "stopping" ? "Stopping — " : "Updating — "}${done} of ${r.items.length} done${failed ? `, ${failed} failed` : ""}, ${count("running")} running.`
317
+ const selected = r.items[Math.min(index, r.items.length - 1)]
318
+ return (
319
+ <box style={{ flexGrow: 1, flexDirection: "column", padding: 1 }}>
320
+ <Panel title={r.phase === "done" ? " Summary " : " Progress "} active flexGrow={1}>
321
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
322
+ <box style={{ flexDirection: "row", height: 1 }}>
323
+ {r.phase !== "done" ? <Spinner /> : null}
324
+ <text content={(r.phase !== "done" ? " " : "") + headline} fg={failed ? theme.warn : theme.text} wrapMode="none" />
325
+ </box>
326
+ {r.stopReason ? <text content={r.stopReason} fg={theme.warn} wrapMode="none" /> : null}
327
+ {group && r.title !== group.title ? (
328
+ <text content={`You opened ${group.title} — one bulk update runs at a time, so it can start once this one finishes.`} fg={theme.warn} wrapMode="none" />
329
+ ) : null}
330
+ {r.phase !== "done" ? <text content="Esc keeps it running in the background." fg={theme.textFaint} wrapMode="none" /> : null}
331
+ <box style={{ height: 1 }} />
332
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
333
+ <List
334
+ items={r.items}
335
+ selectedIndex={Math.min(index, r.items.length - 1)}
336
+ viewportRows={viewport - 1}
337
+ focused
338
+ keyFor={(i) => i.site.id}
339
+ emptyText=""
340
+ renderRow={(it, sel, i) => {
341
+ const fg = (c: string) => (sel ? theme.text : c)
342
+ const icon = { queued: "·", running: "", done: "✓", failed: "✕", notRun: "–" }[it.state]
343
+ const iconFg = { queued: theme.textFaint, running: theme.brand, done: theme.good, failed: theme.bad, notRun: theme.textFaint }[it.state]
344
+ return (
345
+ <>
346
+ {it.state === "running" ? <Spinner color={fg(theme.brand)} interval={120} /> : <text content={icon} fg={fg(iconFg)} style={{ flexShrink: 0 }} />}
347
+ <text content=" " style={{ flexShrink: 0 }} />
348
+ <text content={truncate(it.site.domain, 44)} fg={fg(theme.textDim)} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
349
+ {i === 0 ? <text content="canary " fg={fg(theme.brand)} style={{ flexShrink: 0 }} /> : null}
350
+ <text content={it.composer ? "⚙ " : " "} fg={fg(theme.warn)} style={{ flexShrink: 0 }} />
351
+ <text content={`${it.from} → ${it.to}`.padStart(17)} fg={fg(it.state === "done" ? theme.good : theme.textFaint)} wrapMode="none" style={{ flexShrink: 0 }} />
352
+ </>
353
+ )
354
+ }}
355
+ />
356
+ </box>
357
+ {/* The selected row's failure, in full — the list row has no room for it. */}
358
+ {selected?.state === "failed" ? <text content={`✕ ${selected.site.domain}: ${selected.error ?? "failed"}`} fg={theme.bad} /> : null}
359
+ </box>
360
+ </Panel>
361
+ </box>
362
+ )
363
+ }
364
+
365
+ function hints() {
366
+ if (run) {
367
+ return run.phase === "done"
368
+ ? [
369
+ { key: "↑↓/jk", label: "site" },
370
+ { key: "esc", label: "close" },
371
+ ]
372
+ : [
373
+ { key: "↑↓/jk", label: "site" },
374
+ { key: "x", label: "stop after current" },
375
+ { key: "esc", label: "run in background" },
376
+ ]
377
+ }
378
+ if (phase === "plan") {
379
+ return [
380
+ { key: "↑↓/jk", label: "site" },
381
+ { key: "space", label: "include/leave out" },
382
+ { key: "m", label: "switch target" },
383
+ { key: "⏎", label: "review" },
384
+ { key: "esc", label: "cancel" },
385
+ ]
386
+ }
387
+ if (phase === "confirm") {
388
+ return [
389
+ { key: "y", label: "start" },
390
+ { key: "←", label: "back" },
391
+ { key: "esc", label: "cancel" },
392
+ ]
393
+ }
394
+ return [{ key: "esc", label: "cancel" }]
395
+ }
396
+ }
@@ -1,6 +1,6 @@
1
1
  // PHP-version upgrade overlay — the app's first *write* action.
2
2
  //
3
- // Opened with `u` on a selected site (Browser / Stacks). Walks through: pick a
3
+ // Reached from the `u` Update menu (SiteUpdate.tsx) on a selected site. Walks through: pick a
4
4
  // version → confirm → fire the upgrade. The actual PUT + event polling live in
5
5
  // the store (`startPhpUpgrade`), so closing this modal (Esc/q) doesn't abandon
6
6
  // the upgrade — the site's row keeps a spinner until it settles. A site whose
@@ -24,7 +24,7 @@ function majorMinor(v: string | null | undefined): string {
24
24
  return min != null ? `${maj}.${min}` : maj
25
25
  }
26
26
 
27
- export function PhpUpgrade() {
27
+ export function PhpUpgrade({ onBack }: { onBack?: () => void } = {}) {
28
28
  const store = useStore()
29
29
  const {
30
30
  phpUpgradeSite: site,
@@ -76,6 +76,8 @@ export function PhpUpgrade() {
76
76
  // Esc/q always closes — the upgrade keeps running in the background.
77
77
  if (name === "escape" || name === "q") return close()
78
78
 
79
+ if ((dp === "pick" || dp === "blocked") && onBack && (name === "left" || name === "h")) return onBack()
80
+
79
81
  if (dp === "pick") {
80
82
  switch (name) {
81
83
  case "up":
@@ -281,6 +283,7 @@ export function PhpUpgrade() {
281
283
  return [
282
284
  { key: "↑↓/jk", label: "version" },
283
285
  { key: "⏎", label: "choose" },
286
+ ...(onBack ? [{ key: "←", label: "back" }] : []),
284
287
  { key: "esc", label: "cancel" },
285
288
  ]
286
289
  case "confirm":
@@ -10,6 +10,7 @@ import { theme, statusColor, statusDot } from "../../lib/theme.ts"
10
10
  import { classifyStack, stackColor, stackTag } from "../../lib/stack.ts"
11
11
  import { truncate } from "../../lib/format.ts"
12
12
  import { Panel, SiteMetaCell, Spinner } from "../components.tsx"
13
+ import { WpCoreRowMark } from "../wpCoreJobs.tsx"
13
14
  import { isDbBackupInFlight } from "../../lib/dbBackup.ts"
14
15
  import { isDbSyncInFlight } from "../../lib/dbSync.ts"
15
16
  import { List, moveSelection } from "../List.tsx"
@@ -370,6 +371,7 @@ export function Search({ rows }: { rows: number }) {
370
371
  if (b?.stage === "error") return <text content="⬇! " fg={sel ? theme.text : theme.bad} wrapMode="none" style={{ flexShrink: 0 }} />
371
372
  return null
372
373
  })()}
374
+ <WpCoreRowMark siteId={r.site.id} selected={sel} compact />
373
375
  <SiteMetaCell
374
376
  linked={localLinks.has(r.site.id)}
375
377
  updates={(r.site.wp_plugin_updates || 0) + (r.site.wp_theme_updates || 0) + (r.site.wp_core_update ? 1 : 0)}
@@ -0,0 +1,124 @@
1
+ // `u` → Update: one entry point for everything you can update on a site.
2
+ //
3
+ // A small menu that hands off to the per-thing flows — PHP version (SpinupWP
4
+ // API, PhpUpgrade.tsx) and WordPress core (wp-cli over SSH, WpCoreUpdate.tsx).
5
+ // Each flow owns its own keyboard while shown; ← from its first screen comes
6
+ // back here. Opened via the store's phpUpgradeSite (the overlay's historical
7
+ // name — it's the "site being updated" now).
8
+
9
+ import { useState } from "react"
10
+ import { useKeyboard } from "@opentui/react"
11
+ import { theme } from "../../lib/theme.ts"
12
+ import { truncate } from "../../lib/format.ts"
13
+ import { effectiveStack } from "../../lib/stack.ts"
14
+ import { Panel, Centered } from "../components.tsx"
15
+ import { StatusBar } from "../StatusBar.tsx"
16
+ import { useStore } from "../store.tsx"
17
+ import { moveSelection } from "../List.tsx"
18
+ import { PhpUpgrade } from "./PhpUpgrade.tsx"
19
+ import { WpCoreUpdate } from "./WpCoreUpdate.tsx"
20
+
21
+ type Section = "menu" | "php" | "wp"
22
+
23
+ export function SiteUpdate() {
24
+ const { phpUpgradeSite: site, setPhpUpgradeSite, probes, isPhpEol, phpUpgrades } = useStore()
25
+ const [section, setSection] = useState<Section>("menu")
26
+ const [index, setIndex] = useState(0)
27
+
28
+ const stack = site ? effectiveStack(site, probes.get(site.id)?.result.kind) : "Non-WP"
29
+ const php = site?.php_version ? site.php_version.split(".").slice(0, 2).join(".") : "—"
30
+ const phpBusy = site ? phpUpgrades.get(site.id) : undefined
31
+
32
+ const items: { key: Section; label: string; detail: string; detailFg: string }[] = [
33
+ {
34
+ key: "php",
35
+ label: "PHP version",
36
+ detail: phpBusy && phpBusy.status !== "failed" ? `→ ${phpBusy.target} in progress` : `PHP ${php}${isPhpEol(php) ? " (EOL)" : ""}`,
37
+ detailFg: isPhpEol(php) ? theme.bad : theme.textFaint,
38
+ },
39
+ {
40
+ key: "wp",
41
+ label: "WordPress core",
42
+ // SpinupWP's own flag is a periodic snapshot; the SSH check is authoritative.
43
+ detail:
44
+ stack === "Non-WP" ? "not detected as WordPress" : site?.wp_core_update ? "update available (per SpinupWP)" : stack === "Standard WP" ? "via wp-cli" : `via wp-cli · ${stack}`,
45
+ detailFg: site?.wp_core_update ? theme.warn : theme.textFaint,
46
+ },
47
+ ]
48
+
49
+ const close = () => setPhpUpgradeSite(null)
50
+
51
+ useKeyboard((key) => {
52
+ if (section !== "menu") return
53
+ const name = key.name ?? ""
54
+ if (name === "escape" || name === "q") return close()
55
+ switch (name) {
56
+ case "up":
57
+ case "k":
58
+ return setIndex((i) => moveSelection(i, -1, items.length))
59
+ case "down":
60
+ case "j":
61
+ return setIndex((i) => moveSelection(i, 1, items.length))
62
+ case "p":
63
+ return setSection("php")
64
+ case "w":
65
+ return setSection("wp")
66
+ case "return":
67
+ case "right":
68
+ case "l":
69
+ return setSection(items[index].key)
70
+ }
71
+ })
72
+
73
+ if (!site) return null
74
+ if (section === "php") return <PhpUpgrade onBack={() => setSection("menu")} />
75
+ if (section === "wp") return <WpCoreUpdate site={site} onBack={() => setSection("menu")} onClose={close} />
76
+
77
+ return (
78
+ <box
79
+ style={{
80
+ position: "absolute",
81
+ top: 0,
82
+ left: 0,
83
+ width: "100%",
84
+ height: "100%",
85
+ flexDirection: "column",
86
+ backgroundColor: theme.bg,
87
+ zIndex: 210,
88
+ }}
89
+ >
90
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
91
+ <text content="⬆ Update " fg={theme.brand} style={{ flexShrink: 0 }} />
92
+ <text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
93
+ <box style={{ flexGrow: 1 }} />
94
+ <text content={stack} fg={theme.textFaint} style={{ flexShrink: 0 }} />
95
+ </box>
96
+
97
+ <Centered>
98
+ <Panel title=" What do you want to update? " active>
99
+ <box style={{ flexDirection: "column", width: 60 }}>
100
+ {items.map((it, i) => {
101
+ const selected = i === index
102
+ return (
103
+ <box key={it.key} style={{ flexDirection: "row", height: 1, backgroundColor: selected ? theme.selectedBg : undefined }}>
104
+ <text content={(selected ? "❯ " : " ") + it.label} fg={selected ? theme.text : theme.textDim} style={{ flexGrow: 1 }} wrapMode="none" />
105
+ <text content={it.detail} fg={selected ? theme.text : it.detailFg} style={{ flexShrink: 0 }} wrapMode="none" />
106
+ </box>
107
+ )
108
+ })}
109
+ </box>
110
+ </Panel>
111
+ </Centered>
112
+
113
+ <StatusBar
114
+ hints={[
115
+ { key: "↑↓/jk", label: "choose" },
116
+ { key: "⏎", label: "open" },
117
+ { key: "p/w", label: "PHP / WordPress" },
118
+ { key: "esc", label: "close" },
119
+ ]}
120
+ showGlobal={false}
121
+ />
122
+ </box>
123
+ )
124
+ }