spinuptui 1.1.2 → 1.2.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.
@@ -0,0 +1,181 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mkdtempSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import type { AppConfig } from "../config.ts"
6
+ import type { Event, Server, Site } from "../api/types.ts"
7
+ import type { SpinupWPClientLike } from "../api/client.ts"
8
+ import { bringServerOnline, loadOfflineState, matchServer, takeServerOffline, takeSiteOffline, waitForEvents } from "./offline.ts"
9
+
10
+ const server = { id: 1, name: "web1.example.com", ip_address: "203.0.113.10" } as Server
11
+ const cfg = { profileId: "default", uptimeKuma: null, kumaMonitors: {} } as unknown as AppConfig
12
+
13
+ function site(id: number, domain: string, disabled = false): Site {
14
+ return { id, server_id: 1, domain, disabled_at: disabled ? "2026-10-01T00:00:00Z" : null } as Site
15
+ }
16
+
17
+ // A fake SpinupWP: disable/enable flip disabled_at and queue an event that is
18
+ // "deployed" on the first read, except for sites listed in `failOn`.
19
+ function fakeClient(sites: Site[], failOn: number[] = []) {
20
+ const events: Event[] = []
21
+ const calls: string[] = []
22
+ let nextId = 100
23
+ const push = (siteId: number, verb: string) => {
24
+ const id = nextId++
25
+ const ok = !failOn.includes(siteId)
26
+ events.unshift({ id, status: ok ? "deployed" : "failed", output: ok ? null : "boom", finished_at: "x" } as Event)
27
+ calls.push(`${verb}:${siteId}`)
28
+ return { event_id: id }
29
+ }
30
+ const client = {
31
+ listSites: async () => sites.map((s) => ({ ...s })),
32
+ listEvents: async () => events.map((e) => ({ ...e })),
33
+ getEvent: async (id: number) => events.find((e) => e.id === id)!,
34
+ disableSite: async (id: number) => {
35
+ const s = sites.find((x) => x.id === id)!
36
+ if (!failOn.includes(id)) s.disabled_at = "now"
37
+ return push(id, "disable")
38
+ },
39
+ enableSite: async (id: number) => {
40
+ const s = sites.find((x) => x.id === id)!
41
+ if (!failOn.includes(id)) s.disabled_at = null
42
+ return push(id, "enable")
43
+ },
44
+ } as unknown as SpinupWPClientLike
45
+ return { client, calls }
46
+ }
47
+
48
+ const opts = () => ({ dir: mkdtempSync(join(tmpdir(), "offline-")), kuma: false, pollMs: 1 })
49
+
50
+ describe("takeServerOffline / bringServerOnline", () => {
51
+ test("disables only live sites and records exactly those", async () => {
52
+ const sites = [site(1, "a.com"), site(2, "b.com", true), site(3, "c.com")]
53
+ const { client, calls } = fakeClient(sites)
54
+ const o = opts()
55
+ const res = await takeServerOffline(client, cfg, server, o)
56
+ expect(res.disabled.sort()).toEqual(["a.com", "c.com"])
57
+ expect(res.alreadyOffline).toEqual(["b.com"])
58
+ expect(calls).toEqual(["disable:1", "disable:3"])
59
+ expect(loadOfflineState(o.dir).servers["1"]!.sites.map((s) => s.id)).toEqual([1, 3])
60
+ })
61
+
62
+ test("online re-enables only what offline took down, then forgets the server", async () => {
63
+ const sites = [site(1, "a.com"), site(2, "b.com", true)]
64
+ const { client, calls } = fakeClient(sites)
65
+ const o = opts()
66
+ await takeServerOffline(client, cfg, server, o)
67
+ const res = await bringServerOnline(client, cfg, 1, o)
68
+ expect(res.enabled).toEqual(["a.com"])
69
+ expect(calls).toEqual(["disable:1", "enable:1"])
70
+ expect(sites[1]!.disabled_at).not.toBeNull() // disabled elsewhere: left alone
71
+ expect(loadOfflineState(o.dir).servers["1"]).toBeUndefined()
72
+ })
73
+
74
+ test("a failed enable stays recorded so a re-run finishes the job", async () => {
75
+ const sites = [site(1, "a.com"), site(2, "b.com")]
76
+ const o = opts()
77
+ await takeServerOffline(fakeClient(sites).client, cfg, server, o)
78
+ const res = await bringServerOnline(fakeClient(sites, [2]).client, cfg, 1, o)
79
+ expect(res.enabled).toEqual(["a.com"])
80
+ expect(res.failed.map((f) => f.domain)).toEqual(["b.com"])
81
+ expect(loadOfflineState(o.dir).servers["1"]!.sites.map((s) => s.id)).toEqual([2])
82
+ })
83
+
84
+ test("a site re-enabled by hand counts as already online", async () => {
85
+ const sites = [site(1, "a.com")]
86
+ const { client, calls } = fakeClient(sites)
87
+ const o = opts()
88
+ await takeServerOffline(client, cfg, server, o)
89
+ sites[0]!.disabled_at = null
90
+ const res = await bringServerOnline(client, cfg, 1, o)
91
+ expect(res.alreadyOnline).toEqual(["a.com"])
92
+ expect(calls).toEqual(["disable:1"])
93
+ })
94
+
95
+ test("online on a server nothing was recorded for does nothing", async () => {
96
+ const { client, calls } = fakeClient([site(1, "a.com", true)])
97
+ const res = await bringServerOnline(client, cfg, 1, opts())
98
+ expect(res.nothingRecorded).toBe(true)
99
+ expect(calls).toEqual([])
100
+ })
101
+ })
102
+
103
+ describe("single sites", () => {
104
+ test("takes one site offline and leaves its neighbours up", async () => {
105
+ const sites = [site(1, "a.com"), site(2, "b.com")]
106
+ const { client, calls } = fakeClient(sites)
107
+ const o = opts()
108
+ const res = await takeSiteOffline(client, cfg, server, 2, o)
109
+ expect(res.disabled).toEqual(["b.com"])
110
+ expect(calls).toEqual(["disable:2"])
111
+ expect(sites[0]!.disabled_at).toBeNull()
112
+ expect(loadOfflineState(o.dir).servers["1"]!.sites.map((s) => s.id)).toEqual([2])
113
+ })
114
+
115
+ test("a single site joins the server's record, and the server restore covers it", async () => {
116
+ const sites = [site(1, "a.com"), site(2, "b.com")]
117
+ const { client } = fakeClient(sites)
118
+ const o = opts()
119
+ await takeSiteOffline(client, cfg, server, 1, o)
120
+ await takeSiteOffline(client, cfg, server, 2, o)
121
+ expect(loadOfflineState(o.dir).servers["1"]!.sites.map((s) => s.id)).toEqual([1, 2])
122
+ const res = await bringServerOnline(client, cfg, 1, o)
123
+ expect(res.enabled.sort()).toEqual(["a.com", "b.com"])
124
+ })
125
+
126
+ test("bringing back one site keeps the rest of the server's record", async () => {
127
+ const sites = [site(1, "a.com"), site(2, "b.com")]
128
+ const { client, calls } = fakeClient(sites)
129
+ const o = opts()
130
+ await takeServerOffline(client, cfg, server, o)
131
+ const res = await bringServerOnline(client, cfg, 1, { ...o, siteIds: [2] })
132
+ expect(res.enabled).toEqual(["b.com"])
133
+ expect(calls).toEqual(["disable:1", "disable:2", "enable:2"])
134
+ expect(loadOfflineState(o.dir).servers["1"]!.sites.map((s) => s.id)).toEqual([1])
135
+ })
136
+
137
+ test("asking for a site that wasn't taken offline here touches nothing", async () => {
138
+ const sites = [site(1, "a.com"), site(2, "b.com", true)]
139
+ const { client, calls } = fakeClient(sites)
140
+ const o = opts()
141
+ await takeSiteOffline(client, cfg, server, 1, o)
142
+ const res = await bringServerOnline(client, cfg, 1, { ...o, siteIds: [2] })
143
+ expect(res.notRecorded).toEqual([2])
144
+ expect(res.enabled).toEqual([])
145
+ expect(calls).toEqual(["disable:1"])
146
+ })
147
+
148
+ test("an already-offline site is reported, not recorded", async () => {
149
+ const { client, calls } = fakeClient([site(1, "a.com", true)])
150
+ const o = opts()
151
+ const res = await takeSiteOffline(client, cfg, server, 1, o)
152
+ expect(res.alreadyOffline).toEqual(["a.com"])
153
+ expect(calls).toEqual([])
154
+ expect(loadOfflineState(o.dir).servers["1"]).toBeUndefined()
155
+ })
156
+ })
157
+
158
+ describe("waitForEvents", () => {
159
+ test("reports a never-finishing event as a timeout, not a failure", async () => {
160
+ const client = {
161
+ listEvents: async () => [{ id: 5, status: "queued", finished_at: null } as Event],
162
+ getEvent: async () => ({ id: 5, status: "queued", finished_at: null }) as Event,
163
+ } as unknown as SpinupWPClientLike
164
+ const out = await waitForEvents(client, [5], { timeoutMs: 20, pollMs: 5 })
165
+ expect(out.get(5)).toBe("timeout")
166
+ })
167
+ })
168
+
169
+ describe("matchServer", () => {
170
+ const servers = [server, { id: 2, name: "web10.example.com", ip_address: "203.0.113.11" } as Server]
171
+ test("matches exact name, IP or id only", () => {
172
+ expect(matchServer(servers, "WEB1.example.com").ok).toBe(true)
173
+ expect(matchServer(servers, "203.0.113.11").ok && matchServer(servers, "203.0.113.11")).toMatchObject({ server: { id: 2 } })
174
+ expect(matchServer(servers, "2").ok).toBe(true)
175
+ })
176
+ test("a partial name is refused, with suggestions", () => {
177
+ const m = matchServer(servers, "web1")
178
+ expect(m.ok).toBe(false)
179
+ if (!m.ok) expect(m.suggestions).toEqual(["web1.example.com", "web10.example.com"])
180
+ })
181
+ })
@@ -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
+ }
package/src/ui/App.tsx CHANGED
@@ -19,6 +19,7 @@ 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 { SiteOffline } from "./views/SiteOffline.tsx"
22
23
  import { ReleaseNotes } from "./views/ReleaseNotes.tsx"
23
24
  import { GrantKey } from "./views/GrantKey.tsx"
24
25
  import { SudoConnect } from "./views/SudoConnect.tsx"
@@ -70,6 +71,7 @@ export function App() {
70
71
  store.bulkWpGroup !== null ||
71
72
  store.httpsToggleSite !== null ||
72
73
  store.purgeCacheSite !== null ||
74
+ store.siteOfflineSite !== null ||
73
75
  store.grantKeySite !== null ||
74
76
  store.sudoConnectServer !== null ||
75
77
  store.dbBackupSite !== null ||
@@ -146,6 +148,9 @@ export function App() {
146
148
  // The purge-cache overlay owns the keyboard while open.
147
149
  if (store.purgeCacheSite) return
148
150
 
151
+ // The take-site-offline overlay owns the keyboard while open.
152
+ if (store.siteOfflineSite) return
153
+
149
154
  // The grant-SSH-key overlay owns the keyboard while open.
150
155
  if (store.grantKeySite) return
151
156
 
@@ -265,6 +270,7 @@ export function App() {
265
270
  {store.bulkWpGroup && <BulkWpUpdate />}
266
271
  {store.httpsToggleSite && <HttpsToggle />}
267
272
  {store.purgeCacheSite && <PurgeCache />}
273
+ {store.siteOfflineSite && <SiteOffline />}
268
274
  {store.grantKeySite && <GrantKey />}
269
275
  {store.sudoConnectServer && <SudoConnect />}
270
276
  {store.dbBackupSite && <DbBackup />}
@@ -110,7 +110,7 @@ export const SERVER_CONTROL: ActionGroup[] = [
110
110
  {
111
111
  title: "Manage",
112
112
  items: [
113
- ["a", "Reboot / restart"],
113
+ ["a", "Restart / offline"],
114
114
  ["h", "Health"],
115
115
  ["N", "DNS"],
116
116
  ],
package/src/ui/Help.tsx CHANGED
@@ -37,13 +37,14 @@ const NAV: Section = {
37
37
  ["s", "SSH into the site"],
38
38
  ["h", "Server health (SSH)"],
39
39
  ["w", "Open in SpinupWP web app"],
40
- ["a", "Server actions (reboot/restart)"],
40
+ ["a", "Server actions (restart, take offline)"],
41
41
  ["c", "Create a new server"],
42
42
  ["C", "Clone this server to a new one"],
43
43
  ["S", "Connect sudo on the server"],
44
44
  ["u", "Update PHP version or WordPress core"],
45
45
  ["H", "Enable/disable HTTPS"],
46
46
  ["P", "Purge page + object cache"],
47
+ ["O", "Take a site offline / bring it back"],
47
48
  ["K", "Grant SpinupTUI's SSH key (sudo)"],
48
49
  ["d", "Identify a site's app (SSH)"],
49
50
  ],
@@ -463,11 +463,12 @@ export function siteGroups(isWordpress: boolean, isVanity: boolean): ActionGroup
463
463
  remote.push(["H", "Toggle HTTPS"])
464
464
  remote.push(["P", "Purge cache"])
465
465
  remote.push(["M", "Monitoring"])
466
+ remote.push(["O", "Take offline"])
466
467
  return [
467
468
  { title: "Open", items: [["o", "In browser"], ["w", "In SpinupWP"]] },
468
469
  { title: "Remote", items: remote },
469
470
  { title: "Local", items: local },
470
471
  ...(isVanity ? [{ title: "Vanity", items: [["R", "Refresh"]] as [string, string][] }] : []),
471
- { title: "Server", items: [["a", "Reboot / restart"], ["h", "Health"]] },
472
+ { title: "Server", items: [["a", "Restart / offline"], ["h", "Health"]] },
472
473
  ]
473
474
  }