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.
- package/CHANGELOG.md +943 -0
- package/LICENSE +21 -0
- package/README.md +856 -0
- package/bin/spinuptui.js +13 -0
- package/package.json +55 -0
- package/src/api/client.ts +354 -0
- package/src/api/types.ts +206 -0
- package/src/config.ts +309 -0
- package/src/dev/devMode.ts +7 -0
- package/src/dev/fixtures.ts +238 -0
- package/src/dev/mockClient.ts +214 -0
- package/src/index.tsx +68 -0
- package/src/lib/appUpdate.ts +206 -0
- package/src/lib/awsSigV4.ts +133 -0
- package/src/lib/cloneDomains.ts +112 -0
- package/src/lib/cloneLog.ts +85 -0
- package/src/lib/dbBackup.ts +198 -0
- package/src/lib/dbSync.ts +253 -0
- package/src/lib/discover.ts +165 -0
- package/src/lib/dns.ts +144 -0
- package/src/lib/dnsCache.ts +94 -0
- package/src/lib/dnsQuery.ts +180 -0
- package/src/lib/dnsRecords.ts +411 -0
- package/src/lib/format.ts +106 -0
- package/src/lib/gitDeployKey.ts +0 -0
- package/src/lib/gitStatus.ts +37 -0
- package/src/lib/jobs.ts +32 -0
- package/src/lib/keychain.ts +49 -0
- package/src/lib/local.ts +116 -0
- package/src/lib/mediaFallback.ts +288 -0
- package/src/lib/open.ts +103 -0
- package/src/lib/phpEol.ts +151 -0
- package/src/lib/probe.ts +183 -0
- package/src/lib/providers.ts +284 -0
- package/src/lib/providersCache.ts +105 -0
- package/src/lib/releaseNotes.ts +67 -0
- package/src/lib/serverClone.ts +782 -0
- package/src/lib/serverCreate.ts +161 -0
- package/src/lib/siteDoctor.ts +260 -0
- package/src/lib/siteFingerprint.ts +108 -0
- package/src/lib/spinupweb.ts +33 -0
- package/src/lib/ssh.ts +669 -0
- package/src/lib/stack.ts +78 -0
- package/src/lib/stackCache.ts +113 -0
- package/src/lib/theme.ts +54 -0
- package/src/lib/ubuntuEol.ts +124 -0
- package/src/lib/uptimeKuma.ts +551 -0
- package/src/lib/vanitySite.ts +297 -0
- package/src/lib/wpInventory.ts +153 -0
- package/src/ui/App.tsx +265 -0
- package/src/ui/Details.tsx +403 -0
- package/src/ui/Explain.tsx +158 -0
- package/src/ui/Header.tsx +117 -0
- package/src/ui/Help.tsx +296 -0
- package/src/ui/List.tsx +95 -0
- package/src/ui/Onboarding.tsx +93 -0
- package/src/ui/Splash.tsx +51 -0
- package/src/ui/StatusBar.tsx +60 -0
- package/src/ui/components.tsx +392 -0
- package/src/ui/store.tsx +4253 -0
- package/src/ui/views/Browser.tsx +365 -0
- package/src/ui/views/CloneWizard.tsx +932 -0
- package/src/ui/views/Dashboard.tsx +149 -0
- package/src/ui/views/DbBackup.tsx +228 -0
- package/src/ui/views/DbSync.tsx +307 -0
- package/src/ui/views/Discover.tsx +271 -0
- package/src/ui/views/DnsInventory.tsx +580 -0
- package/src/ui/views/DnsRecords.tsx +562 -0
- package/src/ui/views/Events.tsx +98 -0
- package/src/ui/views/Forgotten.tsx +165 -0
- package/src/ui/views/GrantKey.tsx +504 -0
- package/src/ui/views/Health.tsx +322 -0
- package/src/ui/views/HttpsToggle.tsx +195 -0
- package/src/ui/views/KumaSite.tsx +571 -0
- package/src/ui/views/LocalLink.tsx +220 -0
- package/src/ui/views/MediaFallback.tsx +180 -0
- package/src/ui/views/NewServer.tsx +750 -0
- package/src/ui/views/PhpUpgrade.tsx +301 -0
- package/src/ui/views/ProviderConnect.tsx +527 -0
- package/src/ui/views/PurgeCache.tsx +186 -0
- package/src/ui/views/ReleaseNotes.tsx +84 -0
- package/src/ui/views/Search.tsx +448 -0
- package/src/ui/views/ServerActions.tsx +328 -0
- package/src/ui/views/Stacks.tsx +371 -0
- package/src/ui/views/SudoConnect.tsx +358 -0
- package/src/ui/views/VanityNewSite.tsx +407 -0
- package/src/ui/views/WpInventory.tsx +218 -0
- 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
|
+
}
|