spinuptui 1.2.0-alpha.1 → 1.2.1
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 +10 -10
- package/README.md +5 -10
- package/package.json +1 -1
- package/src/api/client.ts +15 -0
- package/src/api/types.ts +3 -0
- package/src/config.ts +0 -23
- package/src/dev/mockClient.ts +30 -7
- package/src/index.tsx +17 -16
- package/src/lib/appUpdate.ts +7 -33
- package/src/lib/cliOffline.ts +418 -0
- package/src/lib/offline.test.ts +181 -0
- package/src/lib/offline.ts +422 -0
- package/src/lib/spinupweb.test.ts +22 -0
- package/src/lib/spinupweb.ts +11 -0
- package/src/lib/stackCache.test.ts +43 -0
- package/src/lib/stackCache.ts +35 -1
- package/src/ui/App.tsx +12 -10
- package/src/ui/Details.tsx +8 -2
- package/src/ui/Explain.tsx +2 -15
- package/src/ui/Header.tsx +0 -2
- package/src/ui/Help.tsx +3 -2
- package/src/ui/Onboarding.tsx +71 -3
- package/src/ui/components.tsx +2 -1
- package/src/ui/offlineRun.tsx +82 -0
- package/src/ui/store.tsx +76 -10
- package/src/ui/views/AccountSlugPrompt.tsx +102 -0
- package/src/ui/views/Browser.tsx +13 -5
- package/src/ui/views/Dashboard.tsx +3 -1
- package/src/ui/views/Search.tsx +8 -6
- package/src/ui/views/ServerActions.tsx +108 -11
- package/src/ui/views/SiteOffline.tsx +171 -0
- package/src/ui/views/Stacks.tsx +26 -9
- package/src/lib/appUpdate.test.ts +0 -26
- package/src/lib/beacon.test.ts +0 -140
- package/src/lib/beacon.ts +0 -349
- package/src/lib/beaconSetup.test.ts +0 -65
- package/src/lib/beaconSetup.ts +0 -143
- package/src/lib/cliBeacon.ts +0 -290
- package/src/lib/prompt.ts +0 -47
- package/src/modules/beacon.ts +0 -51
- package/src/modules/contract.ts +0 -61
- package/src/modules/index.ts +0 -34
- package/src/ui/views/BeaconSetup.tsx +0 -268
- package/src/ui/views/Modules.tsx +0 -196
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// Take a server's sites offline, and bring back exactly those sites later.
|
|
2
|
+
//
|
|
3
|
+
// For emergencies like an unpatched PHP remote-code-execution bug: SpinupWP's
|
|
4
|
+
// "disable site" removes the site's nginx server block, so port 80 returns an
|
|
5
|
+
// empty reply, 443 fails the TLS handshake, and no request reaches PHP (checked
|
|
6
|
+
// on a test box with direct-IP and --resolve requests too). Files, database,
|
|
7
|
+
// certificate, the PHP-FPM pool and the site's cron are all left in place.
|
|
8
|
+
//
|
|
9
|
+
// PHP is shared by every site on a server, so the usual unit is the server:
|
|
10
|
+
// one still-enabled site keeps the vulnerable binary reachable. Single sites
|
|
11
|
+
// can go offline too (one exposed site, or one client's site for reasons of its
|
|
12
|
+
// own); they're recorded under their server the same way. What this module
|
|
13
|
+
// disabled is recorded per server in `offline.json` (per account), and bringing
|
|
14
|
+
// a server back only re-enables those sites. A site someone had disabled for
|
|
15
|
+
// another reason stays disabled.
|
|
16
|
+
//
|
|
17
|
+
// Kuma: every monitor registered for the affected sites goes behind one manual
|
|
18
|
+
// maintenance window per server, so taking 30 sites down doesn't page 30 times.
|
|
19
|
+
// Best-effort both ways; Kuma being unreachable never blocks the API work.
|
|
20
|
+
|
|
21
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
22
|
+
import { mkdir } from "node:fs/promises"
|
|
23
|
+
import { join } from "node:path"
|
|
24
|
+
import type { AppConfig, KumaMonitorRef } from "../config.ts"
|
|
25
|
+
import { profileDataDir } from "../config.ts"
|
|
26
|
+
import type { Event, Server, Site } from "../api/types.ts"
|
|
27
|
+
import type { SpinupWPClientLike } from "../api/client.ts"
|
|
28
|
+
import { ApiError } from "../api/client.ts"
|
|
29
|
+
import { withKuma } from "./uptimeKuma.ts"
|
|
30
|
+
|
|
31
|
+
// ---- State ----------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
export interface OfflineSite {
|
|
34
|
+
id: number
|
|
35
|
+
domain: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OfflineRecord {
|
|
39
|
+
serverId: number
|
|
40
|
+
serverName: string
|
|
41
|
+
takenAt: string
|
|
42
|
+
// Only the sites this module disabled; the only ones "online" re-enables.
|
|
43
|
+
sites: OfflineSite[]
|
|
44
|
+
kumaMaintenanceId?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface OfflineState {
|
|
48
|
+
servers: Record<string, OfflineRecord>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function offlineStatePath(dir = profileDataDir()): string {
|
|
52
|
+
return join(dir, "offline.json")
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function loadOfflineState(dir = profileDataDir()): OfflineState {
|
|
56
|
+
try {
|
|
57
|
+
const path = offlineStatePath(dir)
|
|
58
|
+
if (!existsSync(path)) return { servers: {} }
|
|
59
|
+
const raw = JSON.parse(readFileSync(path, "utf8")) as Partial<OfflineState>
|
|
60
|
+
return { servers: raw.servers ?? {} }
|
|
61
|
+
} catch {
|
|
62
|
+
return { servers: {} }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Read-modify-write, so a CLI run and the app working on different servers
|
|
67
|
+
// don't overwrite each other's records.
|
|
68
|
+
async function updateRecord(dir: string, serverId: number, fn: (r: OfflineRecord | undefined) => OfflineRecord | undefined): Promise<void> {
|
|
69
|
+
const state = loadOfflineState(dir)
|
|
70
|
+
const next = fn(state.servers[String(serverId)])
|
|
71
|
+
if (next) state.servers[String(serverId)] = next
|
|
72
|
+
else delete state.servers[String(serverId)]
|
|
73
|
+
await mkdir(dir, { recursive: true })
|
|
74
|
+
await Bun.write(offlineStatePath(dir), JSON.stringify(state, null, 2) + "\n")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- Event waiting ----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
const EVENT_DONE = new Set(["deployed", "completed", "finished", "success"])
|
|
80
|
+
const EVENT_FAIL = new Set(["failed", "errored", "error"])
|
|
81
|
+
|
|
82
|
+
export type EventOutcome = "done" | "failed" | "timeout"
|
|
83
|
+
|
|
84
|
+
// Wait on many events at once. One `GET /events` page covers the 100 newest,
|
|
85
|
+
// so a whole server (or fleet) costs one request per round instead of one per
|
|
86
|
+
// site; anything that has scrolled off that page is fetched individually. A
|
|
87
|
+
// timeout is reported as a timeout, never a failure: SpinupWP runs a server's
|
|
88
|
+
// events one at a time, so a big server's last site can sit queued a while.
|
|
89
|
+
export async function waitForEvents(
|
|
90
|
+
client: SpinupWPClientLike,
|
|
91
|
+
ids: number[],
|
|
92
|
+
opts: { timeoutMs?: number; pollMs?: number; onSettled?: (id: number, outcome: EventOutcome, event?: Event) => void } = {},
|
|
93
|
+
): Promise<Map<number, EventOutcome>> {
|
|
94
|
+
const timeoutMs = opts.timeoutMs ?? 15 * 60_000
|
|
95
|
+
const pollMs = opts.pollMs ?? 4000
|
|
96
|
+
const out = new Map<number, EventOutcome>()
|
|
97
|
+
const pending = new Set(ids)
|
|
98
|
+
const settle = (id: number, outcome: EventOutcome, ev?: Event) => {
|
|
99
|
+
pending.delete(id)
|
|
100
|
+
out.set(id, outcome)
|
|
101
|
+
opts.onSettled?.(id, outcome, ev)
|
|
102
|
+
}
|
|
103
|
+
const judge = (ev: Event) => {
|
|
104
|
+
if (!pending.has(ev.id)) return
|
|
105
|
+
if (EVENT_FAIL.has(ev.status)) settle(ev.id, "failed", ev)
|
|
106
|
+
else if (EVENT_DONE.has(ev.status) || ev.finished_at) settle(ev.id, "done", ev)
|
|
107
|
+
}
|
|
108
|
+
const deadline = Date.now() + timeoutMs
|
|
109
|
+
let apiErrs = 0
|
|
110
|
+
while (pending.size && Date.now() < deadline) {
|
|
111
|
+
await new Promise((r) => setTimeout(r, pollMs))
|
|
112
|
+
try {
|
|
113
|
+
const recent = await client.listEvents(1)
|
|
114
|
+
apiErrs = 0
|
|
115
|
+
const seen = new Set<number>()
|
|
116
|
+
for (const ev of recent) {
|
|
117
|
+
seen.add(ev.id)
|
|
118
|
+
judge(ev)
|
|
119
|
+
}
|
|
120
|
+
const oldest = recent.length ? Math.min(...recent.map((e) => e.id)) : Infinity
|
|
121
|
+
for (const id of [...pending]) {
|
|
122
|
+
if (seen.has(id) || id >= oldest) continue
|
|
123
|
+
judge(await client.getEvent(id))
|
|
124
|
+
}
|
|
125
|
+
} catch {
|
|
126
|
+
// A failed read isn't a failed event; give up only on a run of them.
|
|
127
|
+
if (++apiErrs >= 5) break
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
for (const id of pending) settle(id, "timeout")
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- Kuma -----------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
// Every monitor registered for a site: any numeric `*Id` on its ref, by the
|
|
137
|
+
// same convention the alerts toggle uses, so new monitor kinds are covered.
|
|
138
|
+
function monitorIdsFor(ref: KumaMonitorRef | undefined): number[] {
|
|
139
|
+
if (!ref) return []
|
|
140
|
+
return Object.entries(ref)
|
|
141
|
+
.filter((e): e is [string, number] => e[0].endsWith("Id") && typeof e[1] === "number")
|
|
142
|
+
.map(([, id]) => id)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export type KumaOutcome = "shielded" | "no_monitors" | "not_configured" | "failed" | "skipped"
|
|
146
|
+
|
|
147
|
+
async function shieldMonitors(cfg: AppConfig, server: Server, domains: string[], existingId?: number): Promise<{ outcome: KumaOutcome; id?: number; error?: string }> {
|
|
148
|
+
const conn = cfg.uptimeKuma
|
|
149
|
+
if (!conn) return { outcome: "not_configured" }
|
|
150
|
+
const ids = [...new Set(domains.flatMap((d) => monitorIdsFor(cfg.kumaMonitors[d])))]
|
|
151
|
+
if (!ids.length) return { outcome: "no_monitors" }
|
|
152
|
+
try {
|
|
153
|
+
const { result } = await withKuma(conn, async (kuma) => {
|
|
154
|
+
// A re-run after a partial one widens the window it already opened.
|
|
155
|
+
if (existingId != null) {
|
|
156
|
+
try {
|
|
157
|
+
await kuma.setMaintenanceMonitors(existingId, ids)
|
|
158
|
+
return existingId
|
|
159
|
+
} catch {
|
|
160
|
+
// Deleted in Kuma meanwhile; open a fresh one.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const id = await kuma.addManualMaintenance(`Offline: ${server.name}`, "Sites taken offline from SpinupTUI. Removed automatically when they come back online.")
|
|
164
|
+
try {
|
|
165
|
+
await kuma.setMaintenanceMonitors(id, ids)
|
|
166
|
+
} catch (err) {
|
|
167
|
+
await kuma.deleteMaintenance(id).catch(() => {})
|
|
168
|
+
throw err
|
|
169
|
+
}
|
|
170
|
+
return id
|
|
171
|
+
})
|
|
172
|
+
return { outcome: "shielded", id: result }
|
|
173
|
+
} catch (err) {
|
|
174
|
+
return { outcome: "failed", error: (err as Error).message }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- Server lookup --------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
export type ServerMatch = { ok: true; server: Server } | { ok: false; query: string; message: string; suggestions: string[] }
|
|
181
|
+
|
|
182
|
+
// Exact matches only (name, IP or id). A write that takes sites down must
|
|
183
|
+
// never act on a near-miss; a miss lists names that contain the query.
|
|
184
|
+
export function matchServer(servers: Server[], query: string): ServerMatch {
|
|
185
|
+
const q = query.trim().toLowerCase()
|
|
186
|
+
const hit = servers.find((s) => s.name.toLowerCase() === q || s.ip_address === q || String(s.id) === q)
|
|
187
|
+
if (hit) return { ok: true, server: hit }
|
|
188
|
+
const suggestions = servers.filter((s) => s.name.toLowerCase().includes(q)).map((s) => s.name).slice(0, 8)
|
|
189
|
+
return { ok: false, query, message: `No server named "${query}".`, suggestions }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- Take offline ---------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
export interface OfflineServerResult {
|
|
195
|
+
serverId: number
|
|
196
|
+
serverName: string
|
|
197
|
+
// Disabled by this run.
|
|
198
|
+
disabled: string[]
|
|
199
|
+
// Already disabled before this run; left alone and not recorded.
|
|
200
|
+
alreadyOffline: string[]
|
|
201
|
+
failed: { domain: string; error: string }[]
|
|
202
|
+
kuma: KumaOutcome
|
|
203
|
+
kumaError?: string
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface OfflineOptions {
|
|
207
|
+
dir?: string
|
|
208
|
+
onProgress?: (line: string) => void
|
|
209
|
+
// False in Dev Mode: never talk to a real Kuma.
|
|
210
|
+
kuma?: boolean
|
|
211
|
+
timeoutMs?: number
|
|
212
|
+
pollMs?: number
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const errMsg = (err: unknown) => (err instanceof ApiError ? err.message : (err as Error).message)
|
|
216
|
+
|
|
217
|
+
export async function takeServerOffline(client: SpinupWPClientLike, cfg: AppConfig, server: Server, opts: OfflineOptions = {}): Promise<OfflineServerResult> {
|
|
218
|
+
const sites = await client.listSites(server.id)
|
|
219
|
+
const result = emptyOfflineResult(server)
|
|
220
|
+
result.alreadyOffline = sites.filter((s) => s.disabled_at).map((s) => s.domain)
|
|
221
|
+
const targets = sites.filter((s) => !s.disabled_at)
|
|
222
|
+
if (!targets.length) {
|
|
223
|
+
opts.onProgress?.(`${server.name}: every site is already offline`)
|
|
224
|
+
return result
|
|
225
|
+
}
|
|
226
|
+
return disableSites(client, cfg, server, targets, result, opts)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// One site. The rest of the server stays up, and so does its PHP: this
|
|
230
|
+
// protects that site, not the server.
|
|
231
|
+
export async function takeSiteOffline(client: SpinupWPClientLike, cfg: AppConfig, server: Server, siteId: number, opts: OfflineOptions = {}): Promise<OfflineServerResult> {
|
|
232
|
+
const site = (await client.listSites(server.id)).find((s) => s.id === siteId)
|
|
233
|
+
const result = emptyOfflineResult(server)
|
|
234
|
+
if (!site) throw new Error(`Site ${siteId} is no longer on ${server.name}.`)
|
|
235
|
+
if (site.disabled_at) {
|
|
236
|
+
result.alreadyOffline = [site.domain]
|
|
237
|
+
opts.onProgress?.(`${site.domain}: already offline`)
|
|
238
|
+
return result
|
|
239
|
+
}
|
|
240
|
+
return disableSites(client, cfg, server, [site], result, opts)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function emptyOfflineResult(server: Server): OfflineServerResult {
|
|
244
|
+
return { serverId: server.id, serverName: server.name, disabled: [], alreadyOffline: [], failed: [], kuma: "skipped" }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function disableSites(
|
|
248
|
+
client: SpinupWPClientLike,
|
|
249
|
+
cfg: AppConfig,
|
|
250
|
+
server: Server,
|
|
251
|
+
targets: Site[],
|
|
252
|
+
result: OfflineServerResult,
|
|
253
|
+
opts: OfflineOptions,
|
|
254
|
+
): Promise<OfflineServerResult> {
|
|
255
|
+
const dir = opts.dir ?? profileDataDir(cfg.profileId)
|
|
256
|
+
const say = opts.onProgress ?? (() => {})
|
|
257
|
+
|
|
258
|
+
const existing = loadOfflineState(dir).servers[String(server.id)]
|
|
259
|
+
if (opts.kuma !== false) {
|
|
260
|
+
const domains = [...new Set([...(existing?.sites ?? []).map((s) => s.domain), ...targets.map((s) => s.domain)])]
|
|
261
|
+
const k = await shieldMonitors(cfg, server, domains, existing?.kumaMaintenanceId)
|
|
262
|
+
result.kuma = k.outcome
|
|
263
|
+
if (k.error) result.kumaError = k.error
|
|
264
|
+
if (k.id != null) {
|
|
265
|
+
await updateRecord(dir, server.id, (r) => ({
|
|
266
|
+
...(r ?? { serverId: server.id, serverName: server.name, takenAt: new Date().toISOString(), sites: [] }),
|
|
267
|
+
kumaMaintenanceId: k.id,
|
|
268
|
+
}))
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Record each site the moment its disable is accepted, before it finishes:
|
|
273
|
+
// if this process dies mid-run, "online" still knows to bring it back, and
|
|
274
|
+
// restoring a site that never went down is a harmless no-op.
|
|
275
|
+
const events = new Map<number, Site>()
|
|
276
|
+
for (const site of targets) {
|
|
277
|
+
try {
|
|
278
|
+
const { event_id } = await client.disableSite(site.id)
|
|
279
|
+
events.set(event_id, site)
|
|
280
|
+
await updateRecord(dir, server.id, (r) => {
|
|
281
|
+
const rec = r ?? { serverId: server.id, serverName: server.name, takenAt: new Date().toISOString(), sites: [] }
|
|
282
|
+
if (!rec.sites.some((s) => s.id === site.id)) rec.sites.push({ id: site.id, domain: site.domain })
|
|
283
|
+
return rec
|
|
284
|
+
})
|
|
285
|
+
say(`${site.domain}: going offline…`)
|
|
286
|
+
} catch (err) {
|
|
287
|
+
result.failed.push({ domain: site.domain, error: errMsg(err) })
|
|
288
|
+
say(`${site.domain}: ✕ ${errMsg(err)}`)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await waitForEvents(client, [...events.keys()], {
|
|
293
|
+
timeoutMs: opts.timeoutMs,
|
|
294
|
+
pollMs: opts.pollMs,
|
|
295
|
+
onSettled: (id, outcome, ev) => {
|
|
296
|
+
const site = events.get(id)!
|
|
297
|
+
if (outcome === "done") {
|
|
298
|
+
result.disabled.push(site.domain)
|
|
299
|
+
say(`${site.domain}: ✓ offline`)
|
|
300
|
+
} else if (outcome === "failed") {
|
|
301
|
+
const error = ev?.output?.trim() || "SpinupWP reported the disable as failed."
|
|
302
|
+
result.failed.push({ domain: site.domain, error })
|
|
303
|
+
say(`${site.domain}: ✕ ${error}`)
|
|
304
|
+
} else {
|
|
305
|
+
result.failed.push({ domain: site.domain, error: "Still queued at SpinupWP when we stopped waiting; it may yet complete. Re-run to check." })
|
|
306
|
+
say(`${site.domain}: … still queued at SpinupWP`)
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
})
|
|
310
|
+
return result
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ---- Bring back online ----------------------------------------------------
|
|
314
|
+
|
|
315
|
+
export interface OnlineServerResult {
|
|
316
|
+
serverId: number
|
|
317
|
+
serverName: string
|
|
318
|
+
enabled: string[]
|
|
319
|
+
// Recorded, but already serving again (enabled by hand, say).
|
|
320
|
+
alreadyOnline: string[]
|
|
321
|
+
// Recorded, but deleted from SpinupWP since.
|
|
322
|
+
gone: string[]
|
|
323
|
+
failed: { domain: string; error: string }[]
|
|
324
|
+
// Nothing recorded for this server: this module didn't take it offline.
|
|
325
|
+
nothingRecorded: boolean
|
|
326
|
+
// Asked for by id but not taken offline by this module (siteIds only).
|
|
327
|
+
notRecorded: number[]
|
|
328
|
+
kumaWindowClosed: boolean
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Bring back what this module took offline on a server: all of it, or only
|
|
332
|
+
// `siteIds` from it.
|
|
333
|
+
export async function bringServerOnline(
|
|
334
|
+
client: SpinupWPClientLike,
|
|
335
|
+
cfg: AppConfig,
|
|
336
|
+
serverId: number,
|
|
337
|
+
opts: OfflineOptions & { siteIds?: number[] } = {},
|
|
338
|
+
): Promise<OnlineServerResult> {
|
|
339
|
+
const dir = opts.dir ?? profileDataDir(cfg.profileId)
|
|
340
|
+
const say = opts.onProgress ?? (() => {})
|
|
341
|
+
const record = loadOfflineState(dir).servers[String(serverId)]
|
|
342
|
+
const result: OnlineServerResult = {
|
|
343
|
+
serverId,
|
|
344
|
+
serverName: record?.serverName ?? String(serverId),
|
|
345
|
+
enabled: [],
|
|
346
|
+
alreadyOnline: [],
|
|
347
|
+
gone: [],
|
|
348
|
+
failed: [],
|
|
349
|
+
nothingRecorded: !record,
|
|
350
|
+
notRecorded: opts.siteIds?.filter((id) => !record?.sites.some((s) => s.id === id)) ?? [],
|
|
351
|
+
kumaWindowClosed: false,
|
|
352
|
+
}
|
|
353
|
+
if (!record) return result
|
|
354
|
+
const wanted = opts.siteIds ? record.sites.filter((s) => opts.siteIds!.includes(s.id)) : record.sites
|
|
355
|
+
|
|
356
|
+
const live = new Map((await client.listSites(serverId)).map((s) => [s.id, s]))
|
|
357
|
+
const done = new Set<number>()
|
|
358
|
+
const events = new Map<number, OfflineSite>()
|
|
359
|
+
for (const rec of wanted) {
|
|
360
|
+
const site = live.get(rec.id)
|
|
361
|
+
if (!site) {
|
|
362
|
+
result.gone.push(rec.domain)
|
|
363
|
+
done.add(rec.id)
|
|
364
|
+
continue
|
|
365
|
+
}
|
|
366
|
+
if (!site.disabled_at) {
|
|
367
|
+
result.alreadyOnline.push(rec.domain)
|
|
368
|
+
done.add(rec.id)
|
|
369
|
+
continue
|
|
370
|
+
}
|
|
371
|
+
try {
|
|
372
|
+
const { event_id } = await client.enableSite(rec.id)
|
|
373
|
+
events.set(event_id, rec)
|
|
374
|
+
say(`${rec.domain}: coming back online…`)
|
|
375
|
+
} catch (err) {
|
|
376
|
+
result.failed.push({ domain: rec.domain, error: errMsg(err) })
|
|
377
|
+
say(`${rec.domain}: ✕ ${errMsg(err)}`)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
await waitForEvents(client, [...events.keys()], {
|
|
382
|
+
timeoutMs: opts.timeoutMs,
|
|
383
|
+
pollMs: opts.pollMs,
|
|
384
|
+
onSettled: (id, outcome, ev) => {
|
|
385
|
+
const rec = events.get(id)!
|
|
386
|
+
if (outcome === "done") {
|
|
387
|
+
result.enabled.push(rec.domain)
|
|
388
|
+
done.add(rec.id)
|
|
389
|
+
say(`${rec.domain}: ✓ online`)
|
|
390
|
+
} else {
|
|
391
|
+
const error = outcome === "failed" ? ev?.output?.trim() || "SpinupWP reported the enable as failed." : "Still queued at SpinupWP when we stopped waiting. Re-run to check."
|
|
392
|
+
result.failed.push({ domain: rec.domain, error })
|
|
393
|
+
say(`${rec.domain}: ✕ ${error}`)
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
// Keep whatever didn't come back, so a re-run finishes the job. The Kuma
|
|
399
|
+
// window shrinks to the sites still offline, and closes once there are none
|
|
400
|
+
// (or none of them has monitors).
|
|
401
|
+
let left: OfflineSite[] = []
|
|
402
|
+
await updateRecord(dir, serverId, (r) => {
|
|
403
|
+
if (!r) return undefined
|
|
404
|
+
left = r.sites.filter((s) => !done.has(s.id))
|
|
405
|
+
return left.length ? { ...r, sites: left } : undefined
|
|
406
|
+
})
|
|
407
|
+
if (done.size && record.kumaMaintenanceId != null && cfg.uptimeKuma && opts.kuma !== false) {
|
|
408
|
+
const id = record.kumaMaintenanceId
|
|
409
|
+
const stillShielded = [...new Set(left.flatMap((s) => monitorIdsFor(cfg.kumaMonitors[s.domain])))]
|
|
410
|
+
if (stillShielded.length) {
|
|
411
|
+
await withKuma(cfg.uptimeKuma, (kuma) => kuma.setMaintenanceMonitors(id, stillShielded)).catch(() => {
|
|
412
|
+
// The restored sites stay quiet in Kuma until the rest come back.
|
|
413
|
+
})
|
|
414
|
+
} else {
|
|
415
|
+
result.kumaWindowClosed = await withKuma(cfg.uptimeKuma, (kuma) => kuma.deleteMaintenance(id))
|
|
416
|
+
.then(() => true)
|
|
417
|
+
.catch(() => false)
|
|
418
|
+
if (left.length) await updateRecord(dir, serverId, (r) => (r ? { ...r, kumaMaintenanceId: undefined } : r))
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return result
|
|
422
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { parseAccountSlug, siteWebUrl } from "./spinupweb.ts"
|
|
3
|
+
|
|
4
|
+
describe("parseAccountSlug", () => {
|
|
5
|
+
test("a bare slug", () => {
|
|
6
|
+
expect(parseAccountSlug(" acme-agency ")).toBe("acme-agency")
|
|
7
|
+
})
|
|
8
|
+
test("a whole dashboard URL", () => {
|
|
9
|
+
expect(parseAccountSlug("https://spinupwp.app/acme-agency/sites/123#git")).toBe("acme-agency")
|
|
10
|
+
expect(parseAccountSlug("spinupwp.app/acme-agency")).toBe("acme-agency")
|
|
11
|
+
expect(parseAccountSlug("https://SpinupWP.app/acme-agency/?tab=servers")).toBe("acme-agency")
|
|
12
|
+
})
|
|
13
|
+
test("anything that isn't a slug", () => {
|
|
14
|
+
expect(parseAccountSlug("")).toBeNull()
|
|
15
|
+
expect(parseAccountSlug("https://spinupwp.app/")).toBeNull()
|
|
16
|
+
expect(parseAccountSlug("acme agency")).toBeNull()
|
|
17
|
+
expect(parseAccountSlug("../etc")).toBeNull()
|
|
18
|
+
})
|
|
19
|
+
test("feeds the deep link", () => {
|
|
20
|
+
expect(siteWebUrl(42, parseAccountSlug("https://spinupwp.app/acme-agency/sites"))).toBe("https://spinupwp.app/acme-agency/sites/42")
|
|
21
|
+
})
|
|
22
|
+
})
|
package/src/lib/spinupweb.ts
CHANGED
|
@@ -12,6 +12,17 @@
|
|
|
12
12
|
|
|
13
13
|
const WEB_BASE = "https://spinupwp.app"
|
|
14
14
|
|
|
15
|
+
// Read an account slug from what a user types: the slug itself, or any dashboard
|
|
16
|
+
// URL pasted whole ("https://spinupwp.app/acme-agency/sites/123" → "acme-agency").
|
|
17
|
+
// Null when it doesn't look like a slug.
|
|
18
|
+
export function parseAccountSlug(input: string): string | null {
|
|
19
|
+
let s = input.trim()
|
|
20
|
+
const at = s.toLowerCase().indexOf("spinupwp.app/")
|
|
21
|
+
if (at >= 0) s = s.slice(at + "spinupwp.app/".length)
|
|
22
|
+
s = s.replace(/^\/+/, "").split(/[/?#]/)[0] ?? ""
|
|
23
|
+
return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(s) ? s : null
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
export function serverWebUrl(id: number, accountSlug: string | null): string {
|
|
16
27
|
return accountSlug ? `${WEB_BASE}/${accountSlug}/servers/${id}` : WEB_BASE
|
|
17
28
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { isRecheckDue, isVersionOld, VERSION_MAX_AGE_MS, type CachedProbe } from "./stackCache.ts"
|
|
3
|
+
|
|
4
|
+
const NOW = 1_800_000_000_000
|
|
5
|
+
|
|
6
|
+
function entry(version: string | null, ageMs: number): CachedProbe {
|
|
7
|
+
return {
|
|
8
|
+
result: { kind: version ? "wordpress" : "whmcs", app: "x", version, label: "x" },
|
|
9
|
+
detectedAt: NOW - ageMs,
|
|
10
|
+
signature: "true:",
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("isVersionOld", () => {
|
|
15
|
+
test("a fresh version reading isn't old", () => {
|
|
16
|
+
expect(isVersionOld(entry("7.1.3", 60_000), NOW)).toBe(false)
|
|
17
|
+
})
|
|
18
|
+
test("a version reading past the max age is old", () => {
|
|
19
|
+
expect(isVersionOld(entry("7.0", VERSION_MAX_AGE_MS + 1), NOW)).toBe(true)
|
|
20
|
+
})
|
|
21
|
+
test("an identity without a version never goes old", () => {
|
|
22
|
+
expect(isVersionOld(entry(null, 365 * VERSION_MAX_AGE_MS), NOW)).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
test("no entry isn't old", () => {
|
|
25
|
+
expect(isVersionOld(undefined, NOW)).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("isRecheckDue", () => {
|
|
30
|
+
const old = entry("7.0", 3 * VERSION_MAX_AGE_MS)
|
|
31
|
+
test("an old version with no attempt yet is due", () => {
|
|
32
|
+
expect(isRecheckDue(old, NOW)).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
test("an attempt inside the window (even a failed one) holds it off", () => {
|
|
35
|
+
expect(isRecheckDue({ ...old, recheckedAt: NOW - 60_000 }, NOW)).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
test("an attempt from a previous window doesn't", () => {
|
|
38
|
+
expect(isRecheckDue({ ...old, recheckedAt: NOW - VERSION_MAX_AGE_MS - 1 }, NOW)).toBe(true)
|
|
39
|
+
})
|
|
40
|
+
test("a fresh reading is never due", () => {
|
|
41
|
+
expect(isRecheckDue(entry("7.1.3", 60_000), NOW)).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
})
|
package/src/lib/stackCache.ts
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// - set() is called after a successful `d` probe; it upserts the entry and
|
|
6
6
|
// writes the whole file back immediately (write-through), so a probe
|
|
7
7
|
// survives even if the TUI is killed with q / Ctrl+C.
|
|
8
|
-
// Probing
|
|
8
|
+
// Probing is on-demand and per-site, with one exception: a WP version older than
|
|
9
|
+
// VERSION_MAX_AGE_MS is re-probed in the background. This cache exists to avoid
|
|
9
10
|
// repeating the slow SSH round-trips across launches, not to save RAM. It's a
|
|
10
11
|
// deliberately small, reusable pattern for other expensive derived data later.
|
|
11
12
|
|
|
@@ -21,6 +22,10 @@ export interface CachedProbe {
|
|
|
21
22
|
result: ProbeResult
|
|
22
23
|
detectedAt: number // epoch ms
|
|
23
24
|
signature: string // invalidation key (see siteSignature)
|
|
25
|
+
// Last automatic re-check of an old version (epoch ms), kept even when it
|
|
26
|
+
// failed, so the once-per-window limit holds across launches. A successful
|
|
27
|
+
// probe replaces the whole entry, which clears it.
|
|
28
|
+
recheckedAt?: number
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
interface CacheFile {
|
|
@@ -32,6 +37,25 @@ export function stackCachePath(dir = profileDataDir()): string {
|
|
|
32
37
|
return join(dir, "stack-cache.json")
|
|
33
38
|
}
|
|
34
39
|
|
|
40
|
+
// A WordPress version is a fact about the server that changes without us: auto-
|
|
41
|
+
// updates, wp-admin and deploys all move it, and none of them touch the site's
|
|
42
|
+
// shape below. So a reading that carries a version is re-checked once it's older
|
|
43
|
+
// than this (in the background, when the site list loads), and its age is shown.
|
|
44
|
+
export const VERSION_MAX_AGE_MS = 24 * 60 * 60 * 1000
|
|
45
|
+
|
|
46
|
+
// True when the entry carries a WP version read longer ago than the max age.
|
|
47
|
+
// Identities without a version (WHMCS, Laravel, static) don't drift this way.
|
|
48
|
+
export function isVersionOld(entry: CachedProbe | undefined, now = Date.now()): boolean {
|
|
49
|
+
return entry?.result.version != null && now - entry.detectedAt > VERSION_MAX_AGE_MS
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// True when an old version is due an automatic re-check: none was attempted in
|
|
53
|
+
// the current window. Unattended SSH that fails must not repeat on every launch.
|
|
54
|
+
export function isRecheckDue(entry: CachedProbe | undefined, now = Date.now()): boolean {
|
|
55
|
+
if (!entry || !isVersionOld(entry, now)) return false
|
|
56
|
+
return entry.recheckedAt == null || now - entry.recheckedAt > VERSION_MAX_AGE_MS
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
// A cached probe is considered stale when the site's relevant shape changes.
|
|
36
60
|
// Keying on is_wordpress + public_folder catches the cases that would alter the
|
|
37
61
|
// probe's target paths or expected result.
|
|
@@ -83,6 +107,16 @@ export class StackCache {
|
|
|
83
107
|
await this.persist()
|
|
84
108
|
}
|
|
85
109
|
|
|
110
|
+
// Record an automatic re-check attempt on existing entries, in one write.
|
|
111
|
+
async noteRecheck(siteIds: number[]): Promise<void> {
|
|
112
|
+
const now = Date.now()
|
|
113
|
+
for (const id of siteIds) {
|
|
114
|
+
const e = this.entries.get(id)
|
|
115
|
+
if (e) this.entries.set(id, { ...e, recheckedAt: now })
|
|
116
|
+
}
|
|
117
|
+
await this.persist()
|
|
118
|
+
}
|
|
119
|
+
|
|
86
120
|
// A copy of all entries (for rendering aggregates without exposing the map).
|
|
87
121
|
snapshot(): Map<number, CachedProbe> {
|
|
88
122
|
return new Map(this.entries)
|
package/src/ui/App.tsx
CHANGED
|
@@ -19,6 +19,8 @@ import { SiteUpdate } from "./views/SiteUpdate.tsx"
|
|
|
19
19
|
import { BulkWpUpdate } from "./views/BulkWpUpdate.tsx"
|
|
20
20
|
import { HttpsToggle } from "./views/HttpsToggle.tsx"
|
|
21
21
|
import { PurgeCache } from "./views/PurgeCache.tsx"
|
|
22
|
+
import { AccountSlugPrompt } from "./views/AccountSlugPrompt.tsx"
|
|
23
|
+
import { SiteOffline } from "./views/SiteOffline.tsx"
|
|
22
24
|
import { ReleaseNotes } from "./views/ReleaseNotes.tsx"
|
|
23
25
|
import { GrantKey } from "./views/GrantKey.tsx"
|
|
24
26
|
import { SudoConnect } from "./views/SudoConnect.tsx"
|
|
@@ -40,8 +42,6 @@ import { ProviderConnect } from "./views/ProviderConnect.tsx"
|
|
|
40
42
|
import { DnsRecords } from "./views/DnsRecords.tsx"
|
|
41
43
|
import { KumaSite } from "./views/KumaSite.tsx"
|
|
42
44
|
import { Accounts } from "./views/Accounts.tsx"
|
|
43
|
-
import { Modules } from "./views/Modules.tsx"
|
|
44
|
-
import { BeaconSetup } from "./views/BeaconSetup.tsx"
|
|
45
45
|
|
|
46
46
|
const MIN_SPLASH_MS = 1200
|
|
47
47
|
|
|
@@ -72,6 +72,7 @@ export function App() {
|
|
|
72
72
|
store.bulkWpGroup !== null ||
|
|
73
73
|
store.httpsToggleSite !== null ||
|
|
74
74
|
store.purgeCacheSite !== null ||
|
|
75
|
+
store.siteOfflineSite !== null ||
|
|
75
76
|
store.grantKeySite !== null ||
|
|
76
77
|
store.sudoConnectServer !== null ||
|
|
77
78
|
store.dbBackupSite !== null ||
|
|
@@ -90,7 +91,7 @@ export function App() {
|
|
|
90
91
|
store.connectZoneTarget !== null ||
|
|
91
92
|
store.dnsRecordsTarget !== null ||
|
|
92
93
|
store.kumaSite !== null ||
|
|
93
|
-
store.
|
|
94
|
+
store.slugPrompt !== null
|
|
94
95
|
useEffect(() => {
|
|
95
96
|
store.setOverlayOpen(overlayActive)
|
|
96
97
|
}, [overlayActive, store])
|
|
@@ -128,6 +129,9 @@ export function App() {
|
|
|
128
129
|
// The Accounts overlay owns the keyboard while open.
|
|
129
130
|
if (showAccounts) return
|
|
130
131
|
|
|
132
|
+
// The account-slug prompt (from `w`) owns the keyboard while open.
|
|
133
|
+
if (store.slugPrompt) return
|
|
134
|
+
|
|
131
135
|
// The release-notes overlay owns the keyboard while open (any key dismisses).
|
|
132
136
|
if (store.releaseNotesInfo) return
|
|
133
137
|
|
|
@@ -149,6 +153,9 @@ export function App() {
|
|
|
149
153
|
// The purge-cache overlay owns the keyboard while open.
|
|
150
154
|
if (store.purgeCacheSite) return
|
|
151
155
|
|
|
156
|
+
// The take-site-offline overlay owns the keyboard while open.
|
|
157
|
+
if (store.siteOfflineSite) return
|
|
158
|
+
|
|
152
159
|
// The grant-SSH-key overlay owns the keyboard while open.
|
|
153
160
|
if (store.grantKeySite) return
|
|
154
161
|
|
|
@@ -203,9 +210,6 @@ export function App() {
|
|
|
203
210
|
// The site-monitoring (Uptime Kuma) overlay owns the keyboard while open.
|
|
204
211
|
if (store.kumaSite) return
|
|
205
212
|
|
|
206
|
-
// Beacon's guided setup (Modules tab) owns the keyboard while open.
|
|
207
|
-
if (store.beaconSetupOpen) return
|
|
208
|
-
|
|
209
213
|
// A (Shift+a): the Accounts overlay — the header advertises it from every tab.
|
|
210
214
|
if (key.shift && key.name === "a") return setShowAccounts(true)
|
|
211
215
|
|
|
@@ -222,8 +226,6 @@ export function App() {
|
|
|
222
226
|
return store.setRoute("search")
|
|
223
227
|
case "5":
|
|
224
228
|
return store.setRoute("events")
|
|
225
|
-
case "6":
|
|
226
|
-
return store.setRoute("modules")
|
|
227
229
|
case "r":
|
|
228
230
|
return void store.refresh()
|
|
229
231
|
case "?":
|
|
@@ -263,7 +265,6 @@ export function App() {
|
|
|
263
265
|
{store.route === "stacks" && <Stacks rows={contentRows} />}
|
|
264
266
|
{store.route === "search" && <Search rows={contentRows} />}
|
|
265
267
|
{store.route === "events" && <Events rows={contentRows} />}
|
|
266
|
-
{store.route === "modules" && <Modules rows={contentRows} />}
|
|
267
268
|
</box>
|
|
268
269
|
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
|
|
269
270
|
{showExplain && <ExplainOverlay route={store.route} />}
|
|
@@ -274,6 +275,7 @@ export function App() {
|
|
|
274
275
|
{store.bulkWpGroup && <BulkWpUpdate />}
|
|
275
276
|
{store.httpsToggleSite && <HttpsToggle />}
|
|
276
277
|
{store.purgeCacheSite && <PurgeCache />}
|
|
278
|
+
{store.siteOfflineSite && <SiteOffline />}
|
|
277
279
|
{store.grantKeySite && <GrantKey />}
|
|
278
280
|
{store.sudoConnectServer && <SudoConnect />}
|
|
279
281
|
{store.dbBackupSite && <DbBackup />}
|
|
@@ -292,8 +294,8 @@ export function App() {
|
|
|
292
294
|
{store.connectZoneTarget && <ProviderConnect />}
|
|
293
295
|
{store.dnsRecordsTarget && <DnsRecords />}
|
|
294
296
|
{store.kumaSite && <KumaSite />}
|
|
295
|
-
{store.beaconSetupOpen && <BeaconSetup />}
|
|
296
297
|
{showAccounts && <Accounts onClose={() => setShowAccounts(false)} />}
|
|
298
|
+
{store.slugPrompt && <AccountSlugPrompt />}
|
|
297
299
|
<ToastStack />
|
|
298
300
|
</box>
|
|
299
301
|
)
|