spinuptui 1.2.0-alpha.1 → 1.3.0-alpha.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 +13 -2
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/api/client.ts +15 -0
- package/src/api/types.ts +3 -0
- package/src/dev/mockClient.ts +30 -7
- package/src/index.tsx +21 -0
- package/src/lib/cliOffline.ts +418 -0
- package/src/lib/offline.test.ts +181 -0
- package/src/lib/offline.ts +422 -0
- package/src/ui/App.tsx +6 -0
- package/src/ui/Details.tsx +1 -1
- package/src/ui/Help.tsx +2 -1
- package/src/ui/components.tsx +2 -1
- package/src/ui/offlineRun.tsx +82 -0
- package/src/ui/store.tsx +6 -0
- package/src/ui/views/Browser.tsx +9 -1
- package/src/ui/views/Dashboard.tsx +3 -1
- package/src/ui/views/Search.tsx +4 -1
- package/src/ui/views/ServerActions.tsx +102 -7
- package/src/ui/views/SiteOffline.tsx +171 -0
- package/src/ui/views/Stacks.tsx +5 -1
package/CHANGELOG.md
CHANGED
|
@@ -11,9 +11,18 @@ version; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.3.0-alpha.1] - 2026-10-21
|
|
15
|
+
|
|
16
|
+
A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew stay on 1.2.0. It continues `1.2.0-alpha.1` under a new number, because stable 1.2.0 shipped without the modules: it has Beacon and the Modules tab (see [1.2.0-alpha.1]) plus everything in [1.2.0]. The stable 1.3.0 will roll these notes up with the next modules.
|
|
17
|
+
|
|
18
|
+
## [1.2.0] - 2026-10-21
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- **Take a server's sites offline, and bring them back.** For a vulnerability you can't patch yet: `spinuptui offline <server>… --yes` (or **Take all sites offline** in the `a` menu) disables every site on the server through SpinupWP, so nginx stops answering for them and no request reaches PHP. Files, databases and certificates are untouched. `spinuptui online <server>… --yes` re-enables exactly the sites it took down, never one disabled for another reason, and a re-run finishes anything that failed. Without `--yes` both print the plan. One site at a time works too: `O` on a site, or `--site <domain>` on either command; the confirmation says how many other sites on its server stay up, since PHP is shared. Uptime Kuma monitors for those sites sit in a maintenance window until they're back, and offline servers are flagged `⏻ off` in the Servers list and on the Overview. See [docs/taking-sites-offline.md](docs/taking-sites-offline.md).
|
|
22
|
+
|
|
14
23
|
## [1.2.0-alpha.1] - 2026-10-17
|
|
15
24
|
|
|
16
|
-
A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew stay on 1.1.2. The stable 1.
|
|
25
|
+
A pre-release, published on npm's `next` tag (`bun add -g spinuptui@next --no-cache`). `@latest` and Homebrew stay on 1.1.2. The modules' stable release is 1.3.0, which will roll these notes up with the next modules; 1.2.0 shipped without them.
|
|
17
26
|
|
|
18
27
|
### Added
|
|
19
28
|
- **Beacon: `spinuptui beacon send` pages you on Telegram.** One command any script, cron job or agent can use instead of its own `curl` to the Telegram API. Messages go out as plain text with link previews off; anything over Telegram's 4096-character limit is split into numbered parts instead of being rejected; sends are retried (honoring Telegram's rate-limit hints) and logged. Exit codes tell a caller what happened: `0` delivered, `1` not configured (nothing attempted), `2` delivery failed. No Telegram bot yet? `spinuptui beacon setup` walks you through making one with @BotFather, checks the token, and finds your chat by itself once you message the bot. No digging for a chat ID. It saves nothing until a test message has arrived. Scripts can use `spinuptui beacon add telegram <name> --env-file <path>` instead. `beacon test`, `beacon status` and `beacon remove` round it out. See [docs/beacon.md](docs/beacon.md).
|
|
@@ -1497,7 +1506,9 @@ Initial tagged release.
|
|
|
1497
1506
|
### Notes
|
|
1498
1507
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1499
1508
|
|
|
1500
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.
|
|
1509
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v1.3.0-alpha.1...HEAD
|
|
1510
|
+
[1.3.0-alpha.1]: https://github.com/mwender/spinupwp-tui/compare/v1.2.0...v1.3.0-alpha.1
|
|
1511
|
+
[1.2.0]: https://github.com/mwender/spinupwp-tui/compare/v1.1.2...v1.2.0
|
|
1501
1512
|
[1.2.0-alpha.1]: https://github.com/mwender/spinupwp-tui/compare/v1.1.2...v1.2.0-alpha.1
|
|
1502
1513
|
[1.1.2]: https://github.com/mwender/spinupwp-tui/compare/v1.1.1...v1.1.2
|
|
1503
1514
|
[1.1.1]: https://github.com/mwender/spinupwp-tui/compare/v1.1.0...v1.1.1
|
package/README.md
CHANGED
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
- **Purge cache** (`P`) — clear page + object cache together.
|
|
61
61
|
- **Server actions** (`a`) — reboot or restart a service, with the real reason a
|
|
62
62
|
reboot is pending. → [docs/server-actions.md](docs/server-actions.md)
|
|
63
|
+
- **Take sites offline** (`a` for a whole server, `O` for one site, or `spinuptui offline`) — for a vulnerability you can't patch yet: nginx stops answering for those sites, so no request reaches PHP through them. Bringing them back re-enables exactly those sites, and Uptime Kuma stays quiet meanwhile. → [docs/taking-sites-offline.md](docs/taking-sites-offline.md)
|
|
63
64
|
- **Create & connect a server** (`c` / `V`) — provision a server, then wire it up
|
|
64
65
|
end-to-end (DNS, a vanity site, HTTPS, SSH-key handoff).
|
|
65
66
|
→ [docs/creating-connecting-servers.md](docs/creating-connecting-servers.md)
|
package/package.json
CHANGED
package/src/api/client.ts
CHANGED
|
@@ -109,6 +109,8 @@ export interface SpinupWPClientLike {
|
|
|
109
109
|
upgradeSitePhp(siteId: number, phpVersion: string): Promise<{ event_id: number }>
|
|
110
110
|
rebootServer(serverId: number): Promise<{ event_id: number }>
|
|
111
111
|
restartService(serverId: number, service: ServerService): Promise<{ event_id: number }>
|
|
112
|
+
disableSite(siteId: number): Promise<{ event_id: number }>
|
|
113
|
+
enableSite(siteId: number): Promise<{ event_id: number }>
|
|
112
114
|
}
|
|
113
115
|
|
|
114
116
|
const MAX_PAGES = 100 // hard safety cap when auto-paginating
|
|
@@ -482,4 +484,17 @@ export class SpinupWPClient implements SpinupWPClientLike {
|
|
|
482
484
|
restartService(serverId: number, service: ServerService): Promise<{ event_id: number }> {
|
|
483
485
|
return this.mutate<{ event_id: number }>(`/servers/${serverId}/services/${service}/restart`, "POST")
|
|
484
486
|
}
|
|
487
|
+
|
|
488
|
+
// Take a site offline without touching its files, database or certificate.
|
|
489
|
+
// Verified on a test box: nginx drops the site's server block, so port 80
|
|
490
|
+
// returns an empty reply and 443 fails the TLS handshake — no request reaches
|
|
491
|
+
// PHP. The site's PHP-FPM pool and cron stay in place. Async → event_id.
|
|
492
|
+
disableSite(siteId: number): Promise<{ event_id: number }> {
|
|
493
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/disable`, "POST")
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Undo disableSite: the site serves traffic again exactly as before.
|
|
497
|
+
enableSite(siteId: number): Promise<{ event_id: number }> {
|
|
498
|
+
return this.mutate<{ event_id: number }>(`/sites/${siteId}/enable`, "POST")
|
|
499
|
+
}
|
|
485
500
|
}
|
package/src/api/types.ts
CHANGED
|
@@ -120,6 +120,9 @@ export interface Site {
|
|
|
120
120
|
} | null
|
|
121
121
|
basic_auth?: { enabled: boolean; username?: string | null } | null
|
|
122
122
|
subdomain?: { enabled: boolean; url: string | null } | null
|
|
123
|
+
// Set while the site is disabled (POST /sites/{id}/disable): nginx stops
|
|
124
|
+
// serving every domain on it, so no request reaches PHP. Null when live.
|
|
125
|
+
disabled_at?: string | null
|
|
123
126
|
created_at: string
|
|
124
127
|
status: string
|
|
125
128
|
}
|
package/src/dev/mockClient.ts
CHANGED
|
@@ -45,6 +45,16 @@ export function createMockClient(): SpinupWPClientLike {
|
|
|
45
45
|
let nextEventId = nextIdAfter(events, 9100)
|
|
46
46
|
const eventPolls = new Map<number, number>()
|
|
47
47
|
|
|
48
|
+
function advance(e: Event): void {
|
|
49
|
+
if (e.status !== "running") return
|
|
50
|
+
const polls = (eventPolls.get(e.id) ?? 0) + 1
|
|
51
|
+
eventPolls.set(e.id, polls)
|
|
52
|
+
if (polls >= EVENT_POLLS_BEFORE_DONE) {
|
|
53
|
+
e.status = "deployed"
|
|
54
|
+
e.finished_at = new Date().toISOString()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
function pushEvent(name: string, serverId: number | null, output: string): number {
|
|
49
59
|
const id = nextEventId++
|
|
50
60
|
const now = new Date().toISOString()
|
|
@@ -230,20 +240,19 @@ export function createMockClient(): SpinupWPClientLike {
|
|
|
230
240
|
return { event_id }
|
|
231
241
|
},
|
|
232
242
|
|
|
243
|
+
// Reading an event (singly or in the list) counts as a poll, so callers
|
|
244
|
+
// that batch-check via listEvents see events finish too.
|
|
233
245
|
async listEvents(maxPages) {
|
|
234
246
|
const limit = (maxPages ?? 3) * 100
|
|
235
|
-
|
|
247
|
+
const page = events.slice(0, limit)
|
|
248
|
+
for (const e of page) advance(e)
|
|
249
|
+
return page.map((e) => ({ ...e }))
|
|
236
250
|
},
|
|
237
251
|
|
|
238
252
|
async getEvent(id) {
|
|
239
253
|
const found = events.find((e) => e.id === id)
|
|
240
254
|
if (!found) throw new Error(`dev mode: no such event ${id}`)
|
|
241
|
-
|
|
242
|
-
eventPolls.set(id, polls)
|
|
243
|
-
if (polls >= EVENT_POLLS_BEFORE_DONE && found.status === "running") {
|
|
244
|
-
found.status = "deployed"
|
|
245
|
-
found.finished_at = new Date().toISOString()
|
|
246
|
-
}
|
|
255
|
+
advance(found)
|
|
247
256
|
return { ...found }
|
|
248
257
|
},
|
|
249
258
|
|
|
@@ -266,5 +275,19 @@ export function createMockClient(): SpinupWPClientLike {
|
|
|
266
275
|
const event_id = pushEvent(`server.service.${service}.restarted`, serverId, `${service} restarted on ${server?.name ?? serverId}`)
|
|
267
276
|
return { event_id }
|
|
268
277
|
},
|
|
278
|
+
|
|
279
|
+
async disableSite(siteId) {
|
|
280
|
+
const site = sites.find((s) => s.id === siteId)
|
|
281
|
+
if (site) site.disabled_at = new Date().toISOString()
|
|
282
|
+
const event_id = pushEvent("site.disabled", site?.server_id ?? null, `Disabling ${site?.domain ?? siteId}`)
|
|
283
|
+
return { event_id }
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
async enableSite(siteId) {
|
|
287
|
+
const site = sites.find((s) => s.id === siteId)
|
|
288
|
+
if (site) site.disabled_at = null
|
|
289
|
+
const event_id = pushEvent("site.enabled", site?.server_id ?? null, `Enabling ${site?.domain ?? siteId}`)
|
|
290
|
+
return { event_id }
|
|
291
|
+
},
|
|
269
292
|
}
|
|
270
293
|
}
|
package/src/index.tsx
CHANGED
|
@@ -24,6 +24,7 @@ import { resolveSiblings } from "./lib/cliSiblings.ts"
|
|
|
24
24
|
import { execSshCommand } from "./lib/sshExec.ts"
|
|
25
25
|
import { runPullFiles, runPullDb } from "./lib/cliPull.ts"
|
|
26
26
|
import { runBeacon, runModules } from "./lib/cliBeacon.ts"
|
|
27
|
+
import { runOffline, runOnline } from "./lib/cliOffline.ts"
|
|
27
28
|
|
|
28
29
|
const args = process.argv.slice(2)
|
|
29
30
|
const positionals = args.filter((a) => !a.startsWith("-"))
|
|
@@ -90,6 +91,18 @@ Usage:
|
|
|
90
91
|
Import production's database into the linked local copy,
|
|
91
92
|
rewriting URLs. OVERWRITES your local database: needs
|
|
92
93
|
both --yes and "localSync": true in the config.
|
|
94
|
+
spinuptui offline List servers with sites offline (--json)
|
|
95
|
+
spinuptui offline <server>… | --all --yes
|
|
96
|
+
Take every site on those servers offline: nginx stops
|
|
97
|
+
answering, so no request reaches PHP. Files, databases
|
|
98
|
+
and certificates are untouched. Without --yes, prints
|
|
99
|
+
the plan and changes nothing.
|
|
100
|
+
spinuptui offline --site <domain> [--server <name>] --yes
|
|
101
|
+
Just that site. Its server's other sites (and PHP) stay
|
|
102
|
+
up, so this protects the site, not the server.
|
|
103
|
+
spinuptui online <server>… | --all | --site <domain> --yes
|
|
104
|
+
Bring back exactly the sites \`offline\` took down.
|
|
105
|
+
Servers are matched by exact name, IP or id.
|
|
93
106
|
spinuptui beacon setup Guided Telegram setup: make a bot, find your chat,
|
|
94
107
|
send a test (interactive; also in the app's Modules tab, 6)
|
|
95
108
|
spinuptui beacon send [--to <channel>] [--silent] [<message> | -]
|
|
@@ -284,6 +297,14 @@ if (command === "pull") {
|
|
|
284
297
|
process.exit(result.ok ? 0 : 1)
|
|
285
298
|
}
|
|
286
299
|
|
|
300
|
+
if (command === "offline") {
|
|
301
|
+
process.exit(await runOffline(args.slice(args.indexOf("offline") + 1)))
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (command === "online") {
|
|
305
|
+
process.exit(await runOnline(args.slice(args.indexOf("online") + 1)))
|
|
306
|
+
}
|
|
307
|
+
|
|
287
308
|
if (command === "beacon") {
|
|
288
309
|
process.exit(await runBeacon(args.slice(args.indexOf("beacon") + 1)))
|
|
289
310
|
}
|
|
@@ -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
|
+
}
|