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
@@ -0,0 +1,448 @@
1
+ // Global search: fuzzy-ish filtering across every server and site at once.
2
+ //
3
+ // The input is focused for the whole time this tab is active (so `inputMode`
4
+ // stays on and global shortcuts are suppressed). ↑/↓ move through results,
5
+ // Enter opens a site in the browser, Esc returns to the dashboard.
6
+
7
+ import { useEffect, useMemo, useState } from "react"
8
+ import { useKeyboard } from "@opentui/react"
9
+ import { theme, statusColor, statusDot } from "../../lib/theme.ts"
10
+ import { classifyStack, stackColor, stackTag } from "../../lib/stack.ts"
11
+ import { truncate } from "../../lib/format.ts"
12
+ import { Panel, Field, StatusBadge, SiteMetaCell, Spinner, ControlPanel } from "../components.tsx"
13
+ import { isDbBackupInFlight } from "../../lib/dbBackup.ts"
14
+ import { isDbSyncInFlight } from "../../lib/dbSync.ts"
15
+ import { List, moveSelection } from "../List.tsx"
16
+ import { ServerDetail, SiteDetail, SERVER_CONTROL } from "../Details.tsx"
17
+ import { StatusBar } from "../StatusBar.tsx"
18
+ import { openUrl } from "../../lib/open.ts"
19
+ import { serverWebUrl, siteWebUrl } from "../../lib/spinupweb.ts"
20
+ import { useStore } from "../store.tsx"
21
+ import type { Server, Site } from "../../api/types.ts"
22
+
23
+ type Result =
24
+ | { kind: "server"; server: Server; haystack: string }
25
+ | { kind: "site"; site: Site; haystack: string }
26
+
27
+ // Lower score = better match. Returns null when there's no match at all.
28
+ function score(haystack: string, q: string): number | null {
29
+ if (!q) return 0
30
+ const i = haystack.indexOf(q)
31
+ if (i < 0) return null
32
+ return i === 0 ? 0 : 1 + i / 100
33
+ }
34
+
35
+ export function Search({ rows }: { rows: number }) {
36
+ const store = useStore()
37
+ const { servers, sites, serverById, 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 } = store
38
+ const [query, setQuery] = useState("")
39
+ const [selected, setSelected] = useState(0)
40
+ // "query" = typing/filtering (input focused); "actions" = input blurred so the
41
+ // selected result's single-key actions (o/w/u/h) fire. Tab/→ enters, ←/Esc exits.
42
+ const [focus, setFocus] = useState<"query" | "actions">("query")
43
+ const [flash, setFlash] = useState<string | null>(null)
44
+ const flashMsg = (m: string) => {
45
+ setFlash(m)
46
+ setTimeout(() => setFlash(null), 1500)
47
+ }
48
+
49
+ // The input holds focus (suppressing global shortcuts) only in query mode; in
50
+ // actions mode we blur it so single-key actions can fire.
51
+ useEffect(() => {
52
+ setInputMode(focus === "query")
53
+ return () => setInputMode(false)
54
+ }, [focus, setInputMode])
55
+
56
+ const pool = useMemo<Result[]>(() => {
57
+ const s: Result[] = servers.map((server) => ({
58
+ kind: "server",
59
+ server,
60
+ haystack: `${server.name} ${server.ip_address ?? ""} ${server.provider_name ?? ""}`.toLowerCase(),
61
+ }))
62
+ const t: Result[] = sites.map((site) => ({
63
+ kind: "site",
64
+ site,
65
+ haystack: `${site.domain} ${site.site_user ?? ""}`.toLowerCase(),
66
+ }))
67
+ return [...s, ...t]
68
+ }, [servers, sites])
69
+
70
+ const results = useMemo(() => {
71
+ const q = query.trim().toLowerCase()
72
+ const scored = pool
73
+ .map((r) => ({ r, sc: score(r.haystack, q) }))
74
+ .filter((x) => x.sc !== null) as { r: Result; sc: number }[]
75
+ scored.sort((a, b) => {
76
+ if (a.sc !== b.sc) return a.sc - b.sc
77
+ const an = a.r.kind === "server" ? a.r.server.name : a.r.site.domain
78
+ const bn = b.r.kind === "server" ? b.r.server.name : b.r.site.domain
79
+ return an.localeCompare(bn)
80
+ })
81
+ return scored.map((x) => x.r)
82
+ }, [pool, query])
83
+
84
+ // Keep the selection in range as results shrink/grow.
85
+ useEffect(() => {
86
+ setSelected((i) => Math.min(i, Math.max(0, results.length - 1)))
87
+ }, [results.length])
88
+
89
+ const isActive = route === "search" && !overlayOpen
90
+ const current = results[Math.min(selected, Math.max(0, results.length - 1))]
91
+
92
+ // If results vanish (e.g. the query changes), fall back to query focus.
93
+ useEffect(() => {
94
+ if (results.length === 0) setFocus("query")
95
+ }, [results.length])
96
+
97
+ const openSite = (s: Site) => {
98
+ openUrl((s.https?.enabled ? "https://" : "http://") + s.domain)
99
+ flashMsg(`Opening ${s.domain}…`)
100
+ }
101
+ const openInSpinup = (url: string) => {
102
+ openUrl(url)
103
+ flashMsg(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
104
+ }
105
+
106
+ useKeyboard((key) => {
107
+ if (!isActive) return
108
+ // Normalize Shift+letter (OpenTUI delivers it lowercased + a shift flag) so
109
+ // capital actions like L (link) match in actions mode.
110
+ const raw = key.name ?? ""
111
+ const name = key.shift && raw.length === 1 ? raw.toUpperCase() : raw
112
+
113
+ // Selection movement works in both modes.
114
+ if (name === "up") return setSelected((i) => moveSelection(i, -1, results.length))
115
+ if (name === "down") return setSelected((i) => moveSelection(i, 1, results.length))
116
+
117
+ if (focus === "query") {
118
+ switch (name) {
119
+ case "return":
120
+ if (current?.kind === "site") openSite(current.site)
121
+ return
122
+ case "tab":
123
+ case "right":
124
+ if (current) setFocus("actions")
125
+ return
126
+ case "escape":
127
+ return setRoute("dashboard")
128
+ }
129
+ return
130
+ }
131
+
132
+ // actions mode (input blurred) — single-key actions on the selected result.
133
+ switch (name) {
134
+ case "left":
135
+ case "escape":
136
+ return setFocus("query")
137
+ case "o":
138
+ if (current?.kind === "site") openSite(current.site)
139
+ return
140
+ case "w":
141
+ if (current?.kind === "site") openInSpinup(siteWebUrl(current.site.id, accountSlug))
142
+ else if (current?.kind === "server") openInSpinup(serverWebUrl(current.server.id, accountSlug))
143
+ return
144
+ case "u":
145
+ if (current?.kind === "site") setPhpUpgradeSite(current.site)
146
+ return
147
+ case "H":
148
+ if (current?.kind === "site") setHttpsToggleSite(current.site)
149
+ return
150
+ case "P":
151
+ if (current?.kind === "site") setPurgeCacheSite(current.site)
152
+ return
153
+ case "t":
154
+ if (current?.kind === "site") flashMsg(openLocalTerminal(current.site.id))
155
+ return
156
+ case "v":
157
+ if (current?.kind === "site") flashMsg(openLocalUrl(current.site.id))
158
+ return
159
+ case "L":
160
+ if (current?.kind === "site") setLocalLinkSite(current.site)
161
+ return
162
+ case "s":
163
+ if (current?.kind === "site") flashMsg(sshSite(current.site.id))
164
+ return
165
+ case "d":
166
+ // DB backup uses wp-cli on the remote and downloads into the linked copy,
167
+ // so it needs a WordPress site and a local link.
168
+ if (current?.kind === "site") {
169
+ if (!current.site.is_wordpress) flashMsg("Database backup needs WordPress (wp-cli) — this isn't a WP site")
170
+ else if (!localLinks.has(current.site.id)) flashMsg("Not linked — press L to link a local copy")
171
+ else setDbBackupSite(current.site)
172
+ }
173
+ return
174
+ case "p":
175
+ // Pull production → local: opt-in (it overwrites the local DB), and needs
176
+ // a WordPress site + a local link.
177
+ if (current?.kind === "site") {
178
+ if (!localSync) flashMsg('Local sync is off — set "localSync": true in config to enable')
179
+ else if (!current.site.is_wordpress) flashMsg("Sync needs WordPress (wp-cli) — this isn't a WP site")
180
+ else if (!localLinks.has(current.site.id)) flashMsg("Not linked — press L to link a local copy")
181
+ else setDbSyncSite(current.site)
182
+ }
183
+ return
184
+ case "m":
185
+ // Production media fallback: drops a mu-plugin into the linked local copy,
186
+ // so it needs a WordPress site and a local link (same gate as backup).
187
+ if (current?.kind === "site") {
188
+ if (!current.site.is_wordpress) flashMsg("Media fallback needs WordPress — this isn't a WP site")
189
+ else if (!localLinks.has(current.site.id)) flashMsg("Not linked — press L to link a local copy")
190
+ else setMediaFallbackSite(current.site)
191
+ }
192
+ return
193
+ case "M":
194
+ // Site monitoring (Uptime Kuma) — capital because lowercase m is media
195
+ // fallback here; M matches the same overlay's binding in Stacks.
196
+ if (current?.kind === "site") setKumaSite(current.site)
197
+ return
198
+ case "n":
199
+ // DNS inventory scoped to this site (its domains + records) — the migrate
200
+ // lens for moving one site to another server.
201
+ if (current?.kind === "site") {
202
+ const srv = serverById(current.site.server_id)
203
+ if (srv) setDnsInventoryServer(srv, current.site.id)
204
+ }
205
+ return
206
+ case "h": {
207
+ const srv =
208
+ current?.kind === "server"
209
+ ? current.server
210
+ : current?.kind === "site"
211
+ ? serverById(current.site.server_id)
212
+ : undefined
213
+ if (srv) setHealthServer(srv)
214
+ return
215
+ }
216
+ case "a": {
217
+ // Server actions (reboot / restart) — on the server, or a site's server.
218
+ const srv =
219
+ current?.kind === "server"
220
+ ? current.server
221
+ : current?.kind === "site"
222
+ ? serverById(current.site.server_id)
223
+ : undefined
224
+ if (srv) setServerActionsServer(srv)
225
+ return
226
+ }
227
+ // Server-only Server Control actions (server results show the full suite).
228
+ case "C":
229
+ if (current?.kind === "server") beginClone(current.server)
230
+ return
231
+ case "S":
232
+ if (current?.kind === "server") setSudoConnectServer(current.server)
233
+ return
234
+ case "N":
235
+ if (current?.kind === "server") setDnsInventoryServer(current.server)
236
+ return
237
+ }
238
+ })
239
+
240
+ const hints =
241
+ focus === "query"
242
+ ? [
243
+ { key: "↑↓", label: "select" },
244
+ { key: "⏎", label: "open site" },
245
+ { key: "Tab/→", label: "actions" },
246
+ { key: "esc", label: "dashboard" },
247
+ ]
248
+ : current?.kind === "server"
249
+ ? [
250
+ { key: "↑↓", label: "select" },
251
+ { key: "w", label: "SpinupWP" },
252
+ { key: "a", label: "actions" },
253
+ { key: "h", label: "health" },
254
+ { key: "←/esc", label: "back" },
255
+ ]
256
+ : [
257
+ { key: "↑↓", label: "select" },
258
+ { key: "o", label: "open" },
259
+ { key: "s", label: "SSH" },
260
+ { key: "n", label: "DNS" },
261
+ { key: "d", label: "DB backup" },
262
+ ...(localSync ? [{ key: "p", label: "pull to local" }] : []),
263
+ ...(current?.kind === "site" && current.site.is_wordpress && localLinks.has(current.site.id) ? [{ key: "m", label: "media fallback" }] : []),
264
+ { key: "M", label: "monitoring" },
265
+ { key: "a", label: "server actions" },
266
+ { key: "←/esc", label: "back" },
267
+ ]
268
+
269
+ const listRows = Math.max(3, rows - 8) // input box (3) + status bar (1) + chrome
270
+
271
+ return (
272
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
273
+ <box style={{ flexDirection: "row", padding: 1, gap: 1 }}>
274
+ <box
275
+ title=" Search "
276
+ titleColor={focus === "query" ? theme.brand : theme.textDim}
277
+ border
278
+ borderColor={focus === "query" ? theme.borderActive : theme.border}
279
+ style={{ flexGrow: 1, flexDirection: "row", paddingLeft: 1, paddingRight: 1 }}
280
+ >
281
+ <text content="🔍 " fg={theme.brand} />
282
+ <input
283
+ focused={isActive && focus === "query"}
284
+ value={query}
285
+ placeholder="type a server name, domain, or IP…"
286
+ onInput={setQuery}
287
+ style={{ flexGrow: 1, backgroundColor: theme.bg, focusedBackgroundColor: theme.bg, textColor: theme.text }}
288
+ />
289
+ <text content={`${results.length} results`} fg={theme.textDim} />
290
+ </box>
291
+ </box>
292
+
293
+ <box style={{ flexGrow: 1, flexDirection: "row", paddingLeft: 1, paddingRight: 1, paddingBottom: 1, gap: 1 }}>
294
+ <Panel title=" Results " active={focus === "query"} flexGrow={1}>
295
+ <List
296
+ items={results}
297
+ selectedIndex={selected}
298
+ viewportRows={listRows}
299
+ focused={focus === "query"}
300
+ emptyText={query ? "No matches" : "Start typing to search across your whole account"}
301
+ keyFor={(r, i) => (r.kind === "server" ? `s${r.server.id}` : `w${r.site.id}`) + i}
302
+ renderRow={(r, sel) => {
303
+ if (r.kind === "server") {
304
+ return (
305
+ <>
306
+ <text content="SRV " fg={sel ? theme.text : theme.purple} style={{ flexShrink: 0 }} />
307
+ <text content={statusDot(r.server.connection_status) + " "} fg={statusColor(r.server.connection_status)} style={{ flexShrink: 0 }} />
308
+ <text content={truncate(r.server.name, 44)} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1, marginRight: 1 }} />
309
+ {r.server.reboot_required && <text content="↻rbt " fg={sel ? theme.text : theme.warn} style={{ flexShrink: 0 }} />}
310
+ <text content={r.server.provider_name ?? ""} fg={sel ? theme.text : theme.textFaint} style={{ flexShrink: 0 }} />
311
+ </>
312
+ )
313
+ }
314
+ return (
315
+ <>
316
+ <text content="SITE" fg={sel ? theme.text : theme.accent} style={{ flexShrink: 0 }} />
317
+ <text content={" " + statusDot(r.site.status) + " "} fg={statusColor(r.site.status)} style={{ flexShrink: 0 }} />
318
+ <text content={truncate(r.site.domain, 44)} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1, marginRight: 1 }} />
319
+ {(() => {
320
+ // Sync (destructive) takes visual priority over a plain backup.
321
+ const sy = dbSyncs.get(r.site.id)
322
+ if (sy && isDbSyncInFlight(sy))
323
+ return (
324
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
325
+ <Spinner color={sel ? theme.text : theme.brand} interval={120} />
326
+ <text content="⇣ " fg={sel ? theme.text : theme.warn} wrapMode="none" />
327
+ </box>
328
+ )
329
+ if (sy?.stage === "done") return <text content="⇣✓ " fg={sel ? theme.text : theme.good} wrapMode="none" style={{ flexShrink: 0 }} />
330
+ if (sy?.stage === "error") return <text content="⇣! " fg={sel ? theme.text : theme.bad} wrapMode="none" style={{ flexShrink: 0 }} />
331
+ const b = dbBackups.get(r.site.id)
332
+ if (b && isDbBackupInFlight(b))
333
+ return (
334
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
335
+ <Spinner color={sel ? theme.text : theme.brand} interval={120} />
336
+ <text content="⬇ " fg={sel ? theme.text : theme.warn} wrapMode="none" />
337
+ </box>
338
+ )
339
+ if (b?.stage === "done") return <text content="⬇✓ " fg={sel ? theme.text : theme.good} wrapMode="none" style={{ flexShrink: 0 }} />
340
+ if (b?.stage === "error") return <text content="⬇! " fg={sel ? theme.text : theme.bad} wrapMode="none" style={{ flexShrink: 0 }} />
341
+ return null
342
+ })()}
343
+ <SiteMetaCell
344
+ linked={localLinks.has(r.site.id)}
345
+ updates={(r.site.wp_plugin_updates || 0) + (r.site.wp_theme_updates || 0) + (r.site.wp_core_update ? 1 : 0)}
346
+ selected={sel}
347
+ />
348
+ <text content={stackTag(classifyStack(r.site))} fg={stackColor(classifyStack(r.site), sel)} style={{ flexShrink: 0 }} />
349
+ </>
350
+ )
351
+ }}
352
+ />
353
+ </Panel>
354
+
355
+ <Panel title={focus === "actions" ? " Actions " : " Details "} active={focus === "actions"} width={44}>
356
+ {!current ? (
357
+ <text content="No selection" fg={theme.textFaint} />
358
+ ) : focus === "actions" ? (
359
+ <ActionsCard
360
+ result={current}
361
+ serverName={current.kind === "site" ? (serverById(current.site.server_id)?.name ?? "—") : current.server.name}
362
+ localSync={localSync}
363
+ />
364
+ ) : current.kind === "server" ? (
365
+ <ServerDetail server={current.server} siteCount={store.sitesForServer(current.server.id).length} />
366
+ ) : (
367
+ <SiteDetail site={current.site} serverName={serverById(current.site.server_id)?.name ?? "—"} />
368
+ )}
369
+ </Panel>
370
+ </box>
371
+
372
+ <StatusBar hints={hints} message={flash ?? undefined} showGlobal={false} />
373
+ </box>
374
+ )
375
+ }
376
+
377
+ // Site Control panel shown in the Details pane while in "actions" focus. Groups
378
+ // the live single-key actions (Open / Remote / Local / Server) so the suite is
379
+ // discoverable and scales as more actions land. A site gets the full set; a
380
+ // server gets just the open + server actions.
381
+ type ActionGroup = { title: string; items: [string, string][] }
382
+
383
+ // The DB backup/sync use wp-cli, so they only apply to WordPress sites — omitted
384
+ // for non-WP sites (their `d`/`p` keypresses are also guarded in the handler). DNS
385
+ // (`n`) and the rest apply to any site.
386
+ function siteGroups(isWordpress: boolean, localSync: boolean): ActionGroup[] {
387
+ const remote: [string, string][] = [
388
+ ["s", "SSH into the site"],
389
+ ["n", "DNS records (migrate lens)"],
390
+ ]
391
+ if (isWordpress) {
392
+ remote.push(["d", "Download DB backup"])
393
+ if (localSync) remote.push(["p", "Pull production DB to local"])
394
+ }
395
+ const local: [string, string][] = [
396
+ ["t", "Open working copy in a terminal"],
397
+ ["v", "Open the local URL"],
398
+ ["L", "Link / edit local copy"],
399
+ ]
400
+ if (isWordpress) local.push(["m", "Media: serve missing images from production"])
401
+ remote.push(["u", "Upgrade PHP version"])
402
+ remote.push(["H", "Enable/disable HTTPS"])
403
+ remote.push(["P", "Purge page + object cache"])
404
+ remote.push(["M", "Monitoring (Uptime Kuma + front-page check)"])
405
+ return [
406
+ { title: "Open", items: [["o", "Open site in browser"], ["w", "Open in SpinupWP"]] },
407
+ { title: "Remote", items: remote },
408
+ { title: "Local", items: local },
409
+ { title: "Server", items: [["a", "Server actions (reboot / restart)"], ["h", "Server health"]] },
410
+ ]
411
+ }
412
+
413
+ function ActionsCard({ result, serverName, localSync }: { result: Result; serverName: string; localSync: boolean }) {
414
+ const isSite = result.kind === "site"
415
+ const name = isSite ? result.site.domain : result.server.name
416
+ const status = isSite ? result.site.status : result.server.connection_status
417
+ // Server results reuse the SAME Server Control suite as the Browser detail pane.
418
+ const groups = isSite ? siteGroups(result.site.is_wordpress, localSync) : SERVER_CONTROL
419
+ return (
420
+ <box style={{ flexDirection: "column" }}>
421
+ <box style={{ flexDirection: "row" }}>
422
+ <text content={truncate(name, 30)} fg={theme.text} attributes={1} wrapMode="none" />
423
+ <box style={{ flexGrow: 1 }} />
424
+ <StatusBadge status={status} />
425
+ </box>
426
+ {isSite ? (
427
+ <>
428
+ <text content={(result.site.https?.enabled ? "https://" : "http://") + result.site.domain} fg={theme.accent} wrapMode="none" />
429
+ <box style={{ height: 1 }} />
430
+ <Field label="Server" value={truncate(serverName, 28)} labelWidth={8} />
431
+ <Field label="PHP" value={result.site.php_version ?? "—"} labelWidth={8} />
432
+ <Field label="Stack" value={classifyStack(result.site)} valueColor={stackColor(classifyStack(result.site))} labelWidth={8} />
433
+ </>
434
+ ) : (
435
+ <>
436
+ <text content={result.server.ip_address ?? "—"} fg={theme.accent} wrapMode="none" />
437
+ <box style={{ height: 1 }} />
438
+ <Field label="Provider" value={result.server.provider_name ?? "—"} labelWidth={9} />
439
+ <Field label="Region" value={result.server.region ?? "—"} labelWidth={9} />
440
+ </>
441
+ )}
442
+ <box style={{ height: 1 }} />
443
+ <ControlPanel heading={isSite ? "Site Control" : "Server Control"} groups={groups} />
444
+ <box style={{ height: 1 }} />
445
+ <text content="↑↓ select · ←/Esc back to search" fg={theme.textFaint} wrapMode="none" />
446
+ </box>
447
+ )
448
+ }