spinuptui 0.26.1 → 0.27.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 +14 -1
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/api/client.ts +38 -2
- package/src/lib/probe.ts +16 -0
- package/src/lib/wpCore.ts +273 -0
- package/src/ui/App.tsx +8 -2
- package/src/ui/Explain.tsx +5 -4
- package/src/ui/Header.tsx +1 -1
- package/src/ui/Help.tsx +1 -1
- package/src/ui/ToastStack.tsx +2 -2
- package/src/ui/components.tsx +1 -1
- package/src/ui/store.tsx +24 -1
- package/src/ui/toast.ts +16 -10
- 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 +83 -10
- package/src/ui/views/WpCoreUpdate.tsx +322 -0
- package/src/ui/wpCoreJobs.tsx +235 -0
package/src/ui/views/Stacks.tsx
CHANGED
|
@@ -3,21 +3,25 @@
|
|
|
3
3
|
// Left pane: selectable composition groups. The three top-level buckets
|
|
4
4
|
// (Standard WP / Bedrock / Non-WP) use the EFFECTIVE stack — a conclusive Tier-2
|
|
5
5
|
// probe overrides the API heuristic — so counts drift toward reality as you
|
|
6
|
-
// probe.
|
|
7
|
-
//
|
|
6
|
+
// probe. The WordPress buckets (Standard WP / Bedrock / Radicle) expand into one
|
|
7
|
+
// sub-row per probed core version, newest first — the "who still needs the
|
|
8
|
+
// security release" view; Non-WP expands into named sub-rows (WHMCS / Laravel /
|
|
9
|
+
// Static HTML / Unknown / unprobed). Both come from cached probes.
|
|
8
10
|
//
|
|
9
11
|
// Middle pane: the sites in the selected group. `d` probes the selected site;
|
|
10
12
|
// `D` probes the whole selected group (bounded concurrency). Right pane: the
|
|
11
13
|
// fleet-wide PHP version distribution with EOL versions flagged.
|
|
12
14
|
|
|
13
|
-
import { useEffect, useMemo, useState } from "react"
|
|
15
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
14
16
|
import { useKeyboard } from "@opentui/react"
|
|
15
17
|
import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
16
18
|
import { bar, truncate } from "../../lib/format.ts"
|
|
17
19
|
import { STACKS, effectiveStack, stackColor, type Stack } from "../../lib/stack.ts"
|
|
18
20
|
import { phpSortKey } from "../../lib/phpEol.ts"
|
|
19
21
|
import { probeKindColor, type ProbeKind } from "../../lib/probe.ts"
|
|
22
|
+
import { compareVersions } from "../../lib/wpCore.ts"
|
|
20
23
|
import { Panel, Spinner, PhpVersionCell, SiteMetaCell } from "../components.tsx"
|
|
24
|
+
import { WpCoreRowMark, clearBulk, peekBulk, useWpCoreJobs } from "../wpCoreJobs.tsx"
|
|
21
25
|
import { List, moveSelection } from "../List.tsx"
|
|
22
26
|
import { StatusBar } from "../StatusBar.tsx"
|
|
23
27
|
import { SiteContextStrip, SITE_CONTEXT_STRIP_HEIGHT } from "../Details.tsx"
|
|
@@ -29,15 +33,20 @@ import type { Site } from "../../api/types.ts"
|
|
|
29
33
|
type Focus = "groups" | "sites"
|
|
30
34
|
|
|
31
35
|
// A selectable row in the composition pane: a top-level bucket (level 0) or a
|
|
32
|
-
//
|
|
36
|
+
// sub-row (level 1) — a WP core version under a WordPress stack, or a Non-WP
|
|
37
|
+
// sub-category.
|
|
33
38
|
interface Group {
|
|
34
39
|
id: string
|
|
35
40
|
label: string
|
|
41
|
+
title?: string // sites-pane title when the bare label lacks context ("7.0.2" → "Bedrock · WP 7.0.2")
|
|
36
42
|
level: 0 | 1
|
|
37
43
|
color: string
|
|
38
44
|
sites: Site[]
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
const WP_STACKS: Stack[] = ["Standard WP", "Bedrock", "Radicle"]
|
|
48
|
+
|
|
49
|
+
|
|
41
50
|
// Non-WP sub-categories, in display order. `kind: null` = not yet probed.
|
|
42
51
|
const NONWP_SUBS: { kind: ProbeKind | null; label: string }[] = [
|
|
43
52
|
{ kind: "whmcs", label: "WHMCS" },
|
|
@@ -49,10 +58,12 @@ const NONWP_SUBS: { kind: ProbeKind | null; label: string }[] = [
|
|
|
49
58
|
|
|
50
59
|
export function Stacks({ rows }: { rows: number }) {
|
|
51
60
|
const store = useStore()
|
|
52
|
-
const
|
|
61
|
+
const wpCoreJobs = useWpCoreJobs()
|
|
62
|
+
const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, accountSlug, setPhpUpgradeSite, setBulkWpGroup, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
|
|
53
63
|
store
|
|
54
64
|
|
|
55
65
|
const [groupIndex, setGroupIndex] = useState(0)
|
|
66
|
+
const groupTopRef = useRef(0)
|
|
56
67
|
const [siteIndex, setSiteIndex] = useState(0)
|
|
57
68
|
const [focus, setFocus] = useState<Focus>("groups")
|
|
58
69
|
const [flash, setFlash] = useState<string | null>(null)
|
|
@@ -75,10 +86,43 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
75
86
|
}
|
|
76
87
|
for (const list of byStack.values()) list.sort((a, b) => a.domain.localeCompare(b.domain))
|
|
77
88
|
|
|
89
|
+
// The newest core version seen anywhere in the fleet — the one every
|
|
90
|
+
// version row is measured against (highlighted; older rows are dimmed).
|
|
91
|
+
let newestWp: string | null = null
|
|
92
|
+
for (const st of WP_STACKS) {
|
|
93
|
+
for (const site of byStack.get(st)!) {
|
|
94
|
+
const v = probes.get(site.id)?.result.version
|
|
95
|
+
if (v && (!newestWp || compareVersions(v, newestWp) > 0)) newestWp = v
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
78
99
|
const groups: Group[] = []
|
|
79
100
|
for (const st of STACKS) {
|
|
80
101
|
const bucket = byStack.get(st)!
|
|
81
102
|
groups.push({ id: st, label: st, level: 0, color: stackColor(st), sites: bucket })
|
|
103
|
+
if (WP_STACKS.includes(st)) {
|
|
104
|
+
const byVersion = new Map<string, Site[]>()
|
|
105
|
+
const noVersion: Site[] = []
|
|
106
|
+
for (const site of bucket) {
|
|
107
|
+
const v = probes.get(site.id)?.result.version
|
|
108
|
+
if (v) byVersion.set(v, [...(byVersion.get(v) ?? []), site])
|
|
109
|
+
else noVersion.push(site)
|
|
110
|
+
}
|
|
111
|
+
for (const v of [...byVersion.keys()].sort((a, b) => compareVersions(b, a))) {
|
|
112
|
+
groups.push({
|
|
113
|
+
id: `wp:${st}:${v}`,
|
|
114
|
+
label: `WP ${v}`,
|
|
115
|
+
title: `${st} · WP ${v}`,
|
|
116
|
+
level: 1,
|
|
117
|
+
color: v === newestWp ? theme.good : theme.textDim,
|
|
118
|
+
sites: byVersion.get(v)!,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
// Only worth a row when some are versioned — otherwise it'd just repeat the bucket.
|
|
122
|
+
if (noVersion.length && byVersion.size) {
|
|
123
|
+
groups.push({ id: `wp:${st}:none`, label: "no version", title: `${st} · version unknown`, level: 1, color: theme.textFaint, sites: noVersion })
|
|
124
|
+
}
|
|
125
|
+
}
|
|
82
126
|
if (st === "Non-WP") {
|
|
83
127
|
for (const sub of NONWP_SUBS) {
|
|
84
128
|
const subSites = bucket.filter((site) => (probes.get(site.id)?.result.kind ?? null) === sub.kind)
|
|
@@ -101,6 +145,8 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
101
145
|
const safeGroupIndex = Math.min(groupIndex, groups.length - 1)
|
|
102
146
|
const selectedGroup = groups[safeGroupIndex]
|
|
103
147
|
const groupSites = selectedGroup?.sites ?? []
|
|
148
|
+
// A "WP x.y.z" (or "no version") row under a WordPress stack — bulk-updatable.
|
|
149
|
+
const isVersionGroup = !!selectedGroup && selectedGroup.id.startsWith("wp:")
|
|
104
150
|
const total = sites.length || 1
|
|
105
151
|
|
|
106
152
|
useEffect(() => {
|
|
@@ -167,8 +213,20 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
167
213
|
}
|
|
168
214
|
return
|
|
169
215
|
case "u":
|
|
170
|
-
//
|
|
216
|
+
// Sites pane: the selected site's Update menu. Groups pane, on a WP
|
|
217
|
+
// version row: bulk-update that whole group (a running or unseen bulk
|
|
218
|
+
// run takes over the overlay instead — only one exists at a time).
|
|
171
219
|
if (focus === "sites" && groupSites[siteIndex]) setPhpUpgradeSite(groupSites[siteIndex])
|
|
220
|
+
else if (focus === "groups" && isVersionGroup && groupSites.length) {
|
|
221
|
+
const title = selectedGroup.title ?? selectedGroup.label
|
|
222
|
+
// A finished run's summary belongs to its own group: reopening THAT
|
|
223
|
+
// group shows it, any other group starts fresh (and the old summary is
|
|
224
|
+
// dropped). A run still in progress takes over regardless — one at a time.
|
|
225
|
+
const prev = peekBulk()
|
|
226
|
+
if (prev?.phase === "done" && prev.title !== title) clearBulk()
|
|
227
|
+
setBulkWpGroup({ title, sites: groupSites })
|
|
228
|
+
}
|
|
229
|
+
else if (focus === "groups") flashMsg("u updates a WordPress version group — pick a “WP x.y.z” row, or a site")
|
|
172
230
|
return
|
|
173
231
|
case "H":
|
|
174
232
|
// Enable/disable HTTPS on the selected site (sites pane only).
|
|
@@ -224,6 +282,13 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
224
282
|
})
|
|
225
283
|
|
|
226
284
|
const listRows = Math.max(3, rows - 6 - SITE_CONTEXT_STRIP_HEIGHT)
|
|
285
|
+
// Version sub-rows can outgrow a short terminal — scroll the groups pane just
|
|
286
|
+
// enough to keep the selection visible (same viewport as the sites list).
|
|
287
|
+
// Only moves when the selection leaves the viewport, like List does.
|
|
288
|
+
let groupTop = Math.min(groupTopRef.current, Math.max(0, groups.length - listRows))
|
|
289
|
+
if (safeGroupIndex < groupTop) groupTop = safeGroupIndex
|
|
290
|
+
else if (safeGroupIndex >= groupTop + listRows) groupTop = safeGroupIndex - listRows + 1
|
|
291
|
+
groupTopRef.current = groupTop
|
|
227
292
|
const maxPhp = Math.max(1, ...php.map(([, n]) => n))
|
|
228
293
|
|
|
229
294
|
const hints =
|
|
@@ -231,6 +296,7 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
231
296
|
? [
|
|
232
297
|
{ key: "↑↓/jk", label: "select" },
|
|
233
298
|
{ key: "→/⏎", label: "view sites" },
|
|
299
|
+
...(isVersionGroup ? [{ key: "u", label: `update ${groupSites.length} ${groupSites.length === 1 ? "site" : "sites"}` }] : []),
|
|
234
300
|
{ key: "D", label: "identify all" },
|
|
235
301
|
{ key: "S", label: "find local copies" },
|
|
236
302
|
{ key: "f", label: "needs local copy" },
|
|
@@ -239,7 +305,7 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
239
305
|
{ key: "↑↓/jk", label: "select site" },
|
|
240
306
|
{ key: "←/esc", label: "back" },
|
|
241
307
|
{ key: "d", label: "identify app" },
|
|
242
|
-
{ key: "u", label: "
|
|
308
|
+
{ key: "u", label: "update" },
|
|
243
309
|
{ key: "o", label: "open" },
|
|
244
310
|
{ key: "w", label: "SpinupWP" },
|
|
245
311
|
{ key: "s", label: "ssh" },
|
|
@@ -257,7 +323,8 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
257
323
|
{/* Composition groups (top-level buckets + Non-WP sub-rows) */}
|
|
258
324
|
<Panel title={` Stacks (${sites.length}) `} active={focus === "groups"} width={36}>
|
|
259
325
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
260
|
-
{groups.map((grp,
|
|
326
|
+
{groups.slice(groupTop, groupTop + listRows).map((grp, j) => {
|
|
327
|
+
const i = groupTop + j
|
|
261
328
|
const n = grp.sites.length
|
|
262
329
|
const selected = i === safeGroupIndex
|
|
263
330
|
const sel = selected && focus === "groups"
|
|
@@ -291,7 +358,7 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
291
358
|
</Panel>
|
|
292
359
|
|
|
293
360
|
{/* Sites in the selected group */}
|
|
294
|
-
<Panel title={` ${selectedGroup?.label ?? "—"} · sites (${groupSites.length}) `} active={focus === "sites"} flexGrow={1}>
|
|
361
|
+
<Panel title={` ${selectedGroup?.title ?? selectedGroup?.label ?? "—"} · sites (${groupSites.length}) `} active={focus === "sites"} flexGrow={1}>
|
|
295
362
|
<List
|
|
296
363
|
items={groupSites}
|
|
297
364
|
selectedIndex={siteIndex}
|
|
@@ -303,6 +370,7 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
303
370
|
const cached = probes.get(s.id)
|
|
304
371
|
const probing = probingIds.has(s.id)
|
|
305
372
|
const errored = probeErrors.has(s.id)
|
|
373
|
+
const wpRunning = wpCoreJobs.get(s.id)?.result === null
|
|
306
374
|
const faint = selected ? theme.text : theme.textFaint
|
|
307
375
|
const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
|
|
308
376
|
return (
|
|
@@ -318,7 +386,10 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
318
386
|
<SiteMetaCell linked={localLinks.has(s.id)} updates={updates} selected={selected} />
|
|
319
387
|
</box>
|
|
320
388
|
<box style={{ flexShrink: 0, flexDirection: "row", marginLeft: 1 }}>
|
|
321
|
-
{
|
|
389
|
+
{wpRunning ? (
|
|
390
|
+
// A core update in flight replaces the version it's about to change.
|
|
391
|
+
<WpCoreRowMark siteId={s.id} selected={selected} />
|
|
392
|
+
) : probing ? (
|
|
322
393
|
<Spinner color={selected ? theme.text : theme.brand} />
|
|
323
394
|
) : cached ? (
|
|
324
395
|
<text
|
|
@@ -331,6 +402,8 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
331
402
|
) : (
|
|
332
403
|
<text content="· press d" fg={faint} wrapMode="none" />
|
|
333
404
|
)}
|
|
405
|
+
{/* Failed: keep the (unchanged) version, flag it — like PHP's ⬆!. */}
|
|
406
|
+
{!wpRunning && <WpCoreRowMark siteId={s.id} selected={selected} />}
|
|
334
407
|
</box>
|
|
335
408
|
<text
|
|
336
409
|
content={truncate(serverById(s.server_id)?.name ?? "", 16)}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// WordPress core update — the "WordPress core" branch of the `u` Update menu.
|
|
2
|
+
//
|
|
3
|
+
// check (SSH, read-only) → pick a version → confirm → update → done/failed. The
|
|
4
|
+
// work itself lives in lib/wpCore.ts; the job is tracked in ../wpCoreJobs.tsx, so
|
|
5
|
+
// Esc doesn't abandon it: the SSH run keeps going, a toast reports the outcome,
|
|
6
|
+
// and reopening the overlay on the same site picks the job back up.
|
|
7
|
+
|
|
8
|
+
import { useEffect, useState } from "react"
|
|
9
|
+
import { useKeyboard } from "@opentui/react"
|
|
10
|
+
import { theme } from "../../lib/theme.ts"
|
|
11
|
+
import { truncate } from "../../lib/format.ts"
|
|
12
|
+
import { Panel, Spinner, Centered } from "../components.tsx"
|
|
13
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
14
|
+
import { useStore } from "../store.tsx"
|
|
15
|
+
import { moveSelection } from "../List.tsx"
|
|
16
|
+
import type { Site } from "../../api/types.ts"
|
|
17
|
+
import { checkWpCore, type WpCoreStatus } from "../../lib/wpCore.ts"
|
|
18
|
+
import { setCoreJob, startCoreUpdate, useWpCoreJob } from "../wpCoreJobs.tsx"
|
|
19
|
+
|
|
20
|
+
// ---- view -------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
type Phase = "checking" | "checkError" | "upToDate" | "pick" | "confirm"
|
|
23
|
+
|
|
24
|
+
export function WpCoreUpdate({ site, onBack, onClose }: { site: Site; onBack: () => void; onClose: () => void }) {
|
|
25
|
+
const { serverById, sshUser, noteWpVersion } = useStore()
|
|
26
|
+
const server = serverById(site.server_id)
|
|
27
|
+
const job = useWpCoreJob(site.id)
|
|
28
|
+
|
|
29
|
+
const [phase, setPhase] = useState<Phase>("checking")
|
|
30
|
+
const [status, setStatus] = useState<WpCoreStatus | null>(null)
|
|
31
|
+
const [checkError, setCheckError] = useState("")
|
|
32
|
+
const [index, setIndex] = useState(0)
|
|
33
|
+
const [nonce, setNonce] = useState(0) // bump to re-check
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!server) {
|
|
37
|
+
setCheckError("This site's server isn't loaded.")
|
|
38
|
+
setPhase("checkError")
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
let live = true
|
|
42
|
+
setPhase("checking")
|
|
43
|
+
void checkWpCore(server, site, sshUser).then((res) => {
|
|
44
|
+
if (!live) return
|
|
45
|
+
if (!res.ok) {
|
|
46
|
+
setCheckError(`${res.error} (${res.target})`)
|
|
47
|
+
setPhase("checkError")
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
setStatus(res.status)
|
|
51
|
+
// A fresh live read — correct any stale row while we're here, update or not.
|
|
52
|
+
noteWpVersion(site, res.status.current)
|
|
53
|
+
// Land on the first minor (security/maintenance) release when there is one
|
|
54
|
+
// — the usual reason to be here — rather than a major jump.
|
|
55
|
+
const minor = res.status.offers.findIndex((o) => o.updateType === "minor")
|
|
56
|
+
setIndex(minor >= 0 ? minor : 0)
|
|
57
|
+
setPhase(res.status.offers.length ? "pick" : "upToDate")
|
|
58
|
+
})
|
|
59
|
+
return () => {
|
|
60
|
+
live = false
|
|
61
|
+
}
|
|
62
|
+
// Keyed on ids, not objects: a background store refresh hands back a new
|
|
63
|
+
// server object, and re-checking then would yank the user out of pick/confirm.
|
|
64
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
+
}, [server?.id, site.id, sshUser, nonce])
|
|
66
|
+
|
|
67
|
+
const offers = status?.offers ?? []
|
|
68
|
+
const target = offers[index]
|
|
69
|
+
// A job for this site overrides the local flow: running → progress; settled → its result.
|
|
70
|
+
const view: Phase | "running" | "done" | "failed" = job ? (job.result === null ? "running" : job.result.ok ? "done" : "failed") : phase
|
|
71
|
+
|
|
72
|
+
useKeyboard((key) => {
|
|
73
|
+
const name = key.name ?? ""
|
|
74
|
+
if (name === "escape" || name === "q") {
|
|
75
|
+
if (job?.result) setCoreJob(site.id, null) // the outcome's been seen — don't replay it next time
|
|
76
|
+
return onClose()
|
|
77
|
+
}
|
|
78
|
+
if (view === "done" || view === "failed") {
|
|
79
|
+
if (name === "r" || (view === "done" && (name === "left" || name === "h"))) {
|
|
80
|
+
setCoreJob(site.id, null)
|
|
81
|
+
setNonce((n) => n + 1)
|
|
82
|
+
}
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
if (view === "running") return
|
|
86
|
+
if (view === "checking" || view === "checkError" || view === "upToDate") {
|
|
87
|
+
if (name === "left" || name === "h") return onBack()
|
|
88
|
+
if (name === "r" && view !== "checking") setNonce((n) => n + 1)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (view === "pick") {
|
|
92
|
+
switch (name) {
|
|
93
|
+
case "up":
|
|
94
|
+
case "k":
|
|
95
|
+
return setIndex((i) => moveSelection(i, -1, offers.length))
|
|
96
|
+
case "down":
|
|
97
|
+
case "j":
|
|
98
|
+
return setIndex((i) => moveSelection(i, 1, offers.length))
|
|
99
|
+
case "left":
|
|
100
|
+
case "h":
|
|
101
|
+
return onBack()
|
|
102
|
+
case "return":
|
|
103
|
+
case "right":
|
|
104
|
+
case "l":
|
|
105
|
+
if (target) setPhase("confirm")
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (view === "confirm") {
|
|
111
|
+
if (name === "y" && server && status && target) startCoreUpdate(server, site, sshUser, target.version, status.multisite, noteWpVersion)
|
|
112
|
+
else if (name === "left" || name === "h") setPhase("pick")
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<box
|
|
118
|
+
style={{
|
|
119
|
+
position: "absolute",
|
|
120
|
+
top: 0,
|
|
121
|
+
left: 0,
|
|
122
|
+
width: "100%",
|
|
123
|
+
height: "100%",
|
|
124
|
+
flexDirection: "column",
|
|
125
|
+
backgroundColor: theme.bg,
|
|
126
|
+
zIndex: 210,
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
130
|
+
<text content="⬆ Update WordPress " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
131
|
+
<text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
132
|
+
<box style={{ flexGrow: 1 }} />
|
|
133
|
+
<text content={status ? `current WP ${status.current}` : ""} fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
134
|
+
</box>
|
|
135
|
+
|
|
136
|
+
<Centered>{renderBody()}</Centered>
|
|
137
|
+
|
|
138
|
+
<StatusBar hints={hints()} showGlobal={false} />
|
|
139
|
+
</box>
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
function composerLines(): string[] {
|
|
143
|
+
if (!status?.composer) return []
|
|
144
|
+
const pin = status.composer.lockedVersion ? `composer.lock pins ${status.composer.lockedVersion}` : "Composer manages core"
|
|
145
|
+
return [
|
|
146
|
+
`Core is installed by Composer (${pin}). This patches the`,
|
|
147
|
+
"files on the server only — the next composer install (a deploy,",
|
|
148
|
+
"a clone) puts the locked version back. Run composer update and",
|
|
149
|
+
"commit the lock to make it stick.",
|
|
150
|
+
]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function renderBody() {
|
|
154
|
+
if (view === "checking") {
|
|
155
|
+
return (
|
|
156
|
+
<box style={{ flexDirection: "row" }}>
|
|
157
|
+
<Spinner />
|
|
158
|
+
<text content=" Checking WordPress over SSH…" fg={theme.textDim} />
|
|
159
|
+
</box>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (view === "checkError") {
|
|
164
|
+
return (
|
|
165
|
+
<Panel title=" Couldn't check WordPress " active>
|
|
166
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
167
|
+
<text content={`✕ ${checkError}`} fg={theme.bad} />
|
|
168
|
+
<box style={{ height: 1 }} />
|
|
169
|
+
<text content="r to retry · ← back · Esc to close" fg={theme.textFaint} wrapMode="none" />
|
|
170
|
+
</box>
|
|
171
|
+
</Panel>
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (view === "upToDate") {
|
|
176
|
+
return (
|
|
177
|
+
<Panel title=" Up to date " active>
|
|
178
|
+
<box style={{ flexDirection: "column", width: 56, paddingTop: 1, paddingBottom: 1 }}>
|
|
179
|
+
<box style={{ flexDirection: "row" }}>
|
|
180
|
+
<text content="✓ " fg={theme.good} />
|
|
181
|
+
<text content={`WordPress ${status?.current} is the latest version.`} fg={theme.text} wrapMode="none" />
|
|
182
|
+
</box>
|
|
183
|
+
<box style={{ height: 1 }} />
|
|
184
|
+
<text content="r to re-check · ← back · Esc to close" fg={theme.textFaint} wrapMode="none" />
|
|
185
|
+
</box>
|
|
186
|
+
</Panel>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (view === "pick") {
|
|
191
|
+
return (
|
|
192
|
+
<Panel title=" Choose a WordPress version " active>
|
|
193
|
+
<box style={{ flexDirection: "column", width: 56 }}>
|
|
194
|
+
{offers.map((o, i) => {
|
|
195
|
+
const selected = i === index
|
|
196
|
+
const tag = o.updateType ? ` (${o.updateType})` : ""
|
|
197
|
+
return (
|
|
198
|
+
<box key={o.version} style={{ flexDirection: "row", height: 1, backgroundColor: selected ? theme.selectedBg : undefined }}>
|
|
199
|
+
<text content={(selected ? "❯ " : " ") + `WordPress ${o.version}`} fg={selected ? theme.text : theme.textDim} style={{ flexGrow: 1 }} wrapMode="none" />
|
|
200
|
+
<text content={tag} fg={selected ? theme.text : o.updateType === "major" ? theme.warn : theme.textFaint} style={{ flexShrink: 0 }} />
|
|
201
|
+
</box>
|
|
202
|
+
)
|
|
203
|
+
})}
|
|
204
|
+
<box style={{ height: 1 }} />
|
|
205
|
+
<text content={`Installed: WordPress ${status?.current}`} fg={theme.textFaint} wrapMode="none" />
|
|
206
|
+
{status?.composer ? <text content="Composer-managed core — see the note on the next screen." fg={theme.warn} wrapMode="none" /> : null}
|
|
207
|
+
</box>
|
|
208
|
+
</Panel>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (view === "confirm" && target && status) {
|
|
213
|
+
return (
|
|
214
|
+
<Panel title=" Confirm update " active>
|
|
215
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
216
|
+
<box style={{ flexDirection: "row" }}>
|
|
217
|
+
<text content="Update " fg={theme.text} />
|
|
218
|
+
<text content={truncate(site.domain, 40)} fg={theme.accent} wrapMode="none" />
|
|
219
|
+
</box>
|
|
220
|
+
<box style={{ flexDirection: "row" }}>
|
|
221
|
+
<text content={`from WordPress ${status.current}`} fg={theme.textDim} />
|
|
222
|
+
<text content=" → " fg={theme.textFaint} />
|
|
223
|
+
<text content={`WordPress ${target.version}`} fg={theme.good} />
|
|
224
|
+
</box>
|
|
225
|
+
<box style={{ height: 1 }} />
|
|
226
|
+
<text content={`Runs wp core update, then the database upgrade${status.multisite ? " (network-wide)" : ""}.`} fg={theme.textDim} wrapMode="none" />
|
|
227
|
+
{target.updateType === "major" ? <text content="Major release — test plugins and themes against it." fg={theme.warn} wrapMode="none" /> : null}
|
|
228
|
+
{composerLines().length ? <box style={{ height: 1 }} /> : null}
|
|
229
|
+
{composerLines().map((l) => (
|
|
230
|
+
<text key={l} content={l} fg={theme.warn} wrapMode="none" />
|
|
231
|
+
))}
|
|
232
|
+
<box style={{ height: 1 }} />
|
|
233
|
+
<text content="Press y to confirm · ← to go back · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
234
|
+
</box>
|
|
235
|
+
</Panel>
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (view === "running") {
|
|
240
|
+
return (
|
|
241
|
+
<box style={{ flexDirection: "column", alignItems: "center" }}>
|
|
242
|
+
<box style={{ flexDirection: "row" }}>
|
|
243
|
+
<Spinner />
|
|
244
|
+
<text content={` Updating to WordPress ${job?.version}…`} fg={theme.textDim} />
|
|
245
|
+
</box>
|
|
246
|
+
<box style={{ height: 1 }} />
|
|
247
|
+
<text content="You can press Esc — it keeps running in the background." fg={theme.textFaint} wrapMode="none" />
|
|
248
|
+
</box>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (view === "done" && job?.result?.ok) {
|
|
253
|
+
return (
|
|
254
|
+
<Panel title=" Done " active>
|
|
255
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
256
|
+
<box style={{ flexDirection: "row" }}>
|
|
257
|
+
<text content="✓ " fg={theme.good} />
|
|
258
|
+
<text content={truncate(site.domain, 32)} fg={theme.accent} wrapMode="none" />
|
|
259
|
+
<text content={` is now on WordPress ${job.result.after}`} fg={theme.text} wrapMode="none" />
|
|
260
|
+
</box>
|
|
261
|
+
<text content={`(was ${job.result.before})`} fg={theme.textFaint} wrapMode="none" />
|
|
262
|
+
{composerLines().length ? <box style={{ height: 1 }} /> : null}
|
|
263
|
+
{composerLines().map((l) => (
|
|
264
|
+
<text key={l} content={l} fg={theme.warn} wrapMode="none" />
|
|
265
|
+
))}
|
|
266
|
+
<box style={{ height: 1 }} />
|
|
267
|
+
<text content="Esc to close" fg={theme.textFaint} />
|
|
268
|
+
</box>
|
|
269
|
+
</Panel>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// failed
|
|
274
|
+
const result = job?.result && !job.result.ok ? job.result : null
|
|
275
|
+
const tail = (result?.log ?? "").split("\n").filter(Boolean).slice(-8)
|
|
276
|
+
return (
|
|
277
|
+
<Panel title=" Update failed " active>
|
|
278
|
+
<box style={{ flexDirection: "column", width: 76, paddingTop: 1, paddingBottom: 1 }}>
|
|
279
|
+
<text content={`✕ ${result?.error ?? "Something went wrong."}`} fg={theme.bad} />
|
|
280
|
+
{tail.length ? <box style={{ height: 1 }} /> : null}
|
|
281
|
+
{tail.map((l, i) => (
|
|
282
|
+
<text key={i} content={truncate(l, 74)} fg={theme.textDim} wrapMode="none" />
|
|
283
|
+
))}
|
|
284
|
+
<box style={{ height: 1 }} />
|
|
285
|
+
<text content="Press r to re-check the site · Esc to close" fg={theme.textFaint} wrapMode="none" />
|
|
286
|
+
</box>
|
|
287
|
+
</Panel>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function hints() {
|
|
292
|
+
switch (view) {
|
|
293
|
+
case "pick":
|
|
294
|
+
return [
|
|
295
|
+
{ key: "↑↓/jk", label: "version" },
|
|
296
|
+
{ key: "⏎", label: "choose" },
|
|
297
|
+
{ key: "←", label: "back" },
|
|
298
|
+
{ key: "esc", label: "cancel" },
|
|
299
|
+
]
|
|
300
|
+
case "confirm":
|
|
301
|
+
return [
|
|
302
|
+
{ key: "y", label: "confirm" },
|
|
303
|
+
{ key: "←", label: "back" },
|
|
304
|
+
{ key: "esc", label: "cancel" },
|
|
305
|
+
]
|
|
306
|
+
case "checkError":
|
|
307
|
+
case "upToDate":
|
|
308
|
+
return [
|
|
309
|
+
{ key: "r", label: "re-check" },
|
|
310
|
+
{ key: "←", label: "back" },
|
|
311
|
+
{ key: "esc", label: "close" },
|
|
312
|
+
]
|
|
313
|
+
case "failed":
|
|
314
|
+
return [
|
|
315
|
+
{ key: "r", label: "re-check" },
|
|
316
|
+
{ key: "esc", label: "close" },
|
|
317
|
+
]
|
|
318
|
+
default:
|
|
319
|
+
return [{ key: "esc", label: "close" }]
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|