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
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Background WordPress core updates — one site at a time (the `u` → WordPress
|
|
2
|
+
// flow) or a whole version group at once (`u` on a Stacks version row).
|
|
3
|
+
//
|
|
4
|
+
// Both live in module-level stores rather than React state, so closing an
|
|
5
|
+
// overlay never abandons SSH work in flight: the rows keep their markers, a toast
|
|
6
|
+
// reports the outcome, and reopening the overlay picks the job back up. Every
|
|
7
|
+
// update — single or bulk — goes through the same per-site job map, which is what
|
|
8
|
+
// the list rows' WpCoreRowMark reads.
|
|
9
|
+
|
|
10
|
+
import { useSyncExternalStore } from "react"
|
|
11
|
+
import { theme } from "../lib/theme.ts"
|
|
12
|
+
import { Spinner } from "./components.tsx"
|
|
13
|
+
import { toast } from "./toast.ts"
|
|
14
|
+
import type { Server, Site } from "../api/types.ts"
|
|
15
|
+
import { updateWpCore, type WpCoreUpdateResult } from "../lib/wpCore.ts"
|
|
16
|
+
|
|
17
|
+
// ---- per-site jobs ------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export interface CoreJob {
|
|
20
|
+
version: string
|
|
21
|
+
result: WpCoreUpdateResult | null // null while running
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let jobs = new Map<number, CoreJob>()
|
|
25
|
+
const jobListeners = new Set<() => void>()
|
|
26
|
+
const subscribeJobs = (l: () => void) => {
|
|
27
|
+
jobListeners.add(l)
|
|
28
|
+
return () => jobListeners.delete(l)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function setCoreJob(siteId: number, job: CoreJob | null) {
|
|
32
|
+
jobs = new Map(jobs)
|
|
33
|
+
if (job) jobs.set(siteId, job)
|
|
34
|
+
else jobs.delete(siteId)
|
|
35
|
+
for (const l of jobListeners) l()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Every site's job, live — for list views deciding row layout (renderRow isn't a
|
|
39
|
+
// component, so it can't call the per-site hook).
|
|
40
|
+
export function useWpCoreJobs(): ReadonlyMap<number, CoreJob> {
|
|
41
|
+
return useSyncExternalStore(subscribeJobs, () => jobs)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// The job for one site, live.
|
|
45
|
+
export function useWpCoreJob(siteId: number): CoreJob | undefined {
|
|
46
|
+
return useSyncExternalStore(subscribeJobs, () => jobs.get(siteId))
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type NoteVersion = (site: Site, version: string) => void
|
|
50
|
+
|
|
51
|
+
// Run one site's update as a tracked job. Rows show the cached probe's WP
|
|
52
|
+
// version, so a success hands them the version we just verified.
|
|
53
|
+
async function runCoreJob(server: Server, site: Site, sshUser: string | null, version: string, multisite: boolean, noteVersion: NoteVersion) {
|
|
54
|
+
setCoreJob(site.id, { version, result: null })
|
|
55
|
+
const result = await updateWpCore(server, site, sshUser, version, multisite)
|
|
56
|
+
setCoreJob(site.id, { version, result })
|
|
57
|
+
if (result.ok) noteVersion(site, result.after)
|
|
58
|
+
return result
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The single-site flow: fire and forget, with a toast per outcome.
|
|
62
|
+
export function startCoreUpdate(server: Server, site: Site, sshUser: string | null, version: string, multisite: boolean, noteVersion: NoteVersion) {
|
|
63
|
+
void runCoreJob(server, site, sshUser, version, multisite, noteVersion).then((result) => {
|
|
64
|
+
if (result.ok) toast.success(`${site.domain} updated to WordPress ${result.after}`)
|
|
65
|
+
else toast.error(`WordPress update failed on ${site.domain} — press u to see why`)
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Row marker while a site's core update runs (spinner + target) or after it
|
|
70
|
+
// failed (stays until the result is viewed in `u` → WordPress, like PHP's ⬆!).
|
|
71
|
+
// Renders nothing otherwise. `compact` is for rows with no WP-version column.
|
|
72
|
+
export function WpCoreRowMark({ siteId, selected, compact = false }: { siteId: number; selected: boolean; compact?: boolean }) {
|
|
73
|
+
const job = useWpCoreJob(siteId)
|
|
74
|
+
if (!job || job.result?.ok) return null
|
|
75
|
+
if (job.result === null) {
|
|
76
|
+
return (
|
|
77
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
78
|
+
<Spinner color={selected ? theme.text : theme.brand} interval={120} />
|
|
79
|
+
<text content={compact ? "WP " : ` → WP ${job.version}`} fg={selected ? theme.text : theme.warn} wrapMode="none" />
|
|
80
|
+
</box>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
return <text content={compact ? "WP! " : " ⬆!"} fg={selected ? theme.text : theme.bad} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- bulk runs ----------------------------------------------------------------
|
|
87
|
+
//
|
|
88
|
+
// Canary first, always: the first site in the plan updates alone, and if it fails
|
|
89
|
+
// nothing else is touched. Then the rest run MAX_PARALLEL at a time, never two on
|
|
90
|
+
// the same server (a core update is download + unzip + DB upgrade — cheap alone,
|
|
91
|
+
// not when stacked on one box). After MAX_FAILURES failures no new sites start;
|
|
92
|
+
// the ones already running finish. Only one bulk run exists at a time.
|
|
93
|
+
|
|
94
|
+
export const BULK_MAX_PARALLEL = 4
|
|
95
|
+
export const BULK_MAX_FAILURES = 3
|
|
96
|
+
|
|
97
|
+
export interface BulkItem {
|
|
98
|
+
site: Site
|
|
99
|
+
server: Server
|
|
100
|
+
from: string
|
|
101
|
+
to: string
|
|
102
|
+
multisite: boolean
|
|
103
|
+
composer: boolean // core is Composer-managed (Bedrock/Radicle) — the lock will still pin `from`
|
|
104
|
+
state: "queued" | "running" | "done" | "failed" | "notRun"
|
|
105
|
+
error?: string
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface BulkRun {
|
|
109
|
+
title: string // e.g. "Standard WP · WP 7.0.2"
|
|
110
|
+
items: BulkItem[] // items[0] is the canary
|
|
111
|
+
phase: "canary" | "running" | "stopping" | "done"
|
|
112
|
+
stopReason?: string
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let bulk: BulkRun | null = null
|
|
116
|
+
const bulkListeners = new Set<() => void>()
|
|
117
|
+
const subscribeBulk = (l: () => void) => {
|
|
118
|
+
bulkListeners.add(l)
|
|
119
|
+
return () => bulkListeners.delete(l)
|
|
120
|
+
}
|
|
121
|
+
const emitBulk = () => {
|
|
122
|
+
for (const l of bulkListeners) l()
|
|
123
|
+
}
|
|
124
|
+
let stopRequested = false
|
|
125
|
+
|
|
126
|
+
export function useBulkRun(): BulkRun | null {
|
|
127
|
+
return useSyncExternalStore(subscribeBulk, () => bulk)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function patchBulk(update: Partial<BulkRun>) {
|
|
131
|
+
if (!bulk) return
|
|
132
|
+
bulk = { ...bulk, ...update }
|
|
133
|
+
emitBulk()
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function patchItem(index: number, update: Partial<BulkItem>) {
|
|
137
|
+
if (!bulk) return
|
|
138
|
+
const items = bulk.items.slice()
|
|
139
|
+
items[index] = { ...items[index], ...update }
|
|
140
|
+
bulk = { ...bulk, items }
|
|
141
|
+
emitBulk()
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Ask a running bulk to start no more sites; the ones in flight finish.
|
|
145
|
+
export function stopBulk() {
|
|
146
|
+
if (!bulk || bulk.phase === "done") return
|
|
147
|
+
stopRequested = true
|
|
148
|
+
patchBulk({ phase: "stopping", stopReason: "Stopped by you — sites already updating were allowed to finish." })
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The current run, outside React — for key handlers deciding what `u` opens.
|
|
152
|
+
export function peekBulk(): BulkRun | null {
|
|
153
|
+
return bulk
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Forget a finished run (the summary has been seen).
|
|
157
|
+
export function clearBulk() {
|
|
158
|
+
if (bulk?.phase !== "done") return
|
|
159
|
+
bulk = null
|
|
160
|
+
emitBulk()
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function startBulkCoreUpdate(
|
|
164
|
+
title: string,
|
|
165
|
+
plan: Omit<BulkItem, "state" | "error">[],
|
|
166
|
+
sshUser: string | null,
|
|
167
|
+
noteVersion: NoteVersion,
|
|
168
|
+
) {
|
|
169
|
+
if (plan.length === 0 || (bulk && bulk.phase !== "done")) return
|
|
170
|
+
stopRequested = false
|
|
171
|
+
bulk = { title, items: plan.map((p) => ({ ...p, state: "queued" })), phase: "canary" }
|
|
172
|
+
emitBulk()
|
|
173
|
+
|
|
174
|
+
const runOne = async (index: number): Promise<boolean> => {
|
|
175
|
+
const it = bulk!.items[index]
|
|
176
|
+
patchItem(index, { state: "running" })
|
|
177
|
+
const result = await runCoreJob(it.server, it.site, sshUser, it.to, it.multisite, noteVersion)
|
|
178
|
+
if (result.ok) {
|
|
179
|
+
// Success needs no lingering per-site job (the row already shows the new
|
|
180
|
+
// version); a failure keeps its job so the row's ⬆! points at the reason.
|
|
181
|
+
setCoreJob(it.site.id, null)
|
|
182
|
+
patchItem(index, { state: "done" })
|
|
183
|
+
} else {
|
|
184
|
+
patchItem(index, { state: "failed", error: result.error })
|
|
185
|
+
}
|
|
186
|
+
return result.ok
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const finish = (stopReason?: string) => {
|
|
190
|
+
if (!bulk) return
|
|
191
|
+
const items = bulk.items.map((it) => (it.state === "queued" ? { ...it, state: "notRun" as const } : it))
|
|
192
|
+
bulk = { ...bulk, items, phase: "done", stopReason: stopReason ?? bulk.stopReason }
|
|
193
|
+
emitBulk()
|
|
194
|
+
const done = items.filter((i) => i.state === "done").length
|
|
195
|
+
const failed = items.filter((i) => i.state === "failed").length
|
|
196
|
+
const notRun = items.filter((i) => i.state === "notRun").length
|
|
197
|
+
const tail = [failed ? `${failed} failed` : "", notRun ? `${notRun} not run` : ""].filter(Boolean).join(", ")
|
|
198
|
+
const msg = `${title}: ${done} of ${items.length} updated${tail ? ` · ${tail}` : ""}`
|
|
199
|
+
if (failed || notRun) toast.error(`${msg} — press u on the group for details`)
|
|
200
|
+
else toast.success(msg)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
void (async () => {
|
|
204
|
+
if (!(await runOne(0))) return finish("The canary failed, so no other site was touched.")
|
|
205
|
+
if (stopRequested) return finish()
|
|
206
|
+
patchBulk({ phase: "running" })
|
|
207
|
+
|
|
208
|
+
await new Promise<void>((resolve) => {
|
|
209
|
+
const busyServers = new Set<number>()
|
|
210
|
+
let active = 0
|
|
211
|
+
let failures = 0
|
|
212
|
+
const pump = () => {
|
|
213
|
+
if (!stopRequested && failures >= BULK_MAX_FAILURES && bulk?.phase !== "stopping") {
|
|
214
|
+
patchBulk({ phase: "stopping", stopReason: `Stopped after ${BULK_MAX_FAILURES} failures — sites already updating were allowed to finish.` })
|
|
215
|
+
}
|
|
216
|
+
const halted = stopRequested || failures >= BULK_MAX_FAILURES
|
|
217
|
+
for (let i = 1; !halted && active < BULK_MAX_PARALLEL && i < bulk!.items.length; i++) {
|
|
218
|
+
const it = bulk!.items[i]
|
|
219
|
+
if (it.state !== "queued" || busyServers.has(it.server.id)) continue
|
|
220
|
+
busyServers.add(it.server.id)
|
|
221
|
+
active++
|
|
222
|
+
void runOne(i).then((ok) => {
|
|
223
|
+
if (!ok) failures++
|
|
224
|
+
busyServers.delete(it.server.id)
|
|
225
|
+
active--
|
|
226
|
+
pump()
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
if (active === 0) resolve()
|
|
230
|
+
}
|
|
231
|
+
pump()
|
|
232
|
+
})
|
|
233
|
+
finish()
|
|
234
|
+
})()
|
|
235
|
+
}
|