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
package/src/ui/views/Stacks.tsx
CHANGED
|
@@ -3,56 +3,83 @@
|
|
|
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 / Vanity Site / Unknown / unprobed). Both come from cached probes,
|
|
10
|
+
// except Vanity Site, which is derived with no SSH (see `nonWpSub`).
|
|
8
11
|
//
|
|
9
12
|
// Middle pane: the sites in the selected group. `d` probes the selected site;
|
|
10
13
|
// `D` probes the whole selected group (bounded concurrency). Right pane: the
|
|
11
14
|
// fleet-wide PHP version distribution with EOL versions flagged.
|
|
12
15
|
|
|
13
|
-
import { useEffect, useMemo, useState } from "react"
|
|
16
|
+
import { useEffect, useMemo, useRef, useState } from "react"
|
|
14
17
|
import { useKeyboard } from "@opentui/react"
|
|
15
18
|
import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
16
19
|
import { bar, truncate } from "../../lib/format.ts"
|
|
17
20
|
import { STACKS, effectiveStack, stackColor, type Stack } from "../../lib/stack.ts"
|
|
18
21
|
import { phpSortKey } from "../../lib/phpEol.ts"
|
|
19
22
|
import { probeKindColor, type ProbeKind } from "../../lib/probe.ts"
|
|
23
|
+
import { compareVersions } from "../../lib/wpCore.ts"
|
|
20
24
|
import { Panel, Spinner, PhpVersionCell, SiteMetaCell } from "../components.tsx"
|
|
25
|
+
import { WpCoreRowMark, clearBulk, peekBulk, useWpCoreJobs } from "../wpCoreJobs.tsx"
|
|
21
26
|
import { List, moveSelection } from "../List.tsx"
|
|
22
27
|
import { StatusBar } from "../StatusBar.tsx"
|
|
23
28
|
import { SiteContextStrip, SITE_CONTEXT_STRIP_HEIGHT } from "../Details.tsx"
|
|
24
29
|
import { openUrl } from "../../lib/open.ts"
|
|
25
30
|
import { siteWebUrl } from "../../lib/spinupweb.ts"
|
|
31
|
+
import { isVanityPair } from "../../lib/vanitySite.ts"
|
|
26
32
|
import { useStore } from "../store.tsx"
|
|
27
33
|
import type { Site } from "../../api/types.ts"
|
|
28
34
|
|
|
29
35
|
type Focus = "groups" | "sites"
|
|
30
36
|
|
|
31
37
|
// A selectable row in the composition pane: a top-level bucket (level 0) or a
|
|
32
|
-
//
|
|
38
|
+
// sub-row (level 1) — a WP core version under a WordPress stack, or a Non-WP
|
|
39
|
+
// sub-category.
|
|
33
40
|
interface Group {
|
|
34
41
|
id: string
|
|
35
42
|
label: string
|
|
43
|
+
title?: string // sites-pane title when the bare label lacks context ("7.0.2" → "Bedrock · WP 7.0.2")
|
|
36
44
|
level: 0 | 1
|
|
37
45
|
color: string
|
|
38
46
|
sites: Site[]
|
|
39
47
|
}
|
|
40
48
|
|
|
41
|
-
|
|
42
|
-
|
|
49
|
+
const WP_STACKS: Stack[] = ["Standard WP", "Bedrock", "Radicle"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
// Non-WP sub-categories, in display order. `kind: null` = not yet probed;
|
|
53
|
+
// "vanity" is the site at its server's own hostname (see `nonWpSub`).
|
|
54
|
+
type NonWpSub = ProbeKind | "vanity" | null
|
|
55
|
+
const NONWP_SUBS: { kind: NonWpSub; label: string }[] = [
|
|
43
56
|
{ kind: "whmcs", label: "WHMCS" },
|
|
44
57
|
{ kind: "laravel", label: "Laravel" },
|
|
45
58
|
{ kind: "static", label: "Static HTML" },
|
|
59
|
+
{ kind: "vanity", label: "Vanity Site" },
|
|
46
60
|
{ kind: "unknown", label: "Unknown" },
|
|
47
61
|
{ kind: null, label: "unprobed" },
|
|
48
62
|
]
|
|
63
|
+
const VANITY_COLOR = theme.info
|
|
64
|
+
|
|
65
|
+
// Which Non-WP sub-row a site belongs in. A vanity site is known from the API
|
|
66
|
+
// alone (domain === its server's hostname, the same `isVanityPair` every other
|
|
67
|
+
// vanity feature keys off), so it claims the site whether it's unprobed or the
|
|
68
|
+
// probe came back Unknown/Static — the placeholder page reads as neither. A
|
|
69
|
+
// positively identified app (WHMCS/Laravel) living at the hostname still wins.
|
|
70
|
+
function nonWpSub(kind: ProbeKind | null, vanity: boolean): NonWpSub {
|
|
71
|
+
if (vanity && (kind === null || kind === "unknown" || kind === "static")) return "vanity"
|
|
72
|
+
return kind
|
|
73
|
+
}
|
|
49
74
|
|
|
50
75
|
export function Stacks({ rows }: { rows: number }) {
|
|
51
76
|
const store = useStore()
|
|
52
|
-
const
|
|
77
|
+
const wpCoreJobs = useWpCoreJobs()
|
|
78
|
+
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
79
|
store
|
|
54
80
|
|
|
55
81
|
const [groupIndex, setGroupIndex] = useState(0)
|
|
82
|
+
const groupTopRef = useRef(0)
|
|
56
83
|
const [siteIndex, setSiteIndex] = useState(0)
|
|
57
84
|
const [focus, setFocus] = useState<Focus>("groups")
|
|
58
85
|
const [flash, setFlash] = useState<string | null>(null)
|
|
@@ -75,19 +102,54 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
75
102
|
}
|
|
76
103
|
for (const list of byStack.values()) list.sort((a, b) => a.domain.localeCompare(b.domain))
|
|
77
104
|
|
|
105
|
+
// The newest core version seen anywhere in the fleet — the one every
|
|
106
|
+
// version row is measured against (highlighted; older rows are dimmed).
|
|
107
|
+
let newestWp: string | null = null
|
|
108
|
+
for (const st of WP_STACKS) {
|
|
109
|
+
for (const site of byStack.get(st)!) {
|
|
110
|
+
const v = probes.get(site.id)?.result.version
|
|
111
|
+
if (v && (!newestWp || compareVersions(v, newestWp) > 0)) newestWp = v
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
78
115
|
const groups: Group[] = []
|
|
79
116
|
for (const st of STACKS) {
|
|
80
117
|
const bucket = byStack.get(st)!
|
|
81
118
|
groups.push({ id: st, label: st, level: 0, color: stackColor(st), sites: bucket })
|
|
119
|
+
if (WP_STACKS.includes(st)) {
|
|
120
|
+
const byVersion = new Map<string, Site[]>()
|
|
121
|
+
const noVersion: Site[] = []
|
|
122
|
+
for (const site of bucket) {
|
|
123
|
+
const v = probes.get(site.id)?.result.version
|
|
124
|
+
if (v) byVersion.set(v, [...(byVersion.get(v) ?? []), site])
|
|
125
|
+
else noVersion.push(site)
|
|
126
|
+
}
|
|
127
|
+
for (const v of [...byVersion.keys()].sort((a, b) => compareVersions(b, a))) {
|
|
128
|
+
groups.push({
|
|
129
|
+
id: `wp:${st}:${v}`,
|
|
130
|
+
label: `WP ${v}`,
|
|
131
|
+
title: `${st} · WP ${v}`,
|
|
132
|
+
level: 1,
|
|
133
|
+
color: v === newestWp ? theme.good : theme.textDim,
|
|
134
|
+
sites: byVersion.get(v)!,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
// Only worth a row when some are versioned — otherwise it'd just repeat the bucket.
|
|
138
|
+
if (noVersion.length && byVersion.size) {
|
|
139
|
+
groups.push({ id: `wp:${st}:none`, label: "no version", title: `${st} · version unknown`, level: 1, color: theme.textFaint, sites: noVersion })
|
|
140
|
+
}
|
|
141
|
+
}
|
|
82
142
|
if (st === "Non-WP") {
|
|
83
143
|
for (const sub of NONWP_SUBS) {
|
|
84
|
-
const subSites = bucket.filter(
|
|
144
|
+
const subSites = bucket.filter(
|
|
145
|
+
(site) => nonWpSub(probes.get(site.id)?.result.kind ?? null, isVanityPair(site.domain, serverById(site.server_id)?.name ?? "")) === sub.kind,
|
|
146
|
+
)
|
|
85
147
|
if (subSites.length === 0) continue
|
|
86
148
|
groups.push({
|
|
87
149
|
id: `nonwp:${sub.label}`,
|
|
88
150
|
label: sub.label,
|
|
89
151
|
level: 1,
|
|
90
|
-
color: sub.kind ? probeKindColor(sub.kind) : theme.textFaint,
|
|
152
|
+
color: sub.kind === "vanity" ? VANITY_COLOR : sub.kind ? probeKindColor(sub.kind) : theme.textFaint,
|
|
91
153
|
sites: subSites,
|
|
92
154
|
})
|
|
93
155
|
}
|
|
@@ -95,12 +157,14 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
95
157
|
}
|
|
96
158
|
const php = [...phpCounts.entries()].sort((a, b) => phpSortKey(b[0]) - phpSortKey(a[0]))
|
|
97
159
|
return { groups, php }
|
|
98
|
-
}, [sites, probes])
|
|
160
|
+
}, [sites, probes, serverById])
|
|
99
161
|
|
|
100
162
|
// Keep selection in range as groups appear/disappear with probing.
|
|
101
163
|
const safeGroupIndex = Math.min(groupIndex, groups.length - 1)
|
|
102
164
|
const selectedGroup = groups[safeGroupIndex]
|
|
103
165
|
const groupSites = selectedGroup?.sites ?? []
|
|
166
|
+
// A "WP x.y.z" (or "no version") row under a WordPress stack — bulk-updatable.
|
|
167
|
+
const isVersionGroup = !!selectedGroup && selectedGroup.id.startsWith("wp:")
|
|
104
168
|
const total = sites.length || 1
|
|
105
169
|
|
|
106
170
|
useEffect(() => {
|
|
@@ -167,8 +231,20 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
167
231
|
}
|
|
168
232
|
return
|
|
169
233
|
case "u":
|
|
170
|
-
//
|
|
234
|
+
// Sites pane: the selected site's Update menu. Groups pane, on a WP
|
|
235
|
+
// version row: bulk-update that whole group (a running or unseen bulk
|
|
236
|
+
// run takes over the overlay instead — only one exists at a time).
|
|
171
237
|
if (focus === "sites" && groupSites[siteIndex]) setPhpUpgradeSite(groupSites[siteIndex])
|
|
238
|
+
else if (focus === "groups" && isVersionGroup && groupSites.length) {
|
|
239
|
+
const title = selectedGroup.title ?? selectedGroup.label
|
|
240
|
+
// A finished run's summary belongs to its own group: reopening THAT
|
|
241
|
+
// group shows it, any other group starts fresh (and the old summary is
|
|
242
|
+
// dropped). A run still in progress takes over regardless — one at a time.
|
|
243
|
+
const prev = peekBulk()
|
|
244
|
+
if (prev?.phase === "done" && prev.title !== title) clearBulk()
|
|
245
|
+
setBulkWpGroup({ title, sites: groupSites })
|
|
246
|
+
}
|
|
247
|
+
else if (focus === "groups") flashMsg("u updates a WordPress version group — pick a “WP x.y.z” row, or a site")
|
|
172
248
|
return
|
|
173
249
|
case "H":
|
|
174
250
|
// Enable/disable HTTPS on the selected site (sites pane only).
|
|
@@ -224,6 +300,13 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
224
300
|
})
|
|
225
301
|
|
|
226
302
|
const listRows = Math.max(3, rows - 6 - SITE_CONTEXT_STRIP_HEIGHT)
|
|
303
|
+
// Version sub-rows can outgrow a short terminal — scroll the groups pane just
|
|
304
|
+
// enough to keep the selection visible (same viewport as the sites list).
|
|
305
|
+
// Only moves when the selection leaves the viewport, like List does.
|
|
306
|
+
let groupTop = Math.min(groupTopRef.current, Math.max(0, groups.length - listRows))
|
|
307
|
+
if (safeGroupIndex < groupTop) groupTop = safeGroupIndex
|
|
308
|
+
else if (safeGroupIndex >= groupTop + listRows) groupTop = safeGroupIndex - listRows + 1
|
|
309
|
+
groupTopRef.current = groupTop
|
|
227
310
|
const maxPhp = Math.max(1, ...php.map(([, n]) => n))
|
|
228
311
|
|
|
229
312
|
const hints =
|
|
@@ -231,6 +314,7 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
231
314
|
? [
|
|
232
315
|
{ key: "↑↓/jk", label: "select" },
|
|
233
316
|
{ key: "→/⏎", label: "view sites" },
|
|
317
|
+
...(isVersionGroup ? [{ key: "u", label: `update ${groupSites.length} ${groupSites.length === 1 ? "site" : "sites"}` }] : []),
|
|
234
318
|
{ key: "D", label: "identify all" },
|
|
235
319
|
{ key: "S", label: "find local copies" },
|
|
236
320
|
{ key: "f", label: "needs local copy" },
|
|
@@ -239,7 +323,7 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
239
323
|
{ key: "↑↓/jk", label: "select site" },
|
|
240
324
|
{ key: "←/esc", label: "back" },
|
|
241
325
|
{ key: "d", label: "identify app" },
|
|
242
|
-
{ key: "u", label: "
|
|
326
|
+
{ key: "u", label: "update" },
|
|
243
327
|
{ key: "o", label: "open" },
|
|
244
328
|
{ key: "w", label: "SpinupWP" },
|
|
245
329
|
{ key: "s", label: "ssh" },
|
|
@@ -257,7 +341,8 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
257
341
|
{/* Composition groups (top-level buckets + Non-WP sub-rows) */}
|
|
258
342
|
<Panel title={` Stacks (${sites.length}) `} active={focus === "groups"} width={36}>
|
|
259
343
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
260
|
-
{groups.map((grp,
|
|
344
|
+
{groups.slice(groupTop, groupTop + listRows).map((grp, j) => {
|
|
345
|
+
const i = groupTop + j
|
|
261
346
|
const n = grp.sites.length
|
|
262
347
|
const selected = i === safeGroupIndex
|
|
263
348
|
const sel = selected && focus === "groups"
|
|
@@ -291,7 +376,7 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
291
376
|
</Panel>
|
|
292
377
|
|
|
293
378
|
{/* Sites in the selected group */}
|
|
294
|
-
<Panel title={` ${selectedGroup?.label ?? "—"} · sites (${groupSites.length}) `} active={focus === "sites"} flexGrow={1}>
|
|
379
|
+
<Panel title={` ${selectedGroup?.title ?? selectedGroup?.label ?? "—"} · sites (${groupSites.length}) `} active={focus === "sites"} flexGrow={1}>
|
|
295
380
|
<List
|
|
296
381
|
items={groupSites}
|
|
297
382
|
selectedIndex={siteIndex}
|
|
@@ -303,7 +388,13 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
303
388
|
const cached = probes.get(s.id)
|
|
304
389
|
const probing = probingIds.has(s.id)
|
|
305
390
|
const errored = probeErrors.has(s.id)
|
|
391
|
+
const wpRunning = wpCoreJobs.get(s.id)?.result === null
|
|
306
392
|
const faint = selected ? theme.text : theme.textFaint
|
|
393
|
+
// Vanity rows say so instead of the probe's "Unknown"/"Static HTML"
|
|
394
|
+
// (or "· press d" — there's nothing to identify).
|
|
395
|
+
const vanity =
|
|
396
|
+
effectiveStack(s, cached?.result.kind) === "Non-WP" &&
|
|
397
|
+
nonWpSub(cached?.result.kind ?? null, isVanityPair(s.domain, serverById(s.server_id)?.name ?? "")) === "vanity"
|
|
307
398
|
const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
|
|
308
399
|
return (
|
|
309
400
|
<>
|
|
@@ -318,8 +409,13 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
318
409
|
<SiteMetaCell linked={localLinks.has(s.id)} updates={updates} selected={selected} />
|
|
319
410
|
</box>
|
|
320
411
|
<box style={{ flexShrink: 0, flexDirection: "row", marginLeft: 1 }}>
|
|
321
|
-
{
|
|
412
|
+
{wpRunning ? (
|
|
413
|
+
// A core update in flight replaces the version it's about to change.
|
|
414
|
+
<WpCoreRowMark siteId={s.id} selected={selected} />
|
|
415
|
+
) : probing ? (
|
|
322
416
|
<Spinner color={selected ? theme.text : theme.brand} />
|
|
417
|
+
) : vanity ? (
|
|
418
|
+
<text content="Vanity Site" fg={selected ? theme.text : VANITY_COLOR} wrapMode="none" />
|
|
323
419
|
) : cached ? (
|
|
324
420
|
<text
|
|
325
421
|
content={truncate(cached.result.label, 20) + (isProbeStale(s) ? "*" : "")}
|
|
@@ -331,6 +427,8 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
331
427
|
) : (
|
|
332
428
|
<text content="· press d" fg={faint} wrapMode="none" />
|
|
333
429
|
)}
|
|
430
|
+
{/* Failed: keep the (unchanged) version, flag it — like PHP's ⬆!. */}
|
|
431
|
+
{!wpRunning && <WpCoreRowMark siteId={s.id} selected={selected} />}
|
|
334
432
|
</box>
|
|
335
433
|
<text
|
|
336
434
|
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
|
+
}
|