spinuptui 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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,
@@ -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
@@ -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 or restart a single service.
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
- setTarget(ACTIONS[index])
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
+ }
@@ -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.