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,365 @@
|
|
|
1
|
+
// Server & site browser: a three-pane master/detail navigator.
|
|
2
|
+
//
|
|
3
|
+
// [ Servers ] → [ Sites on server ] → [ Details of focused item ]
|
|
4
|
+
//
|
|
5
|
+
// Tab / → moves focus rightward, ← / Esc moves it back. ↑/↓ (or j/k) move the
|
|
6
|
+
// selection in the focused pane. `o` opens the selected site in the browser.
|
|
7
|
+
|
|
8
|
+
import { useEffect, useMemo, useState } from "react"
|
|
9
|
+
import { useKeyboard } from "@opentui/react"
|
|
10
|
+
import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
11
|
+
import { effectiveStack, stackColor, stackTag } from "../../lib/stack.ts"
|
|
12
|
+
import { truncate } from "../../lib/format.ts"
|
|
13
|
+
import { Panel, PhpVersionCell, Spinner, SiteStatusColumn } from "../components.tsx"
|
|
14
|
+
import { List, moveSelection } from "../List.tsx"
|
|
15
|
+
import { ServerDetail, SiteDetail, SiteContextStrip, SITE_CONTEXT_STRIP_HEIGHT } from "../Details.tsx"
|
|
16
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
17
|
+
import { openUrl } from "../../lib/open.ts"
|
|
18
|
+
import { serverWebUrl, siteWebUrl } from "../../lib/spinupweb.ts"
|
|
19
|
+
import { useStore, isServerOpInFlight } from "../store.tsx"
|
|
20
|
+
import { isVanityPair } from "../../lib/vanitySite.ts"
|
|
21
|
+
|
|
22
|
+
type Focus = "servers" | "sites"
|
|
23
|
+
|
|
24
|
+
export function Browser({ rows }: { rows: number }) {
|
|
25
|
+
const store = useStore()
|
|
26
|
+
const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, grantedKeyKinds, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite } = store
|
|
27
|
+
|
|
28
|
+
const [serverIndex, setServerIndex] = useState(0)
|
|
29
|
+
const [siteIndex, setSiteIndex] = useState(0)
|
|
30
|
+
const [focus, setFocus] = useState<Focus>("servers")
|
|
31
|
+
const [flash, setFlash] = useState<string | null>(null)
|
|
32
|
+
|
|
33
|
+
// Servers sorted alphabetically for predictable browsing.
|
|
34
|
+
const sortedServers = useMemo(
|
|
35
|
+
() => [...servers].sort((a, b) => a.name.localeCompare(b.name)),
|
|
36
|
+
[servers],
|
|
37
|
+
)
|
|
38
|
+
const server = sortedServers[Math.min(serverIndex, sortedServers.length - 1)]
|
|
39
|
+
const sites = useMemo(
|
|
40
|
+
() => (server ? [...sitesForServer(server.id)].sort((a, b) => a.domain.localeCompare(b.domain)) : []),
|
|
41
|
+
[server, sitesForServer],
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
// Reset site selection whenever the active server changes.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
setSiteIndex(0)
|
|
47
|
+
}, [serverIndex])
|
|
48
|
+
|
|
49
|
+
const isActive = route === "servers" && !inputMode && !overlayOpen
|
|
50
|
+
|
|
51
|
+
useKeyboard((key) => {
|
|
52
|
+
if (!isActive) return
|
|
53
|
+
// OpenTUI lowercases letters and sets a shift flag; normalize so capital
|
|
54
|
+
// shortcuts (e.g. G = jump to bottom) match.
|
|
55
|
+
const raw = key.name ?? ""
|
|
56
|
+
const name = key.shift && raw.length === 1 ? raw.toUpperCase() : raw
|
|
57
|
+
|
|
58
|
+
const moveBy = (delta: number) => {
|
|
59
|
+
if (focus === "servers") setServerIndex((i) => moveSelection(i, delta, sortedServers.length))
|
|
60
|
+
else setSiteIndex((i) => moveSelection(i, delta, sites.length))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
switch (name) {
|
|
64
|
+
case "up":
|
|
65
|
+
case "k":
|
|
66
|
+
return moveBy(-1)
|
|
67
|
+
case "down":
|
|
68
|
+
case "j":
|
|
69
|
+
return moveBy(1)
|
|
70
|
+
case "g":
|
|
71
|
+
return focus === "servers" ? setServerIndex(0) : setSiteIndex(0)
|
|
72
|
+
case "G":
|
|
73
|
+
return focus === "servers" ? setServerIndex(sortedServers.length - 1) : setSiteIndex(sites.length - 1)
|
|
74
|
+
case "right":
|
|
75
|
+
case "l":
|
|
76
|
+
case "return":
|
|
77
|
+
case "tab":
|
|
78
|
+
if (focus === "servers" && sites.length > 0) setFocus("sites")
|
|
79
|
+
return
|
|
80
|
+
case "left":
|
|
81
|
+
case "escape":
|
|
82
|
+
if (focus === "sites") setFocus("servers")
|
|
83
|
+
return
|
|
84
|
+
case "o":
|
|
85
|
+
if (focus === "sites" && sites[siteIndex]) {
|
|
86
|
+
const s = sites[siteIndex]
|
|
87
|
+
openUrl((s.https?.enabled ? "https://" : "http://") + s.domain)
|
|
88
|
+
setFlash(`Opening ${s.domain}…`)
|
|
89
|
+
setTimeout(() => setFlash(null), 1500)
|
|
90
|
+
}
|
|
91
|
+
return
|
|
92
|
+
case "d":
|
|
93
|
+
// Detect the selected site's stack via SSH (Tier 2); shows in Details.
|
|
94
|
+
if (focus === "sites" && sites[siteIndex]) {
|
|
95
|
+
const s = sites[siteIndex]
|
|
96
|
+
runProbe(s)
|
|
97
|
+
setFlash(`Identifying the app on ${s.domain}…`)
|
|
98
|
+
setTimeout(() => setFlash(null), 1500)
|
|
99
|
+
}
|
|
100
|
+
return
|
|
101
|
+
case "u":
|
|
102
|
+
// Upgrade the selected site's PHP version (first write action).
|
|
103
|
+
if (focus === "sites" && sites[siteIndex]) setPhpUpgradeSite(sites[siteIndex])
|
|
104
|
+
return
|
|
105
|
+
case "H":
|
|
106
|
+
// Enable/disable HTTPS on the selected site (direction auto-derived).
|
|
107
|
+
if (focus === "sites" && sites[siteIndex]) setHttpsToggleSite(sites[siteIndex])
|
|
108
|
+
return
|
|
109
|
+
case "P":
|
|
110
|
+
// Purge page cache + object cache on the selected site.
|
|
111
|
+
if (focus === "sites" && sites[siteIndex]) setPurgeCacheSite(sites[siteIndex])
|
|
112
|
+
return
|
|
113
|
+
case "m":
|
|
114
|
+
case "M":
|
|
115
|
+
// Uptime Kuma monitoring for the selected site (connect on first use).
|
|
116
|
+
// M is an alias: it's the binding Search/Stacks use (their lowercase m
|
|
117
|
+
// is taken), so the capital works everywhere.
|
|
118
|
+
if (focus === "sites" && sites[siteIndex]) setKumaSite(sites[siteIndex])
|
|
119
|
+
return
|
|
120
|
+
case "K":
|
|
121
|
+
// Grant Spinup's machine key to the selected site over SSH (privileged
|
|
122
|
+
// write via the server's sudo user — the API can't do this). Capital K
|
|
123
|
+
// (like L/V/N) since lowercase k is the vim "move up" binding.
|
|
124
|
+
if (focus === "sites" && sites[siteIndex]) setGrantKeySite(sites[siteIndex])
|
|
125
|
+
return
|
|
126
|
+
case "L":
|
|
127
|
+
// Link / view the selected site's local working copy (Phase 1).
|
|
128
|
+
if (focus === "sites" && sites[siteIndex]) setLocalLinkSite(sites[siteIndex])
|
|
129
|
+
return
|
|
130
|
+
case "t":
|
|
131
|
+
// Open the selected site's local working copy in a terminal (inline).
|
|
132
|
+
if (focus === "sites" && sites[siteIndex]) {
|
|
133
|
+
setFlash(openLocalTerminal(sites[siteIndex].id))
|
|
134
|
+
setTimeout(() => setFlash(null), 1800)
|
|
135
|
+
}
|
|
136
|
+
return
|
|
137
|
+
case "v":
|
|
138
|
+
// Open the selected site's stored local URL in the browser.
|
|
139
|
+
if (focus === "sites" && sites[siteIndex]) {
|
|
140
|
+
setFlash(openLocalUrl(sites[siteIndex].id))
|
|
141
|
+
setTimeout(() => setFlash(null), 1800)
|
|
142
|
+
}
|
|
143
|
+
return
|
|
144
|
+
case "s":
|
|
145
|
+
// Open a terminal and SSH into the selected site.
|
|
146
|
+
if (focus === "sites" && sites[siteIndex]) {
|
|
147
|
+
setFlash(sshSite(sites[siteIndex].id))
|
|
148
|
+
setTimeout(() => setFlash(null), 2000)
|
|
149
|
+
}
|
|
150
|
+
return
|
|
151
|
+
case "n":
|
|
152
|
+
// DNS inventory scoped to the selected site (its domains + records).
|
|
153
|
+
if (focus === "sites" && sites[siteIndex] && server) setDnsInventoryServer(server, sites[siteIndex].id)
|
|
154
|
+
return
|
|
155
|
+
case "p":
|
|
156
|
+
// Plugins & themes (wp-cli over SSH) for the selected site.
|
|
157
|
+
if (focus === "sites" && sites[siteIndex]) setWpInventorySite(sites[siteIndex])
|
|
158
|
+
return
|
|
159
|
+
case "N":
|
|
160
|
+
// Server-wide DNS inventory — every site on the server.
|
|
161
|
+
if (server) setDnsInventoryServer(server)
|
|
162
|
+
return
|
|
163
|
+
case "S":
|
|
164
|
+
// Connect sudo on the selected server (open a privileged session for the
|
|
165
|
+
// rest of the run — S for Sudo). Server-scoped — works from either pane.
|
|
166
|
+
if (server) setSudoConnectServer(server)
|
|
167
|
+
return
|
|
168
|
+
case "a":
|
|
169
|
+
// Server actions (reboot / restart) are server-scoped, so only offered
|
|
170
|
+
// when the Servers pane is focused — when you've drilled into a site,
|
|
171
|
+
// the context is the site (server actions are dropped there).
|
|
172
|
+
if (focus === "servers" && server) setServerActionsServer(server)
|
|
173
|
+
return
|
|
174
|
+
case "c":
|
|
175
|
+
// Create a new server. Seed from the highlighted server when there is one;
|
|
176
|
+
// open from scratch otherwise (incl. an empty fleet). Servers-pane only.
|
|
177
|
+
if (focus === "servers") {
|
|
178
|
+
setNewServerSource(server ?? null)
|
|
179
|
+
setNewServerOpen(true)
|
|
180
|
+
}
|
|
181
|
+
return
|
|
182
|
+
case "C":
|
|
183
|
+
// Clone this server (all its sites) to a brand-new server. Servers-pane only,
|
|
184
|
+
// and only when the server actually has sites to clone.
|
|
185
|
+
if (focus === "servers" && server && sitesForServer(server.id).length > 0) {
|
|
186
|
+
beginClone(server)
|
|
187
|
+
}
|
|
188
|
+
return
|
|
189
|
+
case "V":
|
|
190
|
+
// Create the vanity site at the server's own hostname. Offered whenever
|
|
191
|
+
// the server doesn't already HAVE a site there — a busy server benefits
|
|
192
|
+
// just as much as an empty one (the hostname page + a key-holding site
|
|
193
|
+
// user). Also reopens an unfinished build for this server (in-flight,
|
|
194
|
+
// parked at the SSH-key step, or errored — all after the site may exist).
|
|
195
|
+
if (server) {
|
|
196
|
+
const resumable = vanityJob && vanityJob.step !== "done" && vanityJob.serverId === server.id
|
|
197
|
+
const hasVanity = sitesForServer(server.id).some((s) => s.domain.toLowerCase() === server.name.toLowerCase())
|
|
198
|
+
if (resumable || !hasVanity) setVanityServer(server)
|
|
199
|
+
}
|
|
200
|
+
return
|
|
201
|
+
case "h":
|
|
202
|
+
// Open the live health view for the current server (works from either pane).
|
|
203
|
+
if (server) setHealthServer(server)
|
|
204
|
+
return
|
|
205
|
+
case "w":
|
|
206
|
+
// Open the focused server/site in the SpinupWP web app — useful for
|
|
207
|
+
// actions the API can't do (e.g. running a pending server upgrade).
|
|
208
|
+
if (focus === "sites" && sites[siteIndex]) {
|
|
209
|
+
openUrl(siteWebUrl(sites[siteIndex].id, accountSlug))
|
|
210
|
+
setFlash(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
|
|
211
|
+
} else if (server) {
|
|
212
|
+
openUrl(serverWebUrl(server.id, accountSlug))
|
|
213
|
+
setFlash(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
|
|
214
|
+
}
|
|
215
|
+
setTimeout(() => setFlash(null), 2000)
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
const listRows = Math.max(3, rows - 6 - SITE_CONTEXT_STRIP_HEIGHT)
|
|
221
|
+
const focusedSite = sites[Math.min(siteIndex, Math.max(0, sites.length - 1))]
|
|
222
|
+
|
|
223
|
+
const hints =
|
|
224
|
+
focus === "servers"
|
|
225
|
+
? [
|
|
226
|
+
{ key: "↑↓/jk", label: "select" },
|
|
227
|
+
{ key: "→/⏎", label: "view sites" },
|
|
228
|
+
{ key: "c", label: "new server" },
|
|
229
|
+
{ key: "S", label: "connect sudo" },
|
|
230
|
+
{ key: "a", label: "server actions" },
|
|
231
|
+
{ key: "N", label: "DNS hosts" },
|
|
232
|
+
{ key: "h", label: "health" },
|
|
233
|
+
{ key: "w", label: "SpinupWP" },
|
|
234
|
+
]
|
|
235
|
+
: [
|
|
236
|
+
{ key: "↑↓/jk", label: "select site" },
|
|
237
|
+
{ key: "←/esc", label: "back" },
|
|
238
|
+
{ key: "d", label: "identify app" },
|
|
239
|
+
{ key: "n", label: "DNS host" },
|
|
240
|
+
{ key: "u", label: "change PHP" },
|
|
241
|
+
{ key: "m", label: "monitoring" },
|
|
242
|
+
{ key: "K", label: "grant key" },
|
|
243
|
+
{ key: "p", label: "plugins" },
|
|
244
|
+
{ key: "o", label: "open" },
|
|
245
|
+
{ key: "w", label: "SpinupWP" },
|
|
246
|
+
{ key: "s", label: "ssh" },
|
|
247
|
+
{ key: "h", label: "health" },
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
return (
|
|
251
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
252
|
+
<box style={{ flexGrow: 1, flexDirection: "row", padding: 1, gap: 1 }}>
|
|
253
|
+
{/* Servers pane */}
|
|
254
|
+
<Panel title={` Servers (${sortedServers.length}) `} active={focus === "servers"} width={44}>
|
|
255
|
+
<List
|
|
256
|
+
items={sortedServers}
|
|
257
|
+
selectedIndex={serverIndex}
|
|
258
|
+
viewportRows={listRows}
|
|
259
|
+
focused={focus === "servers"}
|
|
260
|
+
keyFor={(s) => s.id}
|
|
261
|
+
emptyText="No servers yet — press c to create one"
|
|
262
|
+
renderRow={(s, selected) => {
|
|
263
|
+
const count = sitesForServer(s.id).length
|
|
264
|
+
const op = serverOps.get(s.id)
|
|
265
|
+
return (
|
|
266
|
+
<>
|
|
267
|
+
<text content={statusDot(s.connection_status) + " "} fg={statusColor(s.connection_status)} style={{ flexShrink: 0 }} />
|
|
268
|
+
<text content={truncate(s.name, 30)} fg={selected ? theme.text : count === 0 ? theme.warn : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
269
|
+
{op && isServerOpInFlight(op) ? (
|
|
270
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
271
|
+
<text content=" " style={{ flexShrink: 0 }} />
|
|
272
|
+
<Spinner color={selected ? theme.text : theme.brand} interval={120} />
|
|
273
|
+
<text content={op.label} fg={selected ? theme.text : theme.warn} wrapMode="none" />
|
|
274
|
+
</box>
|
|
275
|
+
) : op?.status === "failed" ? (
|
|
276
|
+
<text content=" op!" fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />
|
|
277
|
+
) : (
|
|
278
|
+
s.reboot_required && <text content=" ↻ rbt" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />
|
|
279
|
+
)}
|
|
280
|
+
{s.upgrade_required && <text content=" ⬆ upg" fg={selected ? theme.text : theme.warn} style={{ flexShrink: 0 }} />}
|
|
281
|
+
{isServerOsEol(s) && <text content=" ⚠ os" fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />}
|
|
282
|
+
{isSudoConnected(s.id) && <text content=" ● sudo" fg={selected ? theme.text : theme.good} style={{ flexShrink: 0 }} />}
|
|
283
|
+
{/* A server with no sites is a dead end (can't connect/SSH until it
|
|
284
|
+
has a site) — flag the 0 in amber so it's easy to spot. */}
|
|
285
|
+
<text content={" " + count} fg={selected ? theme.text : count === 0 ? theme.warn : theme.textFaint} style={{ flexShrink: 0 }} />
|
|
286
|
+
</>
|
|
287
|
+
)
|
|
288
|
+
}}
|
|
289
|
+
/>
|
|
290
|
+
</Panel>
|
|
291
|
+
|
|
292
|
+
{/* Sites pane */}
|
|
293
|
+
<Panel
|
|
294
|
+
title={
|
|
295
|
+
server
|
|
296
|
+
? ` Sites · ${truncate(server.name, 20)} (${sites.length}) 👤🔑 keys · H·C·B https/cache/backup `
|
|
297
|
+
: " Sites "
|
|
298
|
+
}
|
|
299
|
+
active={focus === "sites"}
|
|
300
|
+
flexGrow={1}
|
|
301
|
+
>
|
|
302
|
+
<List
|
|
303
|
+
items={sites}
|
|
304
|
+
selectedIndex={siteIndex}
|
|
305
|
+
viewportRows={listRows}
|
|
306
|
+
focused={focus === "sites"}
|
|
307
|
+
keyFor={(s) => s.id}
|
|
308
|
+
emptyText="No sites yet — press V to create a vanity site and connect this server"
|
|
309
|
+
renderRow={(s, selected) => {
|
|
310
|
+
const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
|
|
311
|
+
// Use the DETECTED stack (probe result) so the tag reflects `d identify`
|
|
312
|
+
// — SpinupWP's is_wordpress/git shape misclassifies some sites (e.g. a
|
|
313
|
+
// /public/ WP install shown as "app" until probed). Falls back to the
|
|
314
|
+
// API classification when unprobed. Matches the Stacks / Forgotten views.
|
|
315
|
+
const stack = effectiveStack(s, probes.get(s.id)?.result.kind)
|
|
316
|
+
const keyKinds = grantedKeyKinds(s.id)
|
|
317
|
+
// The server's own vanity/health page blends in perfectly when servers
|
|
318
|
+
// are named like domains — mark it (⌂ + brand tint) so it can't hide.
|
|
319
|
+
const vanity = !!server && isVanityPair(s.domain, server.name)
|
|
320
|
+
return (
|
|
321
|
+
<>
|
|
322
|
+
<text content={statusDot(s.status) + " "} fg={statusColor(s.status)} style={{ flexShrink: 0 }} />
|
|
323
|
+
<text
|
|
324
|
+
content={truncate((vanity ? "⌂ " : "") + s.domain, 40)}
|
|
325
|
+
fg={selected ? theme.text : vanity ? theme.brand : theme.textDim}
|
|
326
|
+
wrapMode="none"
|
|
327
|
+
style={{ flexGrow: 1, flexShrink: 1 }}
|
|
328
|
+
/>
|
|
329
|
+
<SiteStatusColumn
|
|
330
|
+
linked={localLinks.has(s.id)}
|
|
331
|
+
personalKey={keyKinds.personal > 0}
|
|
332
|
+
machineKey={keyKinds.machine > 0}
|
|
333
|
+
https={!!s.https?.enabled}
|
|
334
|
+
cache={!!s.page_cache?.enabled}
|
|
335
|
+
backup={!!(s.backups?.files || s.backups?.database)}
|
|
336
|
+
updates={updates}
|
|
337
|
+
selected={selected}
|
|
338
|
+
/>
|
|
339
|
+
{/* Fixed-width stack column (widest tag = "bedrock" = 7) so the
|
|
340
|
+
icon columns to its left, and the PHP column to its right,
|
|
341
|
+
stay vertically aligned across every row regardless of tag. */}
|
|
342
|
+
<text content={stackTag(stack).padEnd(7) + " "} fg={stackColor(stack, selected)} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
343
|
+
<PhpVersionCell version={s.php_version} upgrade={phpUpgrades.get(s.id)} selected={selected} />
|
|
344
|
+
</>
|
|
345
|
+
)
|
|
346
|
+
}}
|
|
347
|
+
/>
|
|
348
|
+
</Panel>
|
|
349
|
+
|
|
350
|
+
{/* Detail pane */}
|
|
351
|
+
<Panel title=" Details " width={44}>
|
|
352
|
+
{focus === "sites" && focusedSite ? (
|
|
353
|
+
<SiteDetail site={focusedSite} serverName={server?.name ?? "—"} />
|
|
354
|
+
) : server ? (
|
|
355
|
+
<ServerDetail server={server} siteCount={sites.length} showControl />
|
|
356
|
+
) : (
|
|
357
|
+
<text content="No data" fg={theme.textFaint} />
|
|
358
|
+
)}
|
|
359
|
+
</Panel>
|
|
360
|
+
</box>
|
|
361
|
+
<SiteContextStrip site={focus === "sites" ? focusedSite ?? null : null} />
|
|
362
|
+
<StatusBar hints={hints} message={flash ?? undefined} messageColor={theme.brand} />
|
|
363
|
+
</box>
|
|
364
|
+
)
|
|
365
|
+
}
|