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.
- package/CHANGELOG.md +7 -1
- 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
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Shared by the two places that take sites offline: the server actions menu
|
|
2
|
+
// (`a`, the whole server) and the site overlay (`O`, one site). The work itself
|
|
3
|
+
// is lib/offline.ts; this is the progress state and panel around it.
|
|
4
|
+
//
|
|
5
|
+
// It runs in the overlay rather than the store: what it changes is recorded on
|
|
6
|
+
// disk as it goes, so closing the overlay mid-run loses only the progress
|
|
7
|
+
// lines, never the ability to undo it.
|
|
8
|
+
|
|
9
|
+
import { useState } from "react"
|
|
10
|
+
import { tmpdir } from "node:os"
|
|
11
|
+
import { join } from "node:path"
|
|
12
|
+
import { theme } from "../lib/theme.ts"
|
|
13
|
+
import { isDevMode } from "../dev/devMode.ts"
|
|
14
|
+
import { Panel, Spinner } from "./components.tsx"
|
|
15
|
+
import { toast } from "./toast.ts"
|
|
16
|
+
|
|
17
|
+
// Dev Mode's fake servers must never land in the real account's offline record.
|
|
18
|
+
export const offlineDir = () => (isDevMode() ? join(tmpdir(), "spinuptui-dev-offline") : undefined)
|
|
19
|
+
|
|
20
|
+
export interface OfflineRun {
|
|
21
|
+
mode: "offline" | "online"
|
|
22
|
+
lines: string[]
|
|
23
|
+
finished: boolean
|
|
24
|
+
summary?: string
|
|
25
|
+
failed?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type OfflineJob = (onProgress: (line: string) => void) => Promise<{ summary: string; failed: boolean }>
|
|
29
|
+
|
|
30
|
+
// `label` prefixes the completion toast (a server or site name), since the
|
|
31
|
+
// overlay may be closed by the time it finishes.
|
|
32
|
+
export function useOfflineRun(refresh: () => Promise<void>) {
|
|
33
|
+
const [run, setRun] = useState<OfflineRun | null>(null)
|
|
34
|
+
const start = (mode: OfflineRun["mode"], label: string, job: OfflineJob) => {
|
|
35
|
+
const push = (line: string) => setRun((r) => (r ? { ...r, lines: [...r.lines, line] } : r))
|
|
36
|
+
setRun({ mode, lines: [], finished: false })
|
|
37
|
+
void (async () => {
|
|
38
|
+
let summary: string
|
|
39
|
+
let failed: boolean
|
|
40
|
+
try {
|
|
41
|
+
;({ summary, failed } = await job(push))
|
|
42
|
+
} catch (err) {
|
|
43
|
+
summary = (err as Error).message
|
|
44
|
+
failed = true
|
|
45
|
+
}
|
|
46
|
+
setRun((r) => (r ? { ...r, finished: true, summary, failed } : r))
|
|
47
|
+
if (failed) toast.error(`${label}: ${summary}`)
|
|
48
|
+
else toast.success(`${label}: ${summary}`)
|
|
49
|
+
await refresh()
|
|
50
|
+
})()
|
|
51
|
+
}
|
|
52
|
+
return { run, start }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function OfflineProgress({ run, plural }: { run: OfflineRun; plural: boolean }) {
|
|
56
|
+
const shown = run.lines.slice(-10)
|
|
57
|
+
const what = plural ? "sites" : "site"
|
|
58
|
+
return (
|
|
59
|
+
<Panel title={run.mode === "offline" ? ` Taking ${what} offline ` : ` Bringing ${what} back online `} active>
|
|
60
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
61
|
+
{shown.map((l, i) => (
|
|
62
|
+
<text key={i} content={l} fg={l.includes("✕") ? theme.bad : l.includes("✓") ? theme.good : theme.textDim} wrapMode="none" />
|
|
63
|
+
))}
|
|
64
|
+
<box style={{ height: 1 }} />
|
|
65
|
+
{run.finished ? (
|
|
66
|
+
<text content={`${run.failed ? "✕" : "✓"} ${run.summary}`} fg={run.failed ? theme.bad : theme.good} />
|
|
67
|
+
) : (
|
|
68
|
+
<box style={{ flexDirection: "row" }}>
|
|
69
|
+
<Spinner />
|
|
70
|
+
<text content={plural ? " Working — SpinupWP runs a server's sites one at a time." : " Working — usually a few seconds."} fg={theme.textDim} wrapMode="none" />
|
|
71
|
+
</box>
|
|
72
|
+
)}
|
|
73
|
+
<box style={{ height: 1 }} />
|
|
74
|
+
<text
|
|
75
|
+
content={run.finished ? "Esc to close" : "Esc to close — it keeps running, and each change is recorded."}
|
|
76
|
+
fg={theme.textFaint}
|
|
77
|
+
wrapMode="none"
|
|
78
|
+
/>
|
|
79
|
+
</box>
|
|
80
|
+
</Panel>
|
|
81
|
+
)
|
|
82
|
+
}
|
package/src/ui/store.tsx
CHANGED
|
@@ -569,6 +569,9 @@ interface StoreValue extends DataState {
|
|
|
569
569
|
// poll it to completion in the background.
|
|
570
570
|
startHttpsToggle: (site: Site) => void
|
|
571
571
|
clearHttpsToggle: (siteId: number) => void
|
|
572
|
+
// The site whose take-offline overlay (O) is open, or null.
|
|
573
|
+
siteOfflineSite: Site | null
|
|
574
|
+
setSiteOfflineSite: (s: Site | null) => void
|
|
572
575
|
// The site whose purge-cache overlay is open, or null.
|
|
573
576
|
purgeCacheSite: Site | null
|
|
574
577
|
setPurgeCacheSite: (s: Site | null) => void
|
|
@@ -1040,6 +1043,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
1040
1043
|
const [httpsToggleSite, setHttpsToggleSite] = useState<Site | null>(null)
|
|
1041
1044
|
const [httpsToggles, setHttpsToggles] = useState<Map<number, HttpsToggleProgress>>(new Map())
|
|
1042
1045
|
const [purgeCacheSite, setPurgeCacheSite] = useState<Site | null>(null)
|
|
1046
|
+
const [siteOfflineSite, setSiteOfflineSite] = useState<Site | null>(null)
|
|
1043
1047
|
const [purgeCacheProgress, setPurgeCacheProgress] = useState<Map<number, PurgeCacheProgress>>(new Map())
|
|
1044
1048
|
const [serverActionsServer, setServerActionsServer] = useState<Server | null>(null)
|
|
1045
1049
|
const [serverOps, setServerOps] = useState<Map<number, ServerOpProgress>>(new Map())
|
|
@@ -4414,6 +4418,8 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
4414
4418
|
clearHttpsToggle,
|
|
4415
4419
|
purgeCacheSite,
|
|
4416
4420
|
setPurgeCacheSite,
|
|
4421
|
+
siteOfflineSite,
|
|
4422
|
+
setSiteOfflineSite,
|
|
4417
4423
|
purgeCacheProgress,
|
|
4418
4424
|
startPurgeCache,
|
|
4419
4425
|
clearPurgeCache,
|
package/src/ui/views/Browser.tsx
CHANGED
|
@@ -24,7 +24,7 @@ type Focus = "servers" | "sites"
|
|
|
24
24
|
|
|
25
25
|
export function Browser({ rows }: { rows: number }) {
|
|
26
26
|
const store = useStore()
|
|
27
|
-
const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setDbSyncSite, localSync, localLinks, setDbBackupSite, setMediaFallbackSite, setEnableLocalSyncSite } = store
|
|
27
|
+
const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setDbSyncSite, localSync, localLinks, setDbBackupSite, setMediaFallbackSite, setEnableLocalSyncSite } = store
|
|
28
28
|
|
|
29
29
|
const [serverIndex, setServerIndex] = useState(0)
|
|
30
30
|
const [siteIndex, setSiteIndex] = useState(0)
|
|
@@ -128,6 +128,11 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
128
128
|
// Purge page cache + object cache on the selected site.
|
|
129
129
|
if (focus === "sites" && sites[siteIndex]) setPurgeCacheSite(sites[siteIndex])
|
|
130
130
|
return
|
|
131
|
+
case "O":
|
|
132
|
+
// Take the selected site offline, or bring it back. The whole server's
|
|
133
|
+
// version is in the server actions menu (a).
|
|
134
|
+
if (focus === "sites" && sites[siteIndex]) setSiteOfflineSite(sites[siteIndex])
|
|
135
|
+
return
|
|
131
136
|
case "M":
|
|
132
137
|
// Uptime Kuma monitoring for the selected site (connect on first use).
|
|
133
138
|
// Capital because lowercase m is media fallback here, matching Search.
|
|
@@ -324,6 +329,7 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
324
329
|
emptyText="No servers yet — press c to create one"
|
|
325
330
|
renderRow={(s, selected) => {
|
|
326
331
|
const count = sitesForServer(s.id).length
|
|
332
|
+
const offline = sitesForServer(s.id).filter((x) => x.disabled_at).length
|
|
327
333
|
const op = serverOps.get(s.id)
|
|
328
334
|
return (
|
|
329
335
|
<>
|
|
@@ -340,6 +346,7 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
340
346
|
) : (
|
|
341
347
|
s.reboot_required && <text content=" ↻ rbt" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />
|
|
342
348
|
)}
|
|
349
|
+
{offline > 0 && <text content={offline === count ? " ⏻ off" : ` ⏻ ${offline} off`} fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />}
|
|
343
350
|
{s.upgrade_required && <text content=" ⬆ upg" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />}
|
|
344
351
|
{isServerOsEol(s) && <text content=" ⚠ os" fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />}
|
|
345
352
|
{isSudoConnected(s.id) && <text content=" ● sudo" fg={selected ? theme.text : theme.good} style={{ flexShrink: 0 }} />}
|
|
@@ -388,6 +395,7 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
388
395
|
linked/key/HTTPS/cache/backup/updates badges moved to the Details
|
|
389
396
|
pane (already shown there for the selected site) to give the
|
|
390
397
|
domain name room on narrower terminals. */}
|
|
398
|
+
{s.disabled_at && <text content="⏻ off " fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />}
|
|
391
399
|
<WpCoreRowMark siteId={s.id} selected={selected} compact />
|
|
392
400
|
<text content={stackTag(stack).padEnd(7) + " "} fg={stackColor(stack, selected)} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
393
401
|
<PhpVersionCell version={s.php_version} upgrade={phpUpgrades.get(s.id)} selected={selected} />
|
|
@@ -61,10 +61,12 @@ export function Dashboard({ rows }: { rows: number }) {
|
|
|
61
61
|
for (const s of servers) {
|
|
62
62
|
if (s.reboot_required) items.push({ text: `${s.name} — reboot required`, color: theme.warn })
|
|
63
63
|
if (s.upgrade_required) items.push({ text: `${s.name} — SpinupWP upgrade required`, color: theme.warn })
|
|
64
|
+
const off = sites.filter((x) => x.server_id === s.id && x.disabled_at).length
|
|
65
|
+
if (off) items.push({ text: `${s.name} — ${off} site${off === 1 ? "" : "s"} offline`, color: theme.bad })
|
|
64
66
|
if (isServerOsEol(s)) items.push({ text: `${s.name} — Ubuntu ${s.ubuntu_version} is EOL, clone to a newer server`, color: theme.bad })
|
|
65
67
|
}
|
|
66
68
|
return items
|
|
67
|
-
}, [servers, isServerOsEol])
|
|
69
|
+
}, [servers, sites, isServerOsEol])
|
|
68
70
|
|
|
69
71
|
const diskPct = agg.total > 0 ? (agg.used / agg.total) * 100 : 0
|
|
70
72
|
const listRows = Math.max(3, rows - 9) // minus the stat-card band (5), status bar, and panel chrome
|
package/src/ui/views/Search.tsx
CHANGED
|
@@ -36,7 +36,7 @@ function score(haystack: string, q: string): number | null {
|
|
|
36
36
|
|
|
37
37
|
export function Search({ rows }: { rows: number }) {
|
|
38
38
|
const store = useStore()
|
|
39
|
-
const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite, setVanityServer, vanityJob } = store
|
|
39
|
+
const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed, setWpInventorySite, setGrantKeySite, runProbe, setEnableLocalSyncSite, setVanityServer, vanityJob } = store
|
|
40
40
|
const [query, setQuery] = useState("")
|
|
41
41
|
const [selected, setSelected] = useState(0)
|
|
42
42
|
// "query" = typing/filtering (input focused); "actions" = input blurred so the
|
|
@@ -153,6 +153,9 @@ export function Search({ rows }: { rows: number }) {
|
|
|
153
153
|
case "P":
|
|
154
154
|
if (current?.kind === "site") setPurgeCacheSite(current.site)
|
|
155
155
|
return
|
|
156
|
+
case "O":
|
|
157
|
+
if (current?.kind === "site") setSiteOfflineSite(current.site)
|
|
158
|
+
return
|
|
156
159
|
case "t":
|
|
157
160
|
if (current?.kind === "site") flashMsg(openLocalTerminal(current.site.id))
|
|
158
161
|
return
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
// Server actions overlay — reboot the server
|
|
1
|
+
// Server actions overlay — reboot the server, restart a single service, or take
|
|
2
|
+
// its sites offline.
|
|
2
3
|
//
|
|
3
4
|
// Opened with `a` on a selected server (Servers tab / Search). Like the PHP
|
|
4
5
|
// upgrade overlay: pick an action → confirm → POST → poll the event. The actual
|
|
@@ -6,6 +7,9 @@
|
|
|
6
7
|
// (Esc) leaves it running and the server's row keeps a spinner. When a reboot is
|
|
7
8
|
// pending, we SSH-probe the *why* (Ubuntu's pending-restart packages) and show
|
|
8
9
|
// it — labeled as OS context, not as SpinupWP's internal logic.
|
|
10
|
+
//
|
|
11
|
+
// The last row takes every site on the server offline (or brings back the ones
|
|
12
|
+
// that took down) — see lib/offline.ts and ../offlineRun.tsx.
|
|
9
13
|
|
|
10
14
|
import { useEffect, useState } from "react"
|
|
11
15
|
import { useKeyboard } from "@opentui/react"
|
|
@@ -20,8 +24,12 @@ import { serverWebUrl } from "../../lib/spinupweb.ts"
|
|
|
20
24
|
import { toast } from "../toast.ts"
|
|
21
25
|
import type { RebootInfo } from "../../lib/ssh.ts"
|
|
22
26
|
import type { ServerOpKind } from "../store.tsx"
|
|
27
|
+
import { loadConfig } from "../../config.ts"
|
|
28
|
+
import { isDevMode } from "../../dev/devMode.ts"
|
|
29
|
+
import { loadOfflineState, takeServerOffline, bringServerOnline } from "../../lib/offline.ts"
|
|
30
|
+
import { offlineDir, useOfflineRun, OfflineProgress } from "../offlineRun.tsx"
|
|
23
31
|
|
|
24
|
-
type Phase = "pick" | "confirm" | "tracking" | "done" | "error"
|
|
32
|
+
type Phase = "pick" | "confirm" | "tracking" | "done" | "error" | "offline"
|
|
25
33
|
|
|
26
34
|
interface Action {
|
|
27
35
|
kind: ServerOpKind
|
|
@@ -60,6 +68,7 @@ export function ServerActions() {
|
|
|
60
68
|
rebootInfoErrors,
|
|
61
69
|
loadRebootInfo,
|
|
62
70
|
accountSlug,
|
|
71
|
+
refresh,
|
|
63
72
|
} = store
|
|
64
73
|
|
|
65
74
|
const rebootRequired = !!server?.reboot_required
|
|
@@ -68,6 +77,18 @@ export function ServerActions() {
|
|
|
68
77
|
// Cursor starts on Reboot when one is pending, else on the first restart.
|
|
69
78
|
const [index, setIndex] = useState(() => (server?.reboot_required ? 0 : 1))
|
|
70
79
|
const [target, setTarget] = useState<Action | null>(null)
|
|
80
|
+
const { run, start } = useOfflineRun(refresh)
|
|
81
|
+
|
|
82
|
+
// The offline row sits after the service actions. It offers to bring sites
|
|
83
|
+
// back when SpinupTUI took this server offline, and to take it offline
|
|
84
|
+
// otherwise.
|
|
85
|
+
const OFFLINE_INDEX = ACTIONS.length
|
|
86
|
+
const offlineRecord = server ? loadOfflineState(offlineDir()).servers[String(server.id)] : undefined
|
|
87
|
+
const offlineMode: "offline" | "online" = offlineRecord ? "online" : "offline"
|
|
88
|
+
const liveCount = server ? sitesForServer(server.id).filter((s) => !s.disabled_at).length : 0
|
|
89
|
+
const offlineLabel = offlineRecord
|
|
90
|
+
? `Bring ${offlineRecord.sites.length} site${offlineRecord.sites.length === 1 ? "" : "s"} back online`
|
|
91
|
+
: `Take all ${liveCount} site${liveCount === 1 ? "" : "s"} offline`
|
|
71
92
|
|
|
72
93
|
const siteCount = server ? sitesForServer(server.id).length : 0
|
|
73
94
|
const info = server ? rebootInfo.get(server.id) : undefined
|
|
@@ -90,6 +111,27 @@ export function ServerActions() {
|
|
|
90
111
|
? "error"
|
|
91
112
|
: "tracking"
|
|
92
113
|
|
|
114
|
+
const startOffline = () => {
|
|
115
|
+
if (!server) return
|
|
116
|
+
setPhase("offline")
|
|
117
|
+
const cfg = loadConfig()
|
|
118
|
+
start(offlineMode, server.name, async (onProgress) => {
|
|
119
|
+
const o = { onProgress, kuma: !isDevMode(), dir: offlineDir() }
|
|
120
|
+
if (offlineMode === "offline") {
|
|
121
|
+
const r = await takeServerOffline(store.client, cfg, server, o)
|
|
122
|
+
return {
|
|
123
|
+
failed: r.failed.length > 0,
|
|
124
|
+
summary: `${r.disabled.length} taken offline${r.failed.length ? `, ${r.failed.length} failed` : ""}${r.kuma === "shielded" ? " · Kuma monitors in maintenance" : ""}`,
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const r = await bringServerOnline(store.client, cfg, server.id, o)
|
|
128
|
+
return {
|
|
129
|
+
failed: r.failed.length > 0,
|
|
130
|
+
summary: `${r.enabled.length} back online${r.alreadyOnline.length ? `, ${r.alreadyOnline.length} already were` : ""}${r.failed.length ? `, ${r.failed.length} failed — run it again` : ""}`,
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
93
135
|
const close = () => {
|
|
94
136
|
if (server && progress?.status === "failed") clearServerOp(server.id)
|
|
95
137
|
setServerActionsServer(null)
|
|
@@ -103,14 +145,18 @@ export function ServerActions() {
|
|
|
103
145
|
switch (name) {
|
|
104
146
|
case "up":
|
|
105
147
|
case "k":
|
|
106
|
-
return setIndex((i) => moveSelection(i, -1, ACTIONS.length))
|
|
148
|
+
return setIndex((i) => moveSelection(i, -1, ACTIONS.length + 1))
|
|
107
149
|
case "down":
|
|
108
150
|
case "j":
|
|
109
|
-
return setIndex((i) => moveSelection(i, 1, ACTIONS.length))
|
|
151
|
+
return setIndex((i) => moveSelection(i, 1, ACTIONS.length + 1))
|
|
110
152
|
case "return":
|
|
111
153
|
case "right":
|
|
112
154
|
case "l":
|
|
113
|
-
|
|
155
|
+
if (index === OFFLINE_INDEX && offlineMode === "offline" && liveCount === 0) {
|
|
156
|
+
toast.error("Every site on this server is already offline.")
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
setTarget(index === OFFLINE_INDEX ? null : ACTIONS[index]!)
|
|
114
160
|
setPhase("confirm")
|
|
115
161
|
return
|
|
116
162
|
case "w":
|
|
@@ -125,6 +171,7 @@ export function ServerActions() {
|
|
|
125
171
|
|
|
126
172
|
if (dp === "confirm") {
|
|
127
173
|
if (name === "y") {
|
|
174
|
+
if (!target) return startOffline()
|
|
128
175
|
if (server && target) {
|
|
129
176
|
startServerOp(server, target.kind, target.verb)
|
|
130
177
|
setPhase("tracking")
|
|
@@ -222,7 +269,7 @@ export function ServerActions() {
|
|
|
222
269
|
<box style={{ height: 1 }} />
|
|
223
270
|
</>
|
|
224
271
|
)}
|
|
225
|
-
{ACTIONS.map((a, i) => {
|
|
272
|
+
{[...ACTIONS.map((a, i) => {
|
|
226
273
|
const selected = i === index
|
|
227
274
|
const recommended = a.kind === "reboot" && rebootRequired
|
|
228
275
|
const fg = selected ? theme.text : recommended ? theme.warn : theme.textDim
|
|
@@ -236,12 +283,60 @@ export function ServerActions() {
|
|
|
236
283
|
{recommended && <text content="(recommended)" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />}
|
|
237
284
|
</box>
|
|
238
285
|
)
|
|
239
|
-
})
|
|
286
|
+
}),
|
|
287
|
+
<box key="offline" style={{ flexDirection: "row", height: 1, backgroundColor: index === OFFLINE_INDEX ? theme.selectedBg : undefined }}>
|
|
288
|
+
<text
|
|
289
|
+
content={(index === OFFLINE_INDEX ? "❯ " : " ") + "⏻ " + offlineLabel}
|
|
290
|
+
fg={index === OFFLINE_INDEX ? theme.text : offlineRecord ? theme.warn : theme.textDim}
|
|
291
|
+
style={{ flexGrow: 1 }}
|
|
292
|
+
wrapMode="none"
|
|
293
|
+
/>
|
|
294
|
+
</box>]}
|
|
240
295
|
</box>
|
|
241
296
|
</Panel>
|
|
242
297
|
)
|
|
243
298
|
}
|
|
244
299
|
|
|
300
|
+
if (dp === "confirm" && !target) {
|
|
301
|
+
const domains = offlineRecord
|
|
302
|
+
? offlineRecord.sites.map((s) => s.domain)
|
|
303
|
+
: sitesForServer(server!.id).filter((s) => !s.disabled_at).map((s) => s.domain)
|
|
304
|
+
const skipped = offlineRecord ? 0 : siteCount - liveCount
|
|
305
|
+
return (
|
|
306
|
+
<Panel title=" Confirm " active>
|
|
307
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
308
|
+
<box style={{ flexDirection: "row" }}>
|
|
309
|
+
<text content={offlineRecord ? "Bring sites back online on " : "Take every site offline on "} fg={theme.text} wrapMode="none" />
|
|
310
|
+
<text content={truncate(server!.name, 30)} fg={theme.accent} wrapMode="none" />
|
|
311
|
+
<text content="?" fg={theme.text} />
|
|
312
|
+
</box>
|
|
313
|
+
<box style={{ height: 1 }} />
|
|
314
|
+
{offlineRecord ? (
|
|
315
|
+
<text content="Re-enables only the sites taken offline here; anything disabled another way stays as it is." fg={theme.textDim} />
|
|
316
|
+
) : (
|
|
317
|
+
<>
|
|
318
|
+
<text content="nginx stops answering for these domains: visitors can't connect and no request reaches PHP." fg={theme.warn} />
|
|
319
|
+
<text content="Files, databases and certificates are untouched; bring them back from this menu." fg={theme.textDim} />
|
|
320
|
+
</>
|
|
321
|
+
)}
|
|
322
|
+
<box style={{ height: 1 }} />
|
|
323
|
+
{domains.slice(0, 8).map((d) => (
|
|
324
|
+
<text key={d} content={` • ${d}`} fg={theme.textFaint} wrapMode="none" />
|
|
325
|
+
))}
|
|
326
|
+
{domains.length > 8 && <text content={` • …${domains.length - 8} more`} fg={theme.textFaint} wrapMode="none" />}
|
|
327
|
+
{skipped > 0 && <text content={` ${skipped} already offline — left alone`} fg={theme.textFaint} wrapMode="none" />}
|
|
328
|
+
{store.kumaConfigured && (
|
|
329
|
+
<text content={offlineRecord ? "Their Uptime Kuma maintenance window closes once all are back." : "Uptime Kuma monitors enter a maintenance window — no alert flood."} fg={theme.textFaint} wrapMode="none" />
|
|
330
|
+
)}
|
|
331
|
+
<box style={{ height: 1 }} />
|
|
332
|
+
<text content="Press y to confirm · ← to go back · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
333
|
+
</box>
|
|
334
|
+
</Panel>
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (dp === "offline" && run) return <OfflineProgress run={run} plural />
|
|
339
|
+
|
|
245
340
|
if (dp === "confirm") {
|
|
246
341
|
const isReboot = target?.kind === "reboot"
|
|
247
342
|
return (
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Take one site offline, or bring it back — opened with `O` on a selected site
|
|
2
|
+
// (Servers tab, Search, Stacks). The server-wide version is the last row of the
|
|
3
|
+
// server actions menu (`a`); both share lib/offline.ts and ../offlineRun.tsx,
|
|
4
|
+
// and a site taken offline here is recorded under its server, so bringing the
|
|
5
|
+
// server back covers it too.
|
|
6
|
+
//
|
|
7
|
+
// The confirm screen is honest about the limit: PHP is shared by the whole
|
|
8
|
+
// server, so while any other site on it is up, PHP is still reachable.
|
|
9
|
+
|
|
10
|
+
import { useState } from "react"
|
|
11
|
+
import { useKeyboard } from "@opentui/react"
|
|
12
|
+
import { theme } from "../../lib/theme.ts"
|
|
13
|
+
import { truncate } from "../../lib/format.ts"
|
|
14
|
+
import { Panel, Centered } from "../components.tsx"
|
|
15
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
16
|
+
import { useStore } from "../store.tsx"
|
|
17
|
+
import { loadConfig } from "../../config.ts"
|
|
18
|
+
import { isDevMode } from "../../dev/devMode.ts"
|
|
19
|
+
import { loadOfflineState, takeSiteOffline, bringServerOnline } from "../../lib/offline.ts"
|
|
20
|
+
import { offlineDir, useOfflineRun, OfflineProgress } from "../offlineRun.tsx"
|
|
21
|
+
|
|
22
|
+
type Phase = "confirm" | "running"
|
|
23
|
+
|
|
24
|
+
export function SiteOffline() {
|
|
25
|
+
const store = useStore()
|
|
26
|
+
const { siteOfflineSite, setSiteOfflineSite, sites, servers, sitesForServer, refresh, kumaConfigured } = store
|
|
27
|
+
// The overlay was opened with a snapshot; read the live row so a refresh
|
|
28
|
+
// after the run shows the new state.
|
|
29
|
+
const site = sites.find((s) => s.id === siteOfflineSite?.id) ?? siteOfflineSite
|
|
30
|
+
const server = site ? servers.find((s) => s.id === site.server_id) : undefined
|
|
31
|
+
|
|
32
|
+
const [phase, setPhase] = useState<Phase>("confirm")
|
|
33
|
+
const { run, start } = useOfflineRun(refresh)
|
|
34
|
+
|
|
35
|
+
const recorded = !!(site && loadOfflineState(offlineDir()).servers[String(site.server_id)]?.sites.some((s) => s.id === site.id))
|
|
36
|
+
// What `y` would do. A site disabled some other way isn't ours to re-enable.
|
|
37
|
+
const mode: "offline" | "online" | "foreign" = !site?.disabled_at ? "offline" : recorded ? "online" : "foreign"
|
|
38
|
+
const neighbours = site && server ? sitesForServer(server.id).filter((s) => s.id !== site.id && !s.disabled_at) : []
|
|
39
|
+
|
|
40
|
+
const close = () => setSiteOfflineSite(null)
|
|
41
|
+
|
|
42
|
+
const go = () => {
|
|
43
|
+
if (!site || !server || mode === "foreign") return
|
|
44
|
+
setPhase("running")
|
|
45
|
+
const cfg = loadConfig()
|
|
46
|
+
start(mode, site.domain, async (onProgress) => {
|
|
47
|
+
const o = { onProgress, kuma: !isDevMode(), dir: offlineDir() }
|
|
48
|
+
if (mode === "offline") {
|
|
49
|
+
const r = await takeSiteOffline(store.client, cfg, server, site.id, o)
|
|
50
|
+
const failed = r.failed.length > 0
|
|
51
|
+
return { failed, summary: failed ? r.failed[0]!.error : `offline${r.kuma === "shielded" ? " · Kuma monitors in maintenance" : ""}` }
|
|
52
|
+
}
|
|
53
|
+
const r = await bringServerOnline(store.client, cfg, server.id, { ...o, siteIds: [site.id] })
|
|
54
|
+
const failed = r.failed.length > 0
|
|
55
|
+
return { failed, summary: failed ? `${r.failed[0]!.error} Press O to retry.` : "back online" }
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
useKeyboard((key) => {
|
|
60
|
+
const name = key.name ?? ""
|
|
61
|
+
if (name === "escape" || name === "q") return close()
|
|
62
|
+
if (phase === "confirm" && name === "y") go()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
if (!site) return null
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<box
|
|
69
|
+
style={{
|
|
70
|
+
position: "absolute",
|
|
71
|
+
top: 0,
|
|
72
|
+
left: 0,
|
|
73
|
+
width: "100%",
|
|
74
|
+
height: "100%",
|
|
75
|
+
flexDirection: "column",
|
|
76
|
+
backgroundColor: theme.bg,
|
|
77
|
+
zIndex: 210,
|
|
78
|
+
}}
|
|
79
|
+
>
|
|
80
|
+
<box
|
|
81
|
+
style={{
|
|
82
|
+
flexDirection: "row",
|
|
83
|
+
height: 1,
|
|
84
|
+
backgroundColor: theme.bgAlt,
|
|
85
|
+
paddingLeft: 1,
|
|
86
|
+
paddingRight: 1,
|
|
87
|
+
alignItems: "center",
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
<text content="⏻ Site offline " fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
91
|
+
<text content={truncate(site.domain, 40)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
92
|
+
<box style={{ flexGrow: 1 }} />
|
|
93
|
+
{server && <text content={truncate(server.name, 30)} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />}
|
|
94
|
+
</box>
|
|
95
|
+
|
|
96
|
+
<Centered>{renderBody()}</Centered>
|
|
97
|
+
|
|
98
|
+
<StatusBar
|
|
99
|
+
hints={phase === "confirm" && mode !== "foreign" ? [{ key: "y", label: "confirm" }, { key: "esc", label: "cancel" }] : [{ key: "esc", label: "close" }]}
|
|
100
|
+
showGlobal={false}
|
|
101
|
+
/>
|
|
102
|
+
</box>
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
function renderBody() {
|
|
106
|
+
if (phase === "running" && run) return <OfflineProgress run={run} plural={false} />
|
|
107
|
+
|
|
108
|
+
if (mode === "foreign") {
|
|
109
|
+
return (
|
|
110
|
+
<Panel title=" Already offline " active>
|
|
111
|
+
<box style={{ flexDirection: "column", width: 62, paddingTop: 1, paddingBottom: 1 }}>
|
|
112
|
+
<text content={`${site!.domain} is disabled, but not by SpinupTUI, so it's left alone.`} fg={theme.text} />
|
|
113
|
+
<box style={{ height: 1 }} />
|
|
114
|
+
<text content="Re-enable it in SpinupWP if that's what you want (w opens the site there from the list)." fg={theme.textDim} />
|
|
115
|
+
<box style={{ height: 1 }} />
|
|
116
|
+
<text content="Esc to close" fg={theme.textFaint} wrapMode="none" />
|
|
117
|
+
</box>
|
|
118
|
+
</Panel>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (mode === "online") {
|
|
123
|
+
return (
|
|
124
|
+
<Panel title=" Confirm " active>
|
|
125
|
+
<box style={{ flexDirection: "column", width: 62, paddingTop: 1, paddingBottom: 1 }}>
|
|
126
|
+
<box style={{ flexDirection: "row" }}>
|
|
127
|
+
<text content="Bring " fg={theme.text} wrapMode="none" />
|
|
128
|
+
<text content={truncate(site!.domain, 36)} fg={theme.accent} wrapMode="none" />
|
|
129
|
+
<text content=" back online?" fg={theme.text} wrapMode="none" />
|
|
130
|
+
</box>
|
|
131
|
+
<box style={{ height: 1 }} />
|
|
132
|
+
<text content="It serves traffic again exactly as before. Other sites on the server stay as they are." fg={theme.textDim} />
|
|
133
|
+
<box style={{ height: 1 }} />
|
|
134
|
+
<text content="Press y to confirm · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
135
|
+
</box>
|
|
136
|
+
</Panel>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const n = neighbours.length
|
|
141
|
+
return (
|
|
142
|
+
<Panel title=" Confirm " active>
|
|
143
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
144
|
+
<box style={{ flexDirection: "row" }}>
|
|
145
|
+
<text content="Take " fg={theme.text} wrapMode="none" />
|
|
146
|
+
<text content={truncate(site!.domain, 40)} fg={theme.accent} wrapMode="none" />
|
|
147
|
+
<text content=" offline?" fg={theme.text} wrapMode="none" />
|
|
148
|
+
</box>
|
|
149
|
+
<box style={{ height: 1 }} />
|
|
150
|
+
<text content="nginx stops answering for its domains: visitors can't connect and no request reaches PHP through this site." fg={theme.warn} />
|
|
151
|
+
<text content="Files, database and certificate are untouched; O again brings it back." fg={theme.textDim} />
|
|
152
|
+
<box style={{ height: 1 }} />
|
|
153
|
+
{n > 0 ? (
|
|
154
|
+
<>
|
|
155
|
+
<text
|
|
156
|
+
content={`${n} other site${n === 1 ? "" : "s"} on ${server?.name ?? "this server"} stay${n === 1 ? "s" : ""} up, so the server's PHP is still reachable through ${n === 1 ? "it" : "them"}.`}
|
|
157
|
+
fg={theme.textDim}
|
|
158
|
+
/>
|
|
159
|
+
<text content="To protect the whole server, use a on the server instead." fg={theme.textDim} />
|
|
160
|
+
</>
|
|
161
|
+
) : (
|
|
162
|
+
<text content="It's the only site still up on this server." fg={theme.textDim} />
|
|
163
|
+
)}
|
|
164
|
+
{kumaConfigured && <text content="Its Uptime Kuma monitors enter a maintenance window — no false alerts." fg={theme.textFaint} />}
|
|
165
|
+
<box style={{ height: 1 }} />
|
|
166
|
+
<text content="Press y to confirm · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
167
|
+
</box>
|
|
168
|
+
</Panel>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
package/src/ui/views/Stacks.tsx
CHANGED
|
@@ -75,7 +75,7 @@ function nonWpSub(kind: ProbeKind | null, vanity: boolean): NonWpSub {
|
|
|
75
75
|
export function Stacks({ rows }: { rows: number }) {
|
|
76
76
|
const store = useStore()
|
|
77
77
|
const wpCoreJobs = useWpCoreJobs()
|
|
78
|
-
const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, accountSlug, setPhpUpgradeSite, setBulkWpGroup, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
|
|
78
|
+
const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, accountSlug, setPhpUpgradeSite, setBulkWpGroup, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
|
|
79
79
|
store
|
|
80
80
|
|
|
81
81
|
const [groupIndex, setGroupIndex] = useState(0)
|
|
@@ -254,6 +254,10 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
254
254
|
// Purge page cache + object cache on the selected site (sites pane only).
|
|
255
255
|
if (focus === "sites" && groupSites[siteIndex]) setPurgeCacheSite(groupSites[siteIndex])
|
|
256
256
|
return
|
|
257
|
+
case "O":
|
|
258
|
+
// Take the selected site offline / bring it back (sites pane only).
|
|
259
|
+
if (focus === "sites" && groupSites[siteIndex]) setSiteOfflineSite(groupSites[siteIndex])
|
|
260
|
+
return
|
|
257
261
|
case "M":
|
|
258
262
|
// Site monitoring (Uptime Kuma) — capital to match Search, where
|
|
259
263
|
// lowercase m means media fallback.
|