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.
- package/CHANGELOG.md +24 -1
- package/README.md +9 -5
- package/package.json +1 -1
- package/src/api/client.ts +38 -2
- package/src/config.ts +232 -8
- package/src/index.tsx +25 -5
- package/src/lib/dnsCache.ts +9 -6
- package/src/lib/keychain.ts +11 -5
- package/src/lib/probe.ts +16 -0
- package/src/lib/providersCache.ts +9 -6
- package/src/lib/stackCache.ts +9 -6
- package/src/lib/wpCli.ts +2 -2
- package/src/lib/wpCore.ts +273 -0
- package/src/ui/App.tsx +18 -2
- package/src/ui/Explain.tsx +5 -4
- package/src/ui/Header.tsx +6 -2
- package/src/ui/Help.tsx +2 -1
- package/src/ui/ToastStack.tsx +2 -2
- package/src/ui/accounts.tsx +21 -0
- package/src/ui/components.tsx +1 -1
- package/src/ui/store.tsx +89 -21
- package/src/ui/toast.ts +16 -10
- package/src/ui/views/Accounts.tsx +370 -0
- package/src/ui/views/Browser.tsx +2 -0
- package/src/ui/views/BulkWpUpdate.tsx +396 -0
- package/src/ui/views/PhpUpgrade.tsx +5 -2
- package/src/ui/views/Search.tsx +2 -0
- package/src/ui/views/SiteUpdate.tsx +124 -0
- package/src/ui/views/Stacks.tsx +113 -15
- package/src/ui/views/WpCoreUpdate.tsx +322 -0
- package/src/ui/wpCoreJobs.tsx +253 -0
|
@@ -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
|
-
//
|
|
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":
|
package/src/ui/views/Search.tsx
CHANGED
|
@@ -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
|
+
}
|