spinuptui 1.2.0-alpha.1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -10
- package/README.md +5 -10
- package/package.json +1 -1
- package/src/api/client.ts +15 -0
- package/src/api/types.ts +3 -0
- package/src/config.ts +0 -23
- package/src/dev/mockClient.ts +30 -7
- package/src/index.tsx +17 -16
- package/src/lib/appUpdate.ts +7 -33
- package/src/lib/cliOffline.ts +418 -0
- package/src/lib/offline.test.ts +181 -0
- package/src/lib/offline.ts +422 -0
- package/src/lib/spinupweb.test.ts +22 -0
- package/src/lib/spinupweb.ts +11 -0
- package/src/lib/stackCache.test.ts +43 -0
- package/src/lib/stackCache.ts +35 -1
- package/src/ui/App.tsx +12 -10
- package/src/ui/Details.tsx +8 -2
- package/src/ui/Explain.tsx +2 -15
- package/src/ui/Header.tsx +0 -2
- package/src/ui/Help.tsx +3 -2
- package/src/ui/Onboarding.tsx +71 -3
- package/src/ui/components.tsx +2 -1
- package/src/ui/offlineRun.tsx +82 -0
- package/src/ui/store.tsx +76 -10
- package/src/ui/views/AccountSlugPrompt.tsx +102 -0
- package/src/ui/views/Browser.tsx +13 -5
- package/src/ui/views/Dashboard.tsx +3 -1
- package/src/ui/views/Search.tsx +8 -6
- package/src/ui/views/ServerActions.tsx +108 -11
- package/src/ui/views/SiteOffline.tsx +171 -0
- package/src/ui/views/Stacks.tsx +26 -9
- package/src/lib/appUpdate.test.ts +0 -26
- package/src/lib/beacon.test.ts +0 -140
- package/src/lib/beacon.ts +0 -349
- package/src/lib/beaconSetup.test.ts +0 -65
- package/src/lib/beaconSetup.ts +0 -143
- package/src/lib/cliBeacon.ts +0 -290
- package/src/lib/prompt.ts +0 -47
- package/src/modules/beacon.ts +0 -51
- package/src/modules/contract.ts +0 -61
- package/src/modules/index.ts +0 -34
- package/src/ui/views/BeaconSetup.tsx +0 -268
- package/src/ui/views/Modules.tsx +0 -196
|
@@ -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,
|
|
39
|
+
const { servers, sites, serverById, sitesForServer, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setServerActionsServer, openInSpinupWP, 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
|
|
@@ -101,9 +101,8 @@ export function Search({ rows }: { rows: number }) {
|
|
|
101
101
|
openUrl((s.https?.enabled ? "https://" : "http://") + s.domain)
|
|
102
102
|
flashMsg(`Opening ${s.domain}…`)
|
|
103
103
|
}
|
|
104
|
-
const openInSpinup = (
|
|
105
|
-
|
|
106
|
-
flashMsg(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
|
|
104
|
+
const openInSpinup = (build: (slug: string) => string) => {
|
|
105
|
+
if (openInSpinupWP(build)) flashMsg("Opening in SpinupWP…")
|
|
107
106
|
}
|
|
108
107
|
|
|
109
108
|
useKeyboard((key) => {
|
|
@@ -141,8 +140,8 @@ export function Search({ rows }: { rows: number }) {
|
|
|
141
140
|
if (current?.kind === "site") openSite(current.site)
|
|
142
141
|
return
|
|
143
142
|
case "w":
|
|
144
|
-
if (current?.kind === "site") openInSpinup(siteWebUrl(current.site.id,
|
|
145
|
-
else if (current?.kind === "server") openInSpinup(serverWebUrl(current.server.id,
|
|
143
|
+
if (current?.kind === "site") openInSpinup((slug) => siteWebUrl(current.site.id, slug))
|
|
144
|
+
else if (current?.kind === "server") openInSpinup((slug) => serverWebUrl(current.server.id, slug))
|
|
146
145
|
return
|
|
147
146
|
case "u":
|
|
148
147
|
if (current?.kind === "site") setPhpUpgradeSite(current.site)
|
|
@@ -153,6 +152,9 @@ export function Search({ rows }: { rows: number }) {
|
|
|
153
152
|
case "P":
|
|
154
153
|
if (current?.kind === "site") setPurgeCacheSite(current.site)
|
|
155
154
|
return
|
|
155
|
+
case "O":
|
|
156
|
+
if (current?.kind === "site") setSiteOfflineSite(current.site)
|
|
157
|
+
return
|
|
156
158
|
case "t":
|
|
157
159
|
if (current?.kind === "site") flashMsg(openLocalTerminal(current.site.id))
|
|
158
160
|
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"
|
|
@@ -15,13 +19,16 @@ import { Panel, Spinner, Centered } from "../components.tsx"
|
|
|
15
19
|
import { StatusBar } from "../StatusBar.tsx"
|
|
16
20
|
import { useStore } from "../store.tsx"
|
|
17
21
|
import { moveSelection } from "../List.tsx"
|
|
18
|
-
import { openUrl } from "../../lib/open.ts"
|
|
19
22
|
import { serverWebUrl } from "../../lib/spinupweb.ts"
|
|
20
23
|
import { toast } from "../toast.ts"
|
|
21
24
|
import type { RebootInfo } from "../../lib/ssh.ts"
|
|
22
25
|
import type { ServerOpKind } from "../store.tsx"
|
|
26
|
+
import { loadConfig } from "../../config.ts"
|
|
27
|
+
import { isDevMode } from "../../dev/devMode.ts"
|
|
28
|
+
import { loadOfflineState, takeServerOffline, bringServerOnline } from "../../lib/offline.ts"
|
|
29
|
+
import { offlineDir, useOfflineRun, OfflineProgress } from "../offlineRun.tsx"
|
|
23
30
|
|
|
24
|
-
type Phase = "pick" | "confirm" | "tracking" | "done" | "error"
|
|
31
|
+
type Phase = "pick" | "confirm" | "tracking" | "done" | "error" | "offline"
|
|
25
32
|
|
|
26
33
|
interface Action {
|
|
27
34
|
kind: ServerOpKind
|
|
@@ -59,7 +66,8 @@ export function ServerActions() {
|
|
|
59
66
|
rebootInfoLoading,
|
|
60
67
|
rebootInfoErrors,
|
|
61
68
|
loadRebootInfo,
|
|
62
|
-
|
|
69
|
+
openInSpinupWP,
|
|
70
|
+
refresh,
|
|
63
71
|
} = store
|
|
64
72
|
|
|
65
73
|
const rebootRequired = !!server?.reboot_required
|
|
@@ -68,6 +76,18 @@ export function ServerActions() {
|
|
|
68
76
|
// Cursor starts on Reboot when one is pending, else on the first restart.
|
|
69
77
|
const [index, setIndex] = useState(() => (server?.reboot_required ? 0 : 1))
|
|
70
78
|
const [target, setTarget] = useState<Action | null>(null)
|
|
79
|
+
const { run, start } = useOfflineRun(refresh)
|
|
80
|
+
|
|
81
|
+
// The offline row sits after the service actions. It offers to bring sites
|
|
82
|
+
// back when SpinupTUI took this server offline, and to take it offline
|
|
83
|
+
// otherwise.
|
|
84
|
+
const OFFLINE_INDEX = ACTIONS.length
|
|
85
|
+
const offlineRecord = server ? loadOfflineState(offlineDir()).servers[String(server.id)] : undefined
|
|
86
|
+
const offlineMode: "offline" | "online" = offlineRecord ? "online" : "offline"
|
|
87
|
+
const liveCount = server ? sitesForServer(server.id).filter((s) => !s.disabled_at).length : 0
|
|
88
|
+
const offlineLabel = offlineRecord
|
|
89
|
+
? `Bring ${offlineRecord.sites.length} site${offlineRecord.sites.length === 1 ? "" : "s"} back online`
|
|
90
|
+
: `Take all ${liveCount} site${liveCount === 1 ? "" : "s"} offline`
|
|
71
91
|
|
|
72
92
|
const siteCount = server ? sitesForServer(server.id).length : 0
|
|
73
93
|
const info = server ? rebootInfo.get(server.id) : undefined
|
|
@@ -90,6 +110,27 @@ export function ServerActions() {
|
|
|
90
110
|
? "error"
|
|
91
111
|
: "tracking"
|
|
92
112
|
|
|
113
|
+
const startOffline = () => {
|
|
114
|
+
if (!server) return
|
|
115
|
+
setPhase("offline")
|
|
116
|
+
const cfg = loadConfig()
|
|
117
|
+
start(offlineMode, server.name, async (onProgress) => {
|
|
118
|
+
const o = { onProgress, kuma: !isDevMode(), dir: offlineDir() }
|
|
119
|
+
if (offlineMode === "offline") {
|
|
120
|
+
const r = await takeServerOffline(store.client, cfg, server, o)
|
|
121
|
+
return {
|
|
122
|
+
failed: r.failed.length > 0,
|
|
123
|
+
summary: `${r.disabled.length} taken offline${r.failed.length ? `, ${r.failed.length} failed` : ""}${r.kuma === "shielded" ? " · Kuma monitors in maintenance" : ""}`,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const r = await bringServerOnline(store.client, cfg, server.id, o)
|
|
127
|
+
return {
|
|
128
|
+
failed: r.failed.length > 0,
|
|
129
|
+
summary: `${r.enabled.length} back online${r.alreadyOnline.length ? `, ${r.alreadyOnline.length} already were` : ""}${r.failed.length ? `, ${r.failed.length} failed — run it again` : ""}`,
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
93
134
|
const close = () => {
|
|
94
135
|
if (server && progress?.status === "failed") clearServerOp(server.id)
|
|
95
136
|
setServerActionsServer(null)
|
|
@@ -103,20 +144,27 @@ export function ServerActions() {
|
|
|
103
144
|
switch (name) {
|
|
104
145
|
case "up":
|
|
105
146
|
case "k":
|
|
106
|
-
return setIndex((i) => moveSelection(i, -1, ACTIONS.length))
|
|
147
|
+
return setIndex((i) => moveSelection(i, -1, ACTIONS.length + 1))
|
|
107
148
|
case "down":
|
|
108
149
|
case "j":
|
|
109
|
-
return setIndex((i) => moveSelection(i, 1, ACTIONS.length))
|
|
150
|
+
return setIndex((i) => moveSelection(i, 1, ACTIONS.length + 1))
|
|
110
151
|
case "return":
|
|
111
152
|
case "right":
|
|
112
153
|
case "l":
|
|
113
|
-
|
|
154
|
+
if (index === OFFLINE_INDEX && offlineMode === "offline" && liveCount === 0) {
|
|
155
|
+
toast.error("Every site on this server is already offline.")
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
setTarget(index === OFFLINE_INDEX ? null : ACTIONS[index]!)
|
|
114
159
|
setPhase("confirm")
|
|
115
160
|
return
|
|
116
161
|
case "w":
|
|
117
162
|
if (upgradeRequired && server) {
|
|
118
|
-
|
|
119
|
-
toast.success(
|
|
163
|
+
const id = server.id
|
|
164
|
+
if (openInSpinupWP((slug) => serverWebUrl(id, slug))) toast.success("Opening in SpinupWP…")
|
|
165
|
+
// No slug yet: the prompt takes over — close this overlay so its
|
|
166
|
+
// keys don't fire underneath.
|
|
167
|
+
else close()
|
|
120
168
|
}
|
|
121
169
|
return
|
|
122
170
|
}
|
|
@@ -125,6 +173,7 @@ export function ServerActions() {
|
|
|
125
173
|
|
|
126
174
|
if (dp === "confirm") {
|
|
127
175
|
if (name === "y") {
|
|
176
|
+
if (!target) return startOffline()
|
|
128
177
|
if (server && target) {
|
|
129
178
|
startServerOp(server, target.kind, target.verb)
|
|
130
179
|
setPhase("tracking")
|
|
@@ -222,7 +271,7 @@ export function ServerActions() {
|
|
|
222
271
|
<box style={{ height: 1 }} />
|
|
223
272
|
</>
|
|
224
273
|
)}
|
|
225
|
-
{ACTIONS.map((a, i) => {
|
|
274
|
+
{[...ACTIONS.map((a, i) => {
|
|
226
275
|
const selected = i === index
|
|
227
276
|
const recommended = a.kind === "reboot" && rebootRequired
|
|
228
277
|
const fg = selected ? theme.text : recommended ? theme.warn : theme.textDim
|
|
@@ -236,12 +285,60 @@ export function ServerActions() {
|
|
|
236
285
|
{recommended && <text content="(recommended)" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />}
|
|
237
286
|
</box>
|
|
238
287
|
)
|
|
239
|
-
})
|
|
288
|
+
}),
|
|
289
|
+
<box key="offline" style={{ flexDirection: "row", height: 1, backgroundColor: index === OFFLINE_INDEX ? theme.selectedBg : undefined }}>
|
|
290
|
+
<text
|
|
291
|
+
content={(index === OFFLINE_INDEX ? "❯ " : " ") + "⏻ " + offlineLabel}
|
|
292
|
+
fg={index === OFFLINE_INDEX ? theme.text : offlineRecord ? theme.warn : theme.textDim}
|
|
293
|
+
style={{ flexGrow: 1 }}
|
|
294
|
+
wrapMode="none"
|
|
295
|
+
/>
|
|
296
|
+
</box>]}
|
|
240
297
|
</box>
|
|
241
298
|
</Panel>
|
|
242
299
|
)
|
|
243
300
|
}
|
|
244
301
|
|
|
302
|
+
if (dp === "confirm" && !target) {
|
|
303
|
+
const domains = offlineRecord
|
|
304
|
+
? offlineRecord.sites.map((s) => s.domain)
|
|
305
|
+
: sitesForServer(server!.id).filter((s) => !s.disabled_at).map((s) => s.domain)
|
|
306
|
+
const skipped = offlineRecord ? 0 : siteCount - liveCount
|
|
307
|
+
return (
|
|
308
|
+
<Panel title=" Confirm " active>
|
|
309
|
+
<box style={{ flexDirection: "column", width: 66, paddingTop: 1, paddingBottom: 1 }}>
|
|
310
|
+
<box style={{ flexDirection: "row" }}>
|
|
311
|
+
<text content={offlineRecord ? "Bring sites back online on " : "Take every site offline on "} fg={theme.text} wrapMode="none" />
|
|
312
|
+
<text content={truncate(server!.name, 30)} fg={theme.accent} wrapMode="none" />
|
|
313
|
+
<text content="?" fg={theme.text} />
|
|
314
|
+
</box>
|
|
315
|
+
<box style={{ height: 1 }} />
|
|
316
|
+
{offlineRecord ? (
|
|
317
|
+
<text content="Re-enables only the sites taken offline here; anything disabled another way stays as it is." fg={theme.textDim} />
|
|
318
|
+
) : (
|
|
319
|
+
<>
|
|
320
|
+
<text content="nginx stops answering for these domains: visitors can't connect and no request reaches PHP." fg={theme.warn} />
|
|
321
|
+
<text content="Files, databases and certificates are untouched; bring them back from this menu." fg={theme.textDim} />
|
|
322
|
+
</>
|
|
323
|
+
)}
|
|
324
|
+
<box style={{ height: 1 }} />
|
|
325
|
+
{domains.slice(0, 8).map((d) => (
|
|
326
|
+
<text key={d} content={` • ${d}`} fg={theme.textFaint} wrapMode="none" />
|
|
327
|
+
))}
|
|
328
|
+
{domains.length > 8 && <text content={` • …${domains.length - 8} more`} fg={theme.textFaint} wrapMode="none" />}
|
|
329
|
+
{skipped > 0 && <text content={` ${skipped} already offline — left alone`} fg={theme.textFaint} wrapMode="none" />}
|
|
330
|
+
{store.kumaConfigured && (
|
|
331
|
+
<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" />
|
|
332
|
+
)}
|
|
333
|
+
<box style={{ height: 1 }} />
|
|
334
|
+
<text content="Press y to confirm · ← to go back · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
335
|
+
</box>
|
|
336
|
+
</Panel>
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (dp === "offline" && run) return <OfflineProgress run={run} plural />
|
|
341
|
+
|
|
245
342
|
if (dp === "confirm") {
|
|
246
343
|
const isReboot = target?.kind === "reboot"
|
|
247
344
|
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
|
@@ -16,7 +16,8 @@
|
|
|
16
16
|
import { useEffect, useMemo, useRef, useState } from "react"
|
|
17
17
|
import { useKeyboard } from "@opentui/react"
|
|
18
18
|
import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
19
|
-
import { bar, truncate } from "../../lib/format.ts"
|
|
19
|
+
import { bar, timeAgo, truncate } from "../../lib/format.ts"
|
|
20
|
+
import { isVersionOld } from "../../lib/stackCache.ts"
|
|
20
21
|
import { STACKS, effectiveStack, stackColor, type Stack } from "../../lib/stack.ts"
|
|
21
22
|
import { phpSortKey } from "../../lib/phpEol.ts"
|
|
22
23
|
import { probeKindColor, type ProbeKind } from "../../lib/probe.ts"
|
|
@@ -75,7 +76,7 @@ function nonWpSub(kind: ProbeKind | null, vanity: boolean): NonWpSub {
|
|
|
75
76
|
export function Stacks({ rows }: { rows: number }) {
|
|
76
77
|
const store = useStore()
|
|
77
78
|
const wpCoreJobs = useWpCoreJobs()
|
|
78
|
-
const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol,
|
|
79
|
+
const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, openInSpinupWP, setPhpUpgradeSite, setBulkWpGroup, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setSiteOfflineSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
|
|
79
80
|
store
|
|
80
81
|
|
|
81
82
|
const [groupIndex, setGroupIndex] = useState(0)
|
|
@@ -218,8 +219,8 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
218
219
|
case "w":
|
|
219
220
|
// Open the selected site in the SpinupWP web app.
|
|
220
221
|
if (focus === "sites" && groupSites[siteIndex]) {
|
|
221
|
-
|
|
222
|
-
|
|
222
|
+
const id = groupSites[siteIndex].id
|
|
223
|
+
if (openInSpinupWP((slug) => siteWebUrl(id, slug))) flashMsg("Opening in SpinupWP…")
|
|
223
224
|
}
|
|
224
225
|
return
|
|
225
226
|
case "d":
|
|
@@ -254,6 +255,10 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
254
255
|
// Purge page cache + object cache on the selected site (sites pane only).
|
|
255
256
|
if (focus === "sites" && groupSites[siteIndex]) setPurgeCacheSite(groupSites[siteIndex])
|
|
256
257
|
return
|
|
258
|
+
case "O":
|
|
259
|
+
// Take the selected site offline / bring it back (sites pane only).
|
|
260
|
+
if (focus === "sites" && groupSites[siteIndex]) setSiteOfflineSite(groupSites[siteIndex])
|
|
261
|
+
return
|
|
257
262
|
case "M":
|
|
258
263
|
// Site monitoring (Uptime Kuma) — capital to match Search, where
|
|
259
264
|
// lowercase m means media fallback.
|
|
@@ -417,11 +422,23 @@ export function Stacks({ rows }: { rows: number }) {
|
|
|
417
422
|
) : vanity ? (
|
|
418
423
|
<text content="Vanity Site" fg={selected ? theme.text : VANITY_COLOR} wrapMode="none" />
|
|
419
424
|
) : cached ? (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
+
<>
|
|
426
|
+
<text
|
|
427
|
+
content={truncate(cached.result.label, 20) + (isProbeStale(s) ? "*" : "")}
|
|
428
|
+
fg={probeKindColor(cached.result.kind, selected)}
|
|
429
|
+
wrapMode="none"
|
|
430
|
+
/>
|
|
431
|
+
{/* A version past its re-check age (the background re-probe
|
|
432
|
+
failed or hasn't run) says how old it is, so it can't
|
|
433
|
+
pass for current. */}
|
|
434
|
+
{isVersionOld(cached) && (
|
|
435
|
+
<text
|
|
436
|
+
content={" " + timeAgo(new Date(cached.detectedAt).toISOString()).replace(/ ago$/, "")}
|
|
437
|
+
fg={selected ? theme.text : theme.warn}
|
|
438
|
+
wrapMode="none"
|
|
439
|
+
/>
|
|
440
|
+
)}
|
|
441
|
+
</>
|
|
425
442
|
) : errored ? (
|
|
426
443
|
<text content="probe failed" fg={selected ? theme.text : theme.bad} wrapMode="none" />
|
|
427
444
|
) : (
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test"
|
|
2
|
-
import { isNewer } from "./appUpdate.ts"
|
|
3
|
-
|
|
4
|
-
test("plain versions", () => {
|
|
5
|
-
expect(isNewer("1.2.0", "1.1.2")).toBe(true)
|
|
6
|
-
expect(isNewer("1.1.2", "1.2.0")).toBe(false)
|
|
7
|
-
expect(isNewer("1.10.0", "1.9.9")).toBe(true)
|
|
8
|
-
expect(isNewer("1.2.0", "1.2.0")).toBe(false)
|
|
9
|
-
})
|
|
10
|
-
|
|
11
|
-
test("a release outranks its pre-releases", () => {
|
|
12
|
-
expect(isNewer("1.2.0", "1.2.0-alpha.1")).toBe(true)
|
|
13
|
-
expect(isNewer("1.2.0-alpha.1", "1.2.0")).toBe(false)
|
|
14
|
-
expect(isNewer("1.2.0-alpha.1", "1.1.2")).toBe(true)
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
test("pre-release ordering", () => {
|
|
18
|
-
expect(isNewer("1.2.0-alpha.2", "1.2.0-alpha.1")).toBe(true)
|
|
19
|
-
expect(isNewer("1.2.0-alpha.10", "1.2.0-alpha.9")).toBe(true)
|
|
20
|
-
expect(isNewer("1.2.0-beta.1", "1.2.0-alpha.5")).toBe(true)
|
|
21
|
-
expect(isNewer("1.2.0-alpha.1", "1.2.0-alpha")).toBe(true)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
test("unparseable never nags", () => {
|
|
25
|
-
expect(isNewer("garbage", "1.0.0")).toBe(false)
|
|
26
|
-
})
|