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.
Files changed (44) hide show
  1. package/CHANGELOG.md +10 -10
  2. package/README.md +5 -10
  3. package/package.json +1 -1
  4. package/src/api/client.ts +15 -0
  5. package/src/api/types.ts +3 -0
  6. package/src/config.ts +0 -23
  7. package/src/dev/mockClient.ts +30 -7
  8. package/src/index.tsx +17 -16
  9. package/src/lib/appUpdate.ts +7 -33
  10. package/src/lib/cliOffline.ts +418 -0
  11. package/src/lib/offline.test.ts +181 -0
  12. package/src/lib/offline.ts +422 -0
  13. package/src/lib/spinupweb.test.ts +22 -0
  14. package/src/lib/spinupweb.ts +11 -0
  15. package/src/lib/stackCache.test.ts +43 -0
  16. package/src/lib/stackCache.ts +35 -1
  17. package/src/ui/App.tsx +12 -10
  18. package/src/ui/Details.tsx +8 -2
  19. package/src/ui/Explain.tsx +2 -15
  20. package/src/ui/Header.tsx +0 -2
  21. package/src/ui/Help.tsx +3 -2
  22. package/src/ui/Onboarding.tsx +71 -3
  23. package/src/ui/components.tsx +2 -1
  24. package/src/ui/offlineRun.tsx +82 -0
  25. package/src/ui/store.tsx +76 -10
  26. package/src/ui/views/AccountSlugPrompt.tsx +102 -0
  27. package/src/ui/views/Browser.tsx +13 -5
  28. package/src/ui/views/Dashboard.tsx +3 -1
  29. package/src/ui/views/Search.tsx +8 -6
  30. package/src/ui/views/ServerActions.tsx +108 -11
  31. package/src/ui/views/SiteOffline.tsx +171 -0
  32. package/src/ui/views/Stacks.tsx +26 -9
  33. package/src/lib/appUpdate.test.ts +0 -26
  34. package/src/lib/beacon.test.ts +0 -140
  35. package/src/lib/beacon.ts +0 -349
  36. package/src/lib/beaconSetup.test.ts +0 -65
  37. package/src/lib/beaconSetup.ts +0 -143
  38. package/src/lib/cliBeacon.ts +0 -290
  39. package/src/lib/prompt.ts +0 -47
  40. package/src/modules/beacon.ts +0 -51
  41. package/src/modules/contract.ts +0 -61
  42. package/src/modules/index.ts +0 -34
  43. package/src/ui/views/BeaconSetup.tsx +0 -268
  44. 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
@@ -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, 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 = (url: string) => {
105
- openUrl(url)
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, accountSlug))
145
- else if (current?.kind === "server") openInSpinup(serverWebUrl(current.server.id, accountSlug))
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 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"
@@ -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
- accountSlug,
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
- setTarget(ACTIONS[index])
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
- openUrl(serverWebUrl(server.id, accountSlug))
119
- toast.success(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
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
+ }
@@ -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, accountSlug, setPhpUpgradeSite, setBulkWpGroup, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
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
- openUrl(siteWebUrl(groupSites[siteIndex].id, accountSlug))
222
- flashMsg(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
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
- <text
421
- content={truncate(cached.result.label, 20) + (isProbeStale(s) ? "*" : "")}
422
- fg={probeKindColor(cached.result.kind, selected)}
423
- wrapMode="none"
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
- })