spinuptui 0.19.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. package/src/version.ts +8 -0
package/src/ui/App.tsx ADDED
@@ -0,0 +1,265 @@
1
+ // Application shell: splash gating, global key routing, header/content/status layout.
2
+
3
+ import { useEffect, useState } from "react"
4
+ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
5
+ import { Toaster } from "@opentui-ui/toast/react"
6
+ import { theme } from "../lib/theme.ts"
7
+ import { useStore } from "./store.tsx"
8
+ import { Splash } from "./Splash.tsx"
9
+ import { Header } from "./Header.tsx"
10
+ import { HelpOverlay } from "./Help.tsx"
11
+ import { ExplainOverlay } from "./Explain.tsx"
12
+ import { Dashboard } from "./views/Dashboard.tsx"
13
+ import { Browser } from "./views/Browser.tsx"
14
+ import { Stacks } from "./views/Stacks.tsx"
15
+ import { Search } from "./views/Search.tsx"
16
+ import { Events } from "./views/Events.tsx"
17
+ import { Health } from "./views/Health.tsx"
18
+ import { PhpUpgrade } from "./views/PhpUpgrade.tsx"
19
+ import { HttpsToggle } from "./views/HttpsToggle.tsx"
20
+ import { PurgeCache } from "./views/PurgeCache.tsx"
21
+ import { ReleaseNotes } from "./views/ReleaseNotes.tsx"
22
+ import { GrantKey } from "./views/GrantKey.tsx"
23
+ import { SudoConnect } from "./views/SudoConnect.tsx"
24
+ import { DbBackup } from "./views/DbBackup.tsx"
25
+ import { DbSync } from "./views/DbSync.tsx"
26
+ import { MediaFallback } from "./views/MediaFallback.tsx"
27
+ import { ServerActions } from "./views/ServerActions.tsx"
28
+ import { NewServer } from "./views/NewServer.tsx"
29
+ import { VanityNewSite } from "./views/VanityNewSite.tsx"
30
+ import { CloneWizard } from "./views/CloneWizard.tsx"
31
+ import { LocalLinkOverlay } from "./views/LocalLink.tsx"
32
+ import { Discover } from "./views/Discover.tsx"
33
+ import { Forgotten } from "./views/Forgotten.tsx"
34
+ import { DnsInventory } from "./views/DnsInventory.tsx"
35
+ import { WpInventory } from "./views/WpInventory.tsx"
36
+ import { ProviderConnect } from "./views/ProviderConnect.tsx"
37
+ import { DnsRecords } from "./views/DnsRecords.tsx"
38
+ import { KumaSite } from "./views/KumaSite.tsx"
39
+
40
+ const MIN_SPLASH_MS = 1200
41
+
42
+ export function App() {
43
+ const store = useStore()
44
+ const renderer = useRenderer()
45
+ const { height } = useTerminalDimensions()
46
+ const [splashDone, setSplashDone] = useState(false)
47
+ const [showHelp, setShowHelp] = useState(false)
48
+ const [showExplain, setShowExplain] = useState(false)
49
+
50
+ // Enforce a minimum splash duration so the intro is visible even on fast loads.
51
+ useEffect(() => {
52
+ const id = setTimeout(() => setSplashDone(true), MIN_SPLASH_MS)
53
+ return () => clearTimeout(id)
54
+ }, [])
55
+
56
+ // Keep views aware that a modal is open so they pause their own key handling.
57
+ const overlayActive =
58
+ showHelp ||
59
+ showExplain ||
60
+ store.releaseNotesInfo !== null ||
61
+ store.healthServer !== null ||
62
+ store.wpInventorySite !== null ||
63
+ store.phpUpgradeSite !== null ||
64
+ store.httpsToggleSite !== null ||
65
+ store.purgeCacheSite !== null ||
66
+ store.grantKeySite !== null ||
67
+ store.sudoConnectServer !== null ||
68
+ store.dbBackupSite !== null ||
69
+ store.dbSyncSite !== null ||
70
+ store.mediaFallbackSite !== null ||
71
+ store.serverActionsServer !== null ||
72
+ store.newServerOpen ||
73
+ store.vanityServer !== null ||
74
+ store.cloneServer !== null ||
75
+ store.localLinkSite !== null ||
76
+ store.discoverOpen ||
77
+ store.forgottenOpen ||
78
+ store.dnsInventoryServer !== null ||
79
+ store.connectZoneTarget !== null ||
80
+ store.dnsRecordsTarget !== null ||
81
+ store.kumaSite !== null
82
+ useEffect(() => {
83
+ store.setOverlayOpen(overlayActive)
84
+ }, [overlayActive, store])
85
+
86
+ function quit() {
87
+ try {
88
+ renderer.destroy?.()
89
+ } catch {
90
+ // ignore — we're exiting anyway
91
+ }
92
+ process.exit(0)
93
+ }
94
+
95
+ useKeyboard((key) => {
96
+ // Ctrl+C always quits, even while typing.
97
+ if (key.ctrl && key.name === "c") return quit()
98
+
99
+ // Help/Explain must close even while a text field is focused (they can open
100
+ // a beat BEFORE a view's input grabs focus — e.g. `4` then typing a name
101
+ // containing `i` faster than Search mounts — and the inputMode gate below
102
+ // would otherwise deadlock them open, since the overlay also deactivates the
103
+ // view's own handler).
104
+ if (showHelp) {
105
+ if (key.name === "escape" || key.name === "q" || key.name === "?") setShowHelp(false)
106
+ return
107
+ }
108
+ if (showExplain) {
109
+ if (key.name === "escape" || key.name === "q" || key.name === "i") setShowExplain(false)
110
+ return
111
+ }
112
+
113
+ // While a text field is focused, let it consume everything else.
114
+ if (store.inputMode) return
115
+
116
+ // The release-notes overlay owns the keyboard while open (any key dismisses).
117
+ if (store.releaseNotesInfo) return
118
+
119
+ // The health overlay owns the keyboard while open (it handles Esc/q/r/h).
120
+ if (store.healthServer) return
121
+
122
+ // The plugins/themes overlay owns the keyboard while open.
123
+ if (store.wpInventorySite) return
124
+
125
+ // The PHP-upgrade overlay owns the keyboard while open.
126
+ if (store.phpUpgradeSite) return
127
+
128
+ // The HTTPS-toggle overlay owns the keyboard while open.
129
+ if (store.httpsToggleSite) return
130
+
131
+ // The purge-cache overlay owns the keyboard while open.
132
+ if (store.purgeCacheSite) return
133
+
134
+ // The grant-SSH-key overlay owns the keyboard while open.
135
+ if (store.grantKeySite) return
136
+
137
+ // The connect-sudo overlay owns the keyboard while open.
138
+ if (store.sudoConnectServer) return
139
+
140
+ // The DB-backup overlay owns the keyboard while open.
141
+ if (store.dbBackupSite) return
142
+
143
+ // The DB-sync overlay owns the keyboard while open.
144
+ if (store.dbSyncSite) return
145
+
146
+ // The media-fallback overlay owns the keyboard while open.
147
+ if (store.mediaFallbackSite) return
148
+
149
+ // The server-actions overlay owns the keyboard while open.
150
+ if (store.serverActionsServer) return
151
+
152
+ // The new-server overlay owns the keyboard while open.
153
+ if (store.newServerOpen) return
154
+
155
+ // The vanity-site overlay owns the keyboard while open.
156
+ if (store.vanityServer) return
157
+
158
+ // The clone wizard owns the keyboard while open.
159
+ if (store.cloneServer) return
160
+
161
+ // The local-link overlay owns the keyboard while open.
162
+ if (store.localLinkSite) return
163
+
164
+ // The discovery overlay owns the keyboard while open.
165
+ if (store.discoverOpen) return
166
+
167
+ // The "needs a local copy" report owns the keyboard while open.
168
+ if (store.forgottenOpen) return
169
+
170
+ // The DNS inventory overlay owns the keyboard while open.
171
+ if (store.dnsInventoryServer) return
172
+
173
+ // The provider-connect overlay owns the keyboard while open.
174
+ if (store.connectZoneTarget) return
175
+
176
+ // The DNS-records overlay owns the keyboard while open.
177
+ if (store.dnsRecordsTarget) return
178
+
179
+ // The site-monitoring (Uptime Kuma) overlay owns the keyboard while open.
180
+ if (store.kumaSite) return
181
+
182
+ switch (key.name) {
183
+ case "q":
184
+ return quit()
185
+ case "1":
186
+ return store.setRoute("dashboard")
187
+ case "2":
188
+ return store.setRoute("servers")
189
+ case "3":
190
+ return store.setRoute("stacks")
191
+ case "4":
192
+ return store.setRoute("search")
193
+ case "5":
194
+ return store.setRoute("events")
195
+ case "r":
196
+ return void store.refresh()
197
+ case "?":
198
+ return setShowHelp(true)
199
+ case "i":
200
+ return setShowExplain(true)
201
+ case "/":
202
+ return store.setRoute("search")
203
+ }
204
+ })
205
+
206
+ // Splash until the minimum time has elapsed AND the first data load resolved.
207
+ if (!splashDone || !store.ready) {
208
+ const status = store.error
209
+ ? "Trouble reaching SpinupWP — entering anyway…"
210
+ : store.loading
211
+ ? "Fetching your servers and sites…"
212
+ : "Ready."
213
+ return <Splash status={status} />
214
+ }
215
+
216
+ // Rows available to the content area: total minus the header (nav + subtitle =
217
+ // 2 rows) and the error banner, when present. Each view renders its own status bar.
218
+ const contentRows = Math.max(3, height - 2 - (store.error ? 1 : 0))
219
+
220
+ return (
221
+ <box style={{ width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg }}>
222
+ <Header />
223
+ {store.error && (
224
+ <box style={{ height: 1, backgroundColor: theme.bad, paddingLeft: 1, paddingRight: 1, flexDirection: "row" }}>
225
+ <text content={`⚠ ${store.error} — press r to retry`} fg={theme.bg} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
226
+ </box>
227
+ )}
228
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
229
+ {store.route === "dashboard" && <Dashboard rows={contentRows} />}
230
+ {store.route === "servers" && <Browser rows={contentRows} />}
231
+ {store.route === "stacks" && <Stacks rows={contentRows} />}
232
+ {store.route === "search" && <Search rows={contentRows} />}
233
+ {store.route === "events" && <Events rows={contentRows} />}
234
+ </box>
235
+ {showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
236
+ {showExplain && <ExplainOverlay route={store.route} />}
237
+ {store.releaseNotesInfo && <ReleaseNotes />}
238
+ {store.healthServer && <Health />}
239
+ {store.wpInventorySite && <WpInventory />}
240
+ {store.phpUpgradeSite && <PhpUpgrade />}
241
+ {store.httpsToggleSite && <HttpsToggle />}
242
+ {store.purgeCacheSite && <PurgeCache />}
243
+ {store.grantKeySite && <GrantKey />}
244
+ {store.sudoConnectServer && <SudoConnect />}
245
+ {store.dbBackupSite && <DbBackup />}
246
+ {store.dbSyncSite && <DbSync />}
247
+ {store.mediaFallbackSite && <MediaFallback />}
248
+ {store.serverActionsServer && <ServerActions />}
249
+ {store.newServerOpen && <NewServer />}
250
+ {store.vanityServer && <VanityNewSite />}
251
+ {store.cloneServer && <CloneWizard />}
252
+ {store.localLinkSite && <LocalLinkOverlay />}
253
+ {store.discoverOpen && <Discover />}
254
+ {store.forgottenOpen && <Forgotten />}
255
+ {store.dnsInventoryServer && <DnsInventory />}
256
+ {store.connectZoneTarget && <ProviderConnect />}
257
+ {store.dnsRecordsTarget && <DnsRecords />}
258
+ {store.kumaSite && <KumaSite />}
259
+ {/* Async-completion toasts (PHP upgrade, server reboot/restart). Mounted last
260
+ so it draws over every view + overlay; top-right, nudged clear of the
261
+ 2-row Header. It never takes keyboard focus. */}
262
+ <Toaster position="top-right" offset={{ top: 2, right: 2 }} />
263
+ </box>
264
+ )
265
+ }
@@ -0,0 +1,403 @@
1
+ // Detail panels for a server and a site. Shared by the Browser and Search views.
2
+
3
+ import { useEffect } from "react"
4
+ import { theme, statusColor, diskColor } from "../lib/theme.ts"
5
+ import { formatBytes, diskUsedPct, bar, formatDate, timeAgo, truncate } from "../lib/format.ts"
6
+ import { Field, StatusBadge, ControlPanel, type ActionGroup } from "./components.tsx"
7
+ import { effectiveStack, stackColor } from "../lib/stack.ts"
8
+ import { probeKindColor } from "../lib/probe.ts"
9
+ import { resolveLocalLink } from "../lib/local.ts"
10
+ import { normalizeDomain } from "../lib/dns.ts"
11
+ import type { Drift } from "../lib/gitStatus.ts"
12
+ import { useStore, isUpgradeInFlight, isServerOpInFlight, isHttpsToggleInFlight, isPurgeCacheInFlight, purgeCacheFailed } from "./store.tsx"
13
+ import type { Server, Site } from "../api/types.ts"
14
+
15
+ export function ServerDetail({ server, siteCount, showControl = false }: { server: Server; siteCount: number; showControl?: boolean }) {
16
+ const { rebootInfo, serverOps, isServerOsEol, sitesForServer, vanityJob } = useStore()
17
+ const ds = server.disk_space
18
+ const pct = diskUsedPct(ds?.used, ds?.total)
19
+ const op = serverOps.get(server.id)
20
+ const rb = rebootInfo.get(server.id)
21
+ const osEol = isServerOsEol(server)
22
+ const rebootValue =
23
+ op && isServerOpInFlight(op)
24
+ ? `${op.label}…`
25
+ : op?.status === "failed"
26
+ ? "action failed"
27
+ : server.reboot_required
28
+ ? rb?.present
29
+ ? `required · ${rb.kernel ? "kernel update" : "pkg updates"} (${rb.packages.length})`
30
+ : "required"
31
+ : "not needed"
32
+ const rebootColor =
33
+ op?.status === "failed" ? theme.bad : server.reboot_required || (op && isServerOpInFlight(op)) ? theme.warn : theme.good
34
+ return (
35
+ <box style={{ flexDirection: "column" }}>
36
+ <box style={{ flexDirection: "row" }}>
37
+ <text content={truncate(server.name, 34)} fg={theme.text} attributes={1} />
38
+ <box style={{ flexGrow: 1 }} />
39
+ <StatusBadge status={server.connection_status} />
40
+ </box>
41
+ <text content={server.ip_address ?? "—"} fg={theme.accent} />
42
+ <box style={{ height: 1 }} />
43
+
44
+ <Field label="Provider" value={server.provider_name ?? "—"} />
45
+ <Field label="Region" value={server.region ?? "—"} />
46
+ <Field label="Size" value={server.size ?? "—"} />
47
+ <Field
48
+ label="Ubuntu"
49
+ value={osEol ? `${server.ubuntu_version} — EOL, clone to newer (C)` : (server.ubuntu_version ?? "—")}
50
+ valueColor={osEol ? theme.bad : undefined}
51
+ />
52
+ <Field label="SSH" value={`${server.ip_address ?? "—"}:${server.ssh_port ?? 22}`} />
53
+ <Field label="Database" value={server.database?.server ?? "—"} />
54
+ <Field label="Sites" value={String(siteCount)} valueColor={theme.accent} />
55
+ <box style={{ height: 1 }} />
56
+
57
+ <Field
58
+ label="Disk"
59
+ value={
60
+ <box style={{ flexDirection: "row" }}>
61
+ <text content={bar(pct / 100, 16)} fg={diskColor(pct)} />
62
+ <text content={` ${pct.toFixed(0)}%`} fg={diskColor(pct)} />
63
+ </box>
64
+ }
65
+ />
66
+ <Field label="Used / Total" value={`${formatBytes(ds?.used)} / ${formatBytes(ds?.total)}`} />
67
+ <Field label="Available" value={formatBytes(ds?.available)} />
68
+ <box style={{ height: 1 }} />
69
+
70
+ <Field label="Reboot" value={rebootValue} valueColor={rebootColor} />
71
+ <Field
72
+ label="SpinupWP upgrade"
73
+ value={server.upgrade_required ? "required — press w to open" : "up to date"}
74
+ valueColor={server.upgrade_required ? theme.warn : theme.good}
75
+ />
76
+ <Field label="Created" value={formatDate(server.created_at)} />
77
+
78
+ {/* Server Control — mirrors Search's Site Control. Only rendered where the keys
79
+ actually fire (the Browser's Servers pane), via showControl; Search's query
80
+ mode passes it off and surfaces the pressable list in its actions focus. */}
81
+ {showControl ? (
82
+ <>
83
+ <box style={{ height: 1 }} />
84
+ <ControlPanel heading="Server Control" groups={serverControlGroups()} />
85
+ </>
86
+ ) : null}
87
+ </box>
88
+ )
89
+
90
+ // The static groups, plus a Manage "V" row whenever the V key would actually do
91
+ // something: the server has no site at its own hostname yet (busy servers
92
+ // benefit from a vanity site just like empty ones), or an unfinished build for
93
+ // this server can be reopened.
94
+ function serverControlGroups(): ActionGroup[] {
95
+ const resumable = vanityJob != null && vanityJob.step !== "done" && vanityJob.serverId === server.id
96
+ const hasVanity = sitesForServer(server.id).some((s) => s.domain.toLowerCase() === server.name.toLowerCase())
97
+ if (!resumable && hasVanity) return SERVER_CONTROL
98
+ const vanityItem: [string, string] = ["V", resumable ? "Resume vanity-site build" : "Vanity site at hostname"]
99
+ return SERVER_CONTROL.map((g) => (g.title === "Manage" ? { ...g, items: [...g.items, vanityItem] } : g))
100
+ }
101
+ }
102
+
103
+ export const SERVER_CONTROL: ActionGroup[] = [
104
+ { title: "Clone", items: [["C", "Clone this server →"]] },
105
+ { title: "Access", items: [["S", "Connect sudo"]] },
106
+ {
107
+ title: "Manage",
108
+ items: [
109
+ ["a", "Reboot / restart services"],
110
+ ["h", "Server health"],
111
+ ["N", "DNS records (migrate lens)"],
112
+ ],
113
+ },
114
+ { title: "Open", items: [["w", "Open in SpinupWP"]] },
115
+ ]
116
+
117
+ export function SiteDetail({ site, serverName }: { site: Site; serverName: string }) {
118
+ const { probes, probingIds, isProbeStale, phpUpgrades, httpsToggles, purgeCacheProgress, localLinks, grantedKeyKinds, kumaStatus, kumaMonitorFor } = useStore()
119
+ const kuma = kumaStatus.get(site.domain)
120
+ // The fingerprint check failing while HTTP is up means the site is answering
121
+ // 200 with the WRONG page (stale/corrupt page cache) — a distinct, worse state
122
+ // than down, so it outranks the plain up/down wording.
123
+ const kumaValue = kuma
124
+ ? kuma.up === false
125
+ ? "DOWN"
126
+ : kuma.fingerprintUp === false
127
+ ? "up · WRONG PAGE SERVED (M)"
128
+ : kuma.redisUp === false
129
+ ? "up · REDIS DOWN (M)"
130
+ : kuma.up
131
+ ? `up${kuma.uptime24 != null ? ` · ${(kuma.uptime24 * 100).toFixed(2)}% (24h)` : ""}`
132
+ : "no beats yet"
133
+ : kumaMonitorFor(site.domain)
134
+ ? "registered · awaiting poll"
135
+ : "not monitored · M"
136
+ const kumaColor = kuma ? (kuma.up === false || kuma.fingerprintUp === false || kuma.redisUp === false ? theme.bad : kuma.up ? theme.good : theme.textDim) : theme.textFaint
137
+ const httpsProgress = httpsToggles.get(site.id)
138
+ const purgeProgress = purgeCacheProgress.get(site.id)
139
+ const updates = (site.wp_plugin_updates || 0) + (site.wp_theme_updates || 0) + (site.wp_core_update ? 1 : 0)
140
+ const probe = probes.get(site.id)
141
+ // Prefer the DETECTED stack over SpinupWP's is_wordpress/git shape, which
142
+ // misclassifies some sites (a /public/ WP install shows as "Generic", Bedrock
143
+ // git sites report is_wordpress:false). Falls back to the API when unprobed.
144
+ const stack = effectiveStack(site, probe?.result.kind)
145
+ const isWordPress = stack !== "Non-WP"
146
+ const probing = probingIds.has(site.id)
147
+ const upgrade = phpUpgrades.get(site.id)
148
+ const link = localLinks.get(site.id)
149
+ const linkState = link ? resolveLocalLink(link) : null
150
+ const linkValue = !link
151
+ ? "not linked · L"
152
+ : !linkState!.exists
153
+ ? "missing — path not found"
154
+ : `${linkState!.label} · ${link.path}`
155
+ const linkColor = !link ? theme.textFaint : !linkState!.exists ? theme.bad : theme.good
156
+ // SSH keys Spinup has granted into this site user's authorized_keys, split into
157
+ // your personal key(s) vs the spinup-tui machine key (so it's never ambiguous).
158
+ const keyKinds = grantedKeyKinds(site.id)
159
+ const keyParts: string[] = []
160
+ if (keyKinds.personal > 0) keyParts.push(keyKinds.personal > 1 ? `your keys (${keyKinds.personal})` : "your key")
161
+ if (keyKinds.machine > 0) keyParts.push("spinup-tui")
162
+ const keyValue = keyParts.length ? `${keyParts.join(" + ")} · K` : "not granted · K"
163
+ return (
164
+ <box style={{ flexDirection: "column" }}>
165
+ <box style={{ flexDirection: "row" }}>
166
+ <text content={truncate(site.domain, 34)} fg={theme.text} attributes={1} />
167
+ <box style={{ flexGrow: 1 }} />
168
+ <StatusBadge status={site.status} />
169
+ </box>
170
+ <text content={(site.https?.enabled ? "https://" : "http://") + site.domain} fg={theme.accent} />
171
+ <box style={{ height: 1 }} />
172
+
173
+ <Field label="Server" value={truncate(serverName, 30)} />
174
+ <Field label="Stack" value={stack} valueColor={stackColor(stack)} />
175
+ <Field
176
+ label="Detected"
177
+ value={probing ? "identifying…" : probe ? probe.result.label + (isProbeStale(site) ? " (stale)" : "") : "not identified · d"}
178
+ valueColor={probing ? theme.textDim : probe ? probeKindColor(probe.result.kind) : theme.textFaint}
179
+ />
180
+ <Field label="Type" value={isWordPress ? "WordPress" : "Generic"} valueColor={isWordPress ? theme.brand : theme.textDim} />
181
+ <Field
182
+ label="PHP"
183
+ value={
184
+ upgrade && isUpgradeInFlight(upgrade)
185
+ ? `${site.php_version ?? "—"} → ${upgrade.target} (${upgrade.status}…)`
186
+ : upgrade?.status === "failed"
187
+ ? `${site.php_version ?? "—"} (upgrade failed)`
188
+ : (site.php_version ?? "—")
189
+ }
190
+ valueColor={
191
+ upgrade && isUpgradeInFlight(upgrade) ? theme.warn : upgrade?.status === "failed" ? theme.bad : theme.text
192
+ }
193
+ />
194
+ <Field label="User" value={site.site_user ?? "—"} />
195
+ <Field label="Granted" value={keyValue} valueColor={keyParts.length ? theme.good : theme.textFaint} />
196
+ <Field label="Public dir" value={site.public_folder ?? "/"} />
197
+ <Field label="Monitor" value={kumaValue} valueColor={kumaColor} />
198
+ <Field label="Local" value={linkValue} valueColor={linkColor} />
199
+ <SiteDnsSection site={site} />
200
+ <Field
201
+ label="HTTPS"
202
+ value={
203
+ httpsProgress && isHttpsToggleInFlight(httpsProgress)
204
+ ? `${httpsProgress.action === "enable" ? "enabling" : "disabling"}… (${httpsProgress.status})`
205
+ : httpsProgress?.status === "failed"
206
+ ? `${site.https?.enabled ? "enabled" : "disabled"} (toggle failed)`
207
+ : `${site.https?.enabled ? "enabled" : "disabled"} · H`
208
+ }
209
+ valueColor={
210
+ httpsProgress && isHttpsToggleInFlight(httpsProgress)
211
+ ? theme.warn
212
+ : httpsProgress?.status === "failed"
213
+ ? theme.bad
214
+ : site.https?.enabled
215
+ ? theme.good
216
+ : theme.warn
217
+ }
218
+ />
219
+ <Field
220
+ label="Page cache"
221
+ value={
222
+ purgeProgress && isPurgeCacheInFlight(purgeProgress)
223
+ ? "purging…"
224
+ : purgeProgress && purgeCacheFailed(purgeProgress)
225
+ ? `${site.page_cache?.enabled ? "enabled" : "disabled"} (purge failed)`
226
+ : `${site.page_cache?.enabled ? "enabled" : "disabled"} · P purge`
227
+ }
228
+ valueColor={
229
+ purgeProgress && isPurgeCacheInFlight(purgeProgress)
230
+ ? theme.warn
231
+ : purgeProgress && purgeCacheFailed(purgeProgress)
232
+ ? theme.bad
233
+ : site.page_cache?.enabled
234
+ ? theme.good
235
+ : theme.textDim
236
+ }
237
+ />
238
+ <box style={{ height: 1 }} />
239
+
240
+ {site.is_wordpress && (
241
+ <Field
242
+ label="WP updates"
243
+ value={
244
+ updates === 0
245
+ ? "all up to date"
246
+ : `${site.wp_plugin_updates || 0} plugin · ${site.wp_theme_updates || 0} theme${site.wp_core_update ? " · core" : ""}`
247
+ }
248
+ valueColor={updates === 0 ? theme.good : theme.warn}
249
+ />
250
+ )}
251
+ <Field
252
+ label="Backups"
253
+ value={
254
+ site.backups?.files || site.backups?.database
255
+ ? `${site.backups?.files ? "files" : ""}${site.backups?.files && site.backups?.database ? " + " : ""}${site.backups?.database ? "db" : ""}`
256
+ : "none"
257
+ }
258
+ valueColor={site.backups?.files || site.backups?.database ? theme.good : theme.textDim}
259
+ />
260
+ {site.backups?.next_run_time && <Field label="Next backup" value={timeAgo(site.backups.next_run_time)} />}
261
+ {site.git?.repo && <Field label="Git" value={`${truncate(site.git.repo, 26)} (${site.git.branch ?? "?"})`} />}
262
+ {site.basic_auth?.enabled && <Field label="Basic auth" value="enabled" valueColor={theme.warn} />}
263
+ <Field label="Created" value={formatDate(site.created_at)} />
264
+ </box>
265
+ )
266
+ }
267
+
268
+ // DNS zone-host lines for a site's domains, populated on demand (key `n`). Each
269
+ // distinct zone (www + apex collapsed) shows its resolved host; a separate-TLD
270
+ // additional domain surfaces as its own line with its own host. Read-only.
271
+ function SiteDnsSection({ site }: { site: Site }) {
272
+ const { zoneForDomain, isDnsResolving } = useStore()
273
+ const seen = new Set<string>()
274
+ const domains = [site.domain, ...(site.additional_domains?.map((a) => a.domain) ?? [])].filter((d) => {
275
+ const k = normalizeDomain(d)
276
+ if (!k || seen.has(k)) return false
277
+ seen.add(k)
278
+ return true
279
+ })
280
+ const anyResolved = domains.some((d) => zoneForDomain(d))
281
+ const anyResolving = domains.some((d) => isDnsResolving(d))
282
+ if (!anyResolved && !anyResolving) {
283
+ return <Field label="DNS" value="not checked · n" valueColor={theme.textFaint} />
284
+ }
285
+ return (
286
+ <box style={{ flexDirection: "column" }}>
287
+ <text content="DNS" fg={theme.textDim} />
288
+ {domains.map((d) => {
289
+ const c = zoneForDomain(d)
290
+ const resolving = isDnsResolving(d) && !c
291
+ const host = resolving ? "looking up…" : !c ? "—" : c.zone === null ? "no host found" : c.zone.providerLabel
292
+ const color = resolving ? theme.textDim : !c ? theme.textFaint : c.zone === null ? theme.warn : theme.accent
293
+ return (
294
+ <box key={d} style={{ flexDirection: "row" }}>
295
+ <text content={" " + truncate(d, 22)} fg={theme.text} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
296
+ <text content={host} fg={color} wrapMode="none" style={{ flexShrink: 0 }} />
297
+ </box>
298
+ )
299
+ })}
300
+ </box>
301
+ )
302
+ }
303
+
304
+ // Color for a resolved local stack label.
305
+ function localLabelColor(kind: "bedrock" | "wp" | "unknown"): string {
306
+ return kind === "bedrock" ? theme.good : kind === "wp" ? theme.accent : theme.warn
307
+ }
308
+
309
+ // Compact, self-explaining git-drift label for a linked copy's line, e.g.
310
+ // "⇡2 unpushed ● uncommitted". Empty when in sync.
311
+ function driftLabel(d: Drift): string {
312
+ const parts: string[] = []
313
+ if (d.ahead > 0) parts.push(`⇡${d.ahead} unpushed`)
314
+ if (d.dirty) parts.push("● uncommitted")
315
+ return parts.join(" ")
316
+ }
317
+
318
+ // A single inline action token, e.g. "t terminal". Dimmed when not applicable.
319
+ function Act({ keyName, label, on }: { keyName: string; label: string; on: boolean }) {
320
+ return (
321
+ <box style={{ flexDirection: "row", flexShrink: 0, marginRight: 3 }}>
322
+ <text content={keyName + " "} fg={on ? theme.brand : theme.textFaint} />
323
+ <text content={label} fg={on ? theme.textDim : theme.textFaint} wrapMode="none" />
324
+ </box>
325
+ )
326
+ }
327
+
328
+ // Height (incl. border) of the contextual strip — views subtract this from their
329
+ // list viewport so the panes shrink to make room. Border eats 2 rows → 2 content
330
+ // lines (status + actions).
331
+ export const SITE_CONTEXT_STRIP_HEIGHT = 4
332
+
333
+ // A full-width context strip for the selected site, sitting between the content
334
+ // panes and the command drawer. Shows the local-link status and the inline
335
+ // open-locally actions (which the host view's keyboard fires on the selection).
336
+ // The space is always reserved (a placeholder when nothing is selected) so the
337
+ // layout doesn't jump as the cursor moves.
338
+ export function SiteContextStrip({ site }: { site: Site | null }) {
339
+ const { localLinks, drift, ensureDrift } = useStore()
340
+ const link = site ? localLinks.get(site.id) : undefined
341
+ const state = link ? resolveLocalLink(link) : null
342
+ const canOpen = !!state?.exists
343
+ const hasLocalUrl = !!link?.localUrl
344
+ const d = site ? drift.get(site.id) : undefined
345
+
346
+ // Auto-compute (and cache) git drift when a linked, on-disk copy is shown.
347
+ useEffect(() => {
348
+ if (site && link && state?.exists) ensureDrift(site.id, link.path)
349
+ }, [site?.id, link?.path, state?.exists, ensureDrift])
350
+
351
+ return (
352
+ <box
353
+ title=" Local "
354
+ titleColor={theme.brand}
355
+ border
356
+ borderColor={link && !state!.exists ? theme.bad : theme.border}
357
+ style={{ height: SITE_CONTEXT_STRIP_HEIGHT, flexDirection: "column", paddingLeft: 1, paddingRight: 1 }}
358
+ >
359
+ {/* Line 1 — status (or, with nothing selected, a legend for the row markers) */}
360
+ {!site ? (
361
+ <box style={{ flexDirection: "row" }}>
362
+ <text content="◆ " fg={theme.good} style={{ flexShrink: 0 }} />
363
+ <text content="linked locally " fg={theme.textDim} style={{ flexShrink: 0 }} />
364
+ <text content="↑N " fg={theme.warn} style={{ flexShrink: 0 }} />
365
+ <text content="pending WordPress updates" fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
366
+ </box>
367
+ ) : !link ? (
368
+ <box style={{ flexDirection: "row" }}>
369
+ <text content={truncate(site.domain, 44)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
370
+ <text content=" not linked" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
371
+ </box>
372
+ ) : !state!.exists ? (
373
+ <box style={{ flexDirection: "row" }}>
374
+ <text content="missing " fg={theme.bad} style={{ flexShrink: 0 }} />
375
+ <text content={link.path} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
376
+ </box>
377
+ ) : (
378
+ <box style={{ flexDirection: "row" }}>
379
+ <text content={state!.label} fg={localLabelColor(state!.kind)} style={{ flexShrink: 0 }} />
380
+ <text content=" · " fg={theme.textFaint} style={{ flexShrink: 0 }} />
381
+ <text content={link.path} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
382
+ {link.localUrl ? (
383
+ <text content={" " + link.localUrl} fg={theme.accent} wrapMode="none" style={{ flexShrink: 1 }} />
384
+ ) : null}
385
+ {d && (d.ahead > 0 || d.dirty) ? (
386
+ <text content={" " + driftLabel(d)} fg={theme.warn} wrapMode="none" style={{ flexShrink: 0 }} />
387
+ ) : null}
388
+ </box>
389
+ )}
390
+
391
+ {/* Line 2 — inline actions (fired by the host view on the selection) */}
392
+ {site ? (
393
+ <box style={{ flexDirection: "row" }}>
394
+ <Act keyName="t" label="terminal" on={canOpen} />
395
+ <Act keyName="v" label="local URL" on={canOpen && hasLocalUrl} />
396
+ <Act keyName="L" label={link ? "edit / unlink" : "link a local copy"} on />
397
+ </box>
398
+ ) : (
399
+ <text content="Select a site to link a local copy and open it locally (t · v · L)" fg={theme.textFaint} wrapMode="none" />
400
+ )}
401
+ </box>
402
+ )
403
+ }