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,418 @@
|
|
|
1
|
+
// `spinuptui offline` / `spinuptui online` — take whole servers' sites offline
|
|
2
|
+
// and bring them back. See lib/offline.ts for what "offline" means on the box.
|
|
3
|
+
//
|
|
4
|
+
// spinuptui offline what's offline right now
|
|
5
|
+
// spinuptui offline <server>… | --all --yes take every site on them offline
|
|
6
|
+
// spinuptui offline --site <domain> --yes just that site
|
|
7
|
+
// spinuptui online <server>… | --all --yes bring back what `offline` took down
|
|
8
|
+
// spinuptui online --site <domain> --yes just that site
|
|
9
|
+
//
|
|
10
|
+
// Without --yes both commands print the plan and change nothing. Progress goes
|
|
11
|
+
// to stderr; stdout carries the summary (or one JSON object with --json).
|
|
12
|
+
|
|
13
|
+
import type { AppConfig } from "../config.ts"
|
|
14
|
+
import { loadConfig } from "../config.ts"
|
|
15
|
+
import type { Server, Site } from "../api/types.ts"
|
|
16
|
+
import { SpinupWPClient, ApiError } from "../api/client.ts"
|
|
17
|
+
import { resolveSiteByDomain } from "./cliSsh.ts"
|
|
18
|
+
import {
|
|
19
|
+
loadOfflineState,
|
|
20
|
+
matchServer,
|
|
21
|
+
takeServerOffline,
|
|
22
|
+
bringServerOnline,
|
|
23
|
+
takeSiteOffline,
|
|
24
|
+
type OfflineServerResult,
|
|
25
|
+
type OnlineServerResult,
|
|
26
|
+
} from "./offline.ts"
|
|
27
|
+
|
|
28
|
+
// Servers are worked on side by side (SpinupWP queues events per server, so
|
|
29
|
+
// sites on one server finish one after another regardless); capped so a fleet
|
|
30
|
+
// run doesn't fire every POST in the first second.
|
|
31
|
+
const SERVER_CONCURRENCY = 4
|
|
32
|
+
|
|
33
|
+
async function mapLimit<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]> {
|
|
34
|
+
const out = new Array<R>(items.length)
|
|
35
|
+
let next = 0
|
|
36
|
+
const worker = async () => {
|
|
37
|
+
while (next < items.length) {
|
|
38
|
+
const i = next++
|
|
39
|
+
out[i] = await fn(items[i]!)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
|
|
43
|
+
return out
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface Parsed {
|
|
47
|
+
names: string[]
|
|
48
|
+
all: boolean
|
|
49
|
+
yes: boolean
|
|
50
|
+
json: boolean
|
|
51
|
+
// --site <domain>, with --server <name> when the domain is on two servers.
|
|
52
|
+
site: string | null
|
|
53
|
+
server: string | null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const VALUE_FLAGS = new Set(["--site", "--server"])
|
|
57
|
+
|
|
58
|
+
function parse(args: string[]): Parsed {
|
|
59
|
+
const names: string[] = []
|
|
60
|
+
const values = new Map<string, string>()
|
|
61
|
+
for (let i = 0; i < args.length; i++) {
|
|
62
|
+
const a = args[i]!
|
|
63
|
+
if (VALUE_FLAGS.has(a)) values.set(a, args[++i] ?? "")
|
|
64
|
+
else if (!a.startsWith("-")) names.push(a)
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
names,
|
|
68
|
+
all: args.includes("--all"),
|
|
69
|
+
yes: args.includes("--yes"),
|
|
70
|
+
json: args.includes("--json"),
|
|
71
|
+
site: values.has("--site") ? values.get("--site")! : null,
|
|
72
|
+
server: values.get("--server") ?? null,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --site combines with nothing else that picks a target.
|
|
77
|
+
function siteUsageError(p: Parsed): string | null {
|
|
78
|
+
if (p.site === null) return p.server !== null ? "--server only narrows --site; name servers directly otherwise." : null
|
|
79
|
+
if (!p.site || p.site.startsWith("-")) return "--site needs a domain, e.g. --site example.com"
|
|
80
|
+
if (p.names.length || p.all) return "--site takes one site; don't also name servers or pass --all."
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Find the site behind a domain (primary or additional), and its server.
|
|
85
|
+
async function resolveSite(client: SpinupWPClient, cfg: AppConfig, p: Parsed): Promise<{ ok: true; site: Site; server: Server } | { ok: false; code: number }> {
|
|
86
|
+
const r = await resolveSiteByDomain(p.site!, client, cfg, { server: p.server })
|
|
87
|
+
if (r.ok) return { ok: true, site: r.site, server: r.server }
|
|
88
|
+
const lines = [r.result.message, ...(r.result.candidates ?? []).map((c) => ` · ${c.server} (site ${c.siteId})`)]
|
|
89
|
+
if (r.result.reason === "multiple_matches") lines.push("Pick one with --server <name>.")
|
|
90
|
+
else if (r.result.remedy) lines.push(r.result.remedy)
|
|
91
|
+
return { ok: false, code: fail(p.json, r.result.reason, lines.join("\n"), { candidates: r.result.candidates }) }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`
|
|
95
|
+
const err = (line: string) => process.stderr.write(line + "\n")
|
|
96
|
+
|
|
97
|
+
function fail(json: boolean, reason: string, message: string, extra: Record<string, unknown> = {}): number {
|
|
98
|
+
if (json) console.log(JSON.stringify({ ok: false, reason, message, ...extra }))
|
|
99
|
+
else err(message)
|
|
100
|
+
return 1
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function apiFailure(json: boolean, e: unknown): number {
|
|
104
|
+
if (e instanceof ApiError && e.status === 401) return fail(json, "token_rejected", "Saved API token was rejected (401). Run `spinuptui login` to save a fresh one.")
|
|
105
|
+
return fail(json, "api_error", e instanceof ApiError ? e.message : `Unexpected error: ${(e as Error).message}`)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Resolve every name, or report all the misses at once.
|
|
109
|
+
function resolveServers(servers: Server[], names: string[]): { ok: true; servers: Server[] } | { ok: false; message: string } {
|
|
110
|
+
const picked: Server[] = []
|
|
111
|
+
const misses: string[] = []
|
|
112
|
+
for (const name of names) {
|
|
113
|
+
const m = matchServer(servers, name)
|
|
114
|
+
if (m.ok) {
|
|
115
|
+
if (!picked.includes(m.server)) picked.push(m.server)
|
|
116
|
+
} else {
|
|
117
|
+
misses.push(m.suggestions.length ? `${m.message} Did you mean: ${m.suggestions.join(", ")}?` : m.message)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return misses.length ? { ok: false, message: misses.join("\n") } : { ok: true, servers: picked }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- status ---------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
async function status(client: SpinupWPClient, cfg: AppConfig, json: boolean): Promise<number> {
|
|
126
|
+
const state = loadOfflineState()
|
|
127
|
+
let servers: Server[]
|
|
128
|
+
let sites: Site[]
|
|
129
|
+
try {
|
|
130
|
+
;[servers, sites] = await Promise.all([client.listServers(), client.listSites()])
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return apiFailure(json, e)
|
|
133
|
+
}
|
|
134
|
+
const byServer = new Map<number, Site[]>()
|
|
135
|
+
for (const s of sites) byServer.set(s.server_id, [...(byServer.get(s.server_id) ?? []), s])
|
|
136
|
+
|
|
137
|
+
const rows = servers
|
|
138
|
+
.map((srv) => {
|
|
139
|
+
const all = byServer.get(srv.id) ?? []
|
|
140
|
+
const off = all.filter((s) => s.disabled_at)
|
|
141
|
+
const rec = state.servers[String(srv.id)]
|
|
142
|
+
return { server: srv.name, id: srv.id, sites: all.length, offline: off.map((s) => s.domain), takenOfflineBySpinupTUI: rec?.sites.map((s) => s.domain) ?? [], since: rec?.takenAt ?? null }
|
|
143
|
+
})
|
|
144
|
+
.filter((r) => r.offline.length || r.takenOfflineBySpinupTUI.length)
|
|
145
|
+
|
|
146
|
+
if (json) {
|
|
147
|
+
console.log(JSON.stringify({ ok: true, account: cfg.profileLabel, servers: rows }))
|
|
148
|
+
return 0
|
|
149
|
+
}
|
|
150
|
+
if (!rows.length) {
|
|
151
|
+
console.log(`Every site on ${plural(servers.length, "server")} is online.`)
|
|
152
|
+
return 0
|
|
153
|
+
}
|
|
154
|
+
for (const r of rows) {
|
|
155
|
+
const whole = r.offline.length === r.sites ? "all sites offline" : `${r.offline.length} of ${plural(r.sites, "site")} offline`
|
|
156
|
+
console.log(`${r.server}: ${whole}${r.since ? ` (taken offline ${r.since})` : ""}`)
|
|
157
|
+
for (const d of r.offline) console.log(` ${r.takenOfflineBySpinupTUI.includes(d) ? "·" : "○"} ${d}`)
|
|
158
|
+
}
|
|
159
|
+
if (rows.some((r) => r.offline.some((d) => !r.takenOfflineBySpinupTUI.includes(d)))) {
|
|
160
|
+
console.log("\n○ = disabled outside `spinuptui offline`; `spinuptui online` leaves these alone.")
|
|
161
|
+
}
|
|
162
|
+
return 0
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---- offline --------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
export async function runOffline(args: string[]): Promise<number> {
|
|
168
|
+
const p = parse(args)
|
|
169
|
+
const cfg = loadConfig()
|
|
170
|
+
const client = new SpinupWPClient(cfg)
|
|
171
|
+
const siteErr = siteUsageError(p)
|
|
172
|
+
if (siteErr) return fail(p.json, "usage", siteErr)
|
|
173
|
+
if (p.site !== null) return siteOffline(client, cfg, p)
|
|
174
|
+
if (!p.names.length && !p.all) return status(client, cfg, p.json)
|
|
175
|
+
if (p.names.length && p.all) return fail(p.json, "usage", "Name servers or pass --all, not both.")
|
|
176
|
+
|
|
177
|
+
let servers: Server[]
|
|
178
|
+
let sites: Site[]
|
|
179
|
+
try {
|
|
180
|
+
;[servers, sites] = await Promise.all([client.listServers(), client.listSites()])
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return apiFailure(p.json, e)
|
|
183
|
+
}
|
|
184
|
+
let targets = servers
|
|
185
|
+
if (!p.all) {
|
|
186
|
+
const r = resolveServers(servers, p.names)
|
|
187
|
+
if (!r.ok) return fail(p.json, "server_not_found", r.message)
|
|
188
|
+
targets = r.servers
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!p.yes) {
|
|
192
|
+
const plan = targets.map((srv) => {
|
|
193
|
+
const on = sites.filter((s) => s.server_id === srv.id)
|
|
194
|
+
return { server: srv.name, id: srv.id, willDisable: on.filter((s) => !s.disabled_at).map((s) => s.domain), alreadyOffline: on.filter((s) => s.disabled_at).map((s) => s.domain) }
|
|
195
|
+
})
|
|
196
|
+
if (p.json) {
|
|
197
|
+
console.log(JSON.stringify({ ok: true, dryRun: true, servers: plan }))
|
|
198
|
+
return 0
|
|
199
|
+
}
|
|
200
|
+
const total = plan.reduce((n, s) => n + s.willDisable.length, 0)
|
|
201
|
+
console.log(`Would take ${plural(total, "site")} offline on ${plural(plan.length, "server")} (${cfg.profileLabel}):`)
|
|
202
|
+
for (const s of plan) {
|
|
203
|
+
console.log(` ${s.server}: ${plural(s.willDisable.length, "site")}${s.alreadyOffline.length ? ` (+${s.alreadyOffline.length} already offline)` : ""}`)
|
|
204
|
+
for (const d of s.willDisable) console.log(` · ${d}`)
|
|
205
|
+
}
|
|
206
|
+
console.log("\nOffline means nginx stops answering for these domains: visitors can't connect, and no request reaches PHP.")
|
|
207
|
+
console.log("Files, databases and certificates are untouched. Nothing has changed yet; add --yes to do it.")
|
|
208
|
+
return 0
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const results = await mapLimit(targets, SERVER_CONCURRENCY, async (srv): Promise<OfflineServerResult | { serverName: string; error: string }> => {
|
|
212
|
+
try {
|
|
213
|
+
return await takeServerOffline(client, cfg, srv, { onProgress: (l) => err(`→ ${l}`) })
|
|
214
|
+
} catch (e) {
|
|
215
|
+
return { serverName: srv.name, error: e instanceof ApiError ? e.message : (e as Error).message }
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const failedAny = results.some((r) => "error" in r || r.failed.length)
|
|
220
|
+
if (p.json) {
|
|
221
|
+
console.log(JSON.stringify({ ok: !failedAny, servers: results }))
|
|
222
|
+
return failedAny ? 1 : 0
|
|
223
|
+
}
|
|
224
|
+
console.log("")
|
|
225
|
+
for (const r of results) {
|
|
226
|
+
if ("error" in r) {
|
|
227
|
+
console.log(`✕ ${r.serverName}: ${r.error}`)
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
const bits = [`${plural(r.disabled.length, "site")} taken offline`]
|
|
231
|
+
if (r.alreadyOffline.length) bits.push(`${r.alreadyOffline.length} already offline`)
|
|
232
|
+
if (r.failed.length) bits.push(`${r.failed.length} failed`)
|
|
233
|
+
console.log(`${r.failed.length ? "✕" : "✓"} ${r.serverName}: ${bits.join(", ")}`)
|
|
234
|
+
for (const f of r.failed) console.log(` ✕ ${f.domain}: ${f.error}`)
|
|
235
|
+
if (r.kuma === "shielded") console.log(" Uptime Kuma: monitors are in a maintenance window until they come back.")
|
|
236
|
+
if (r.kuma === "failed") console.log(` Uptime Kuma: couldn't open a maintenance window (${r.kumaError}); expect down alerts.`)
|
|
237
|
+
}
|
|
238
|
+
console.log("\nBring them back with: spinuptui online " + (p.all ? "--all" : targets.map((s) => s.name).join(" ")) + " --yes")
|
|
239
|
+
return failedAny ? 1 : 0
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function siteOffline(client: SpinupWPClient, cfg: AppConfig, p: Parsed): Promise<number> {
|
|
243
|
+
const r = await resolveSite(client, cfg, p)
|
|
244
|
+
if (!r.ok) return r.code
|
|
245
|
+
const { site, server } = r
|
|
246
|
+
let neighbours: Site[]
|
|
247
|
+
try {
|
|
248
|
+
neighbours = (await client.listSites(server.id)).filter((s) => s.id !== site.id && !s.disabled_at)
|
|
249
|
+
} catch (e) {
|
|
250
|
+
return apiFailure(p.json, e)
|
|
251
|
+
}
|
|
252
|
+
const exposure = neighbours.length
|
|
253
|
+
? `${plural(neighbours.length, "other site")} on ${server.name} stay${neighbours.length === 1 ? "s" : ""} up, so that server's PHP is still reachable through ${neighbours.length === 1 ? "it" : "them"}. To protect the server, take it offline: spinuptui offline ${server.name} --yes`
|
|
254
|
+
: `It's the only site still up on ${server.name}, so this takes the whole server's sites offline.`
|
|
255
|
+
|
|
256
|
+
if (site.disabled_at) {
|
|
257
|
+
if (p.json) console.log(JSON.stringify({ ok: true, dryRun: !p.yes, server: server.name, alreadyOffline: [site.domain] }))
|
|
258
|
+
else console.log(`${site.domain} is already offline.`)
|
|
259
|
+
return 0
|
|
260
|
+
}
|
|
261
|
+
if (!p.yes) {
|
|
262
|
+
if (p.json) {
|
|
263
|
+
console.log(JSON.stringify({ ok: true, dryRun: true, server: server.name, willDisable: [site.domain], otherSitesStillUp: neighbours.map((s) => s.domain) }))
|
|
264
|
+
return 0
|
|
265
|
+
}
|
|
266
|
+
console.log(`Would take ${site.domain} offline (on ${server.name}, ${cfg.profileLabel}).`)
|
|
267
|
+
console.log(exposure)
|
|
268
|
+
console.log("\nOffline means nginx stops answering for its domains: visitors can't connect, and no request reaches PHP through this site.")
|
|
269
|
+
console.log("Files, database and certificate are untouched. Nothing has changed yet; add --yes to do it.")
|
|
270
|
+
return 0
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
let res: OfflineServerResult
|
|
274
|
+
try {
|
|
275
|
+
res = await takeSiteOffline(client, cfg, server, site.id, { onProgress: (l) => err(`→ ${l}`) })
|
|
276
|
+
} catch (e) {
|
|
277
|
+
return apiFailure(p.json, e)
|
|
278
|
+
}
|
|
279
|
+
const failed = res.failed.length > 0
|
|
280
|
+
if (p.json) {
|
|
281
|
+
console.log(JSON.stringify({ ok: !failed, servers: [res] }))
|
|
282
|
+
return failed ? 1 : 0
|
|
283
|
+
}
|
|
284
|
+
console.log("")
|
|
285
|
+
if (failed) for (const f of res.failed) console.log(`✕ ${f.domain}: ${f.error}`)
|
|
286
|
+
else console.log(`✓ ${site.domain} is offline.`)
|
|
287
|
+
if (res.kuma === "shielded") console.log(" Uptime Kuma: its monitors are in a maintenance window until it comes back.")
|
|
288
|
+
if (res.kuma === "failed") console.log(` Uptime Kuma: couldn't open a maintenance window (${res.kumaError}); expect down alerts.`)
|
|
289
|
+
if (!failed) {
|
|
290
|
+
if (neighbours.length) console.log(exposure)
|
|
291
|
+
console.log(`\nBring it back with: spinuptui online --site ${site.domain} --yes`)
|
|
292
|
+
}
|
|
293
|
+
return failed ? 1 : 0
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---- online ---------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
export async function runOnline(args: string[]): Promise<number> {
|
|
299
|
+
const p = parse(args)
|
|
300
|
+
const cfg = loadConfig()
|
|
301
|
+
const client = new SpinupWPClient(cfg)
|
|
302
|
+
const siteErr = siteUsageError(p)
|
|
303
|
+
if (siteErr) return fail(p.json, "usage", siteErr)
|
|
304
|
+
if (p.site !== null) return siteOnline(client, cfg, p)
|
|
305
|
+
if (!p.names.length && !p.all) return fail(p.json, "usage", "Usage: spinuptui online <server>… | --all | --site <domain> [--yes]")
|
|
306
|
+
if (p.names.length && p.all) return fail(p.json, "usage", "Name servers or pass --all, not both.")
|
|
307
|
+
|
|
308
|
+
const state = loadOfflineState()
|
|
309
|
+
let ids: number[]
|
|
310
|
+
if (p.all) {
|
|
311
|
+
ids = Object.keys(state.servers).map(Number)
|
|
312
|
+
} else {
|
|
313
|
+
let servers: Server[]
|
|
314
|
+
try {
|
|
315
|
+
servers = await client.listServers()
|
|
316
|
+
} catch (e) {
|
|
317
|
+
return apiFailure(p.json, e)
|
|
318
|
+
}
|
|
319
|
+
const r = resolveServers(servers, p.names)
|
|
320
|
+
if (!r.ok) return fail(p.json, "server_not_found", r.message)
|
|
321
|
+
ids = r.servers.map((s) => s.id)
|
|
322
|
+
const unknown = r.servers.filter((s) => !state.servers[String(s.id)])
|
|
323
|
+
if (unknown.length) {
|
|
324
|
+
return fail(
|
|
325
|
+
p.json,
|
|
326
|
+
"nothing_recorded",
|
|
327
|
+
`SpinupTUI didn't take ${unknown.map((s) => s.name).join(", ")} offline, so there's nothing to bring back. Sites disabled elsewhere are re-enabled in SpinupWP itself.`,
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (!ids.length) {
|
|
332
|
+
if (p.json) console.log(JSON.stringify({ ok: true, servers: [] }))
|
|
333
|
+
else console.log("Nothing is offline from SpinupTUI on this account.")
|
|
334
|
+
return 0
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!p.yes) {
|
|
338
|
+
const plan = ids.map((id) => state.servers[String(id)]!)
|
|
339
|
+
if (p.json) {
|
|
340
|
+
console.log(JSON.stringify({ ok: true, dryRun: true, servers: plan.map((r) => ({ server: r.serverName, id: r.serverId, willEnable: r.sites.map((s) => s.domain) })) }))
|
|
341
|
+
return 0
|
|
342
|
+
}
|
|
343
|
+
for (const r of plan) {
|
|
344
|
+
console.log(`${r.serverName}: would bring ${plural(r.sites.length, "site")} back online (offline since ${r.takenAt})`)
|
|
345
|
+
for (const s of r.sites) console.log(` · ${s.domain}`)
|
|
346
|
+
}
|
|
347
|
+
console.log("\nOnly sites `spinuptui offline` took down are re-enabled. Nothing has changed yet; add --yes to do it.")
|
|
348
|
+
return 0
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const results = await mapLimit(ids, SERVER_CONCURRENCY, async (id): Promise<OnlineServerResult | { serverName: string; error: string }> => {
|
|
352
|
+
try {
|
|
353
|
+
return await bringServerOnline(client, cfg, id, { onProgress: (l) => err(`→ ${l}`) })
|
|
354
|
+
} catch (e) {
|
|
355
|
+
return { serverName: state.servers[String(id)]?.serverName ?? String(id), error: e instanceof ApiError ? e.message : (e as Error).message }
|
|
356
|
+
}
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
const failedAny = results.some((r) => "error" in r || r.failed.length)
|
|
360
|
+
if (p.json) {
|
|
361
|
+
console.log(JSON.stringify({ ok: !failedAny, servers: results }))
|
|
362
|
+
return failedAny ? 1 : 0
|
|
363
|
+
}
|
|
364
|
+
console.log("")
|
|
365
|
+
for (const r of results) {
|
|
366
|
+
if ("error" in r) {
|
|
367
|
+
console.log(`✕ ${r.serverName}: ${r.error}`)
|
|
368
|
+
continue
|
|
369
|
+
}
|
|
370
|
+
const bits = [`${plural(r.enabled.length, "site")} back online`]
|
|
371
|
+
if (r.alreadyOnline.length) bits.push(`${r.alreadyOnline.length} already online`)
|
|
372
|
+
if (r.gone.length) bits.push(`${r.gone.length} no longer exist`)
|
|
373
|
+
if (r.failed.length) bits.push(`${r.failed.length} failed — re-run to retry`)
|
|
374
|
+
console.log(`${r.failed.length ? "✕" : "✓"} ${r.serverName}: ${bits.join(", ")}`)
|
|
375
|
+
for (const f of r.failed) console.log(` ✕ ${f.domain}: ${f.error}`)
|
|
376
|
+
if (r.kumaWindowClosed) console.log(" Uptime Kuma: maintenance window closed; monitoring resumed.")
|
|
377
|
+
}
|
|
378
|
+
return failedAny ? 1 : 0
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function siteOnline(client: SpinupWPClient, cfg: AppConfig, p: Parsed): Promise<number> {
|
|
382
|
+
const r = await resolveSite(client, cfg, p)
|
|
383
|
+
if (!r.ok) return r.code
|
|
384
|
+
const { site, server } = r
|
|
385
|
+
const record = loadOfflineState().servers[String(server.id)]
|
|
386
|
+
if (!record?.sites.some((s) => s.id === site.id)) {
|
|
387
|
+
return fail(
|
|
388
|
+
p.json,
|
|
389
|
+
"nothing_recorded",
|
|
390
|
+
site.disabled_at
|
|
391
|
+
? `${site.domain} is offline, but not because of SpinupTUI, so it's left alone. Re-enable it in SpinupWP itself.`
|
|
392
|
+
: `${site.domain} is already online.`,
|
|
393
|
+
)
|
|
394
|
+
}
|
|
395
|
+
if (!p.yes) {
|
|
396
|
+
if (p.json) console.log(JSON.stringify({ ok: true, dryRun: true, server: server.name, willEnable: [site.domain] }))
|
|
397
|
+
else console.log(`Would bring ${site.domain} back online (on ${server.name}, offline since ${record.takenAt}). Nothing has changed yet; add --yes to do it.`)
|
|
398
|
+
return 0
|
|
399
|
+
}
|
|
400
|
+
let res: OnlineServerResult
|
|
401
|
+
try {
|
|
402
|
+
res = await bringServerOnline(client, cfg, server.id, { siteIds: [site.id], onProgress: (l) => err(`→ ${l}`) })
|
|
403
|
+
} catch (e) {
|
|
404
|
+
return apiFailure(p.json, e)
|
|
405
|
+
}
|
|
406
|
+
const failed = res.failed.length > 0
|
|
407
|
+
if (p.json) {
|
|
408
|
+
console.log(JSON.stringify({ ok: !failed, servers: [res] }))
|
|
409
|
+
return failed ? 1 : 0
|
|
410
|
+
}
|
|
411
|
+
console.log("")
|
|
412
|
+
if (failed) for (const f of res.failed) console.log(`✕ ${f.domain}: ${f.error} Re-run to retry.`)
|
|
413
|
+
else if (res.alreadyOnline.length) console.log(`✓ ${site.domain} was already back online.`)
|
|
414
|
+
else if (res.gone.length) console.log(`${site.domain} no longer exists in SpinupWP; forgotten.`)
|
|
415
|
+
else console.log(`✓ ${site.domain} is back online.`)
|
|
416
|
+
if (res.kumaWindowClosed) console.log(" Uptime Kuma: maintenance window closed; monitoring resumed.")
|
|
417
|
+
return failed ? 1 : 0
|
|
418
|
+
}
|
|
@@ -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
|
+
})
|