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,371 @@
|
|
|
1
|
+
// Fleet composition: site stacks + PHP version spread, with Tier-2 probing.
|
|
2
|
+
//
|
|
3
|
+
// Left pane: selectable composition groups. The three top-level buckets
|
|
4
|
+
// (Standard WP / Bedrock / Non-WP) use the EFFECTIVE stack — a conclusive Tier-2
|
|
5
|
+
// probe overrides the API heuristic — so counts drift toward reality as you
|
|
6
|
+
// probe. Non-WP expands into named sub-rows (WHMCS / Laravel / Static HTML /
|
|
7
|
+
// Unknown / unprobed) from cached probes.
|
|
8
|
+
//
|
|
9
|
+
// Middle pane: the sites in the selected group. `d` probes the selected site;
|
|
10
|
+
// `D` probes the whole selected group (bounded concurrency). Right pane: the
|
|
11
|
+
// fleet-wide PHP version distribution with EOL versions flagged.
|
|
12
|
+
|
|
13
|
+
import { useEffect, useMemo, useState } from "react"
|
|
14
|
+
import { useKeyboard } from "@opentui/react"
|
|
15
|
+
import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
16
|
+
import { bar, truncate } from "../../lib/format.ts"
|
|
17
|
+
import { STACKS, effectiveStack, stackColor, type Stack } from "../../lib/stack.ts"
|
|
18
|
+
import { phpSortKey } from "../../lib/phpEol.ts"
|
|
19
|
+
import { probeKindColor, type ProbeKind } from "../../lib/probe.ts"
|
|
20
|
+
import { Panel, Spinner, PhpVersionCell, SiteMetaCell } from "../components.tsx"
|
|
21
|
+
import { List, moveSelection } from "../List.tsx"
|
|
22
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
23
|
+
import { SiteContextStrip, SITE_CONTEXT_STRIP_HEIGHT } from "../Details.tsx"
|
|
24
|
+
import { openUrl } from "../../lib/open.ts"
|
|
25
|
+
import { siteWebUrl } from "../../lib/spinupweb.ts"
|
|
26
|
+
import { useStore } from "../store.tsx"
|
|
27
|
+
import type { Site } from "../../api/types.ts"
|
|
28
|
+
|
|
29
|
+
type Focus = "groups" | "sites"
|
|
30
|
+
|
|
31
|
+
// A selectable row in the composition pane: a top-level bucket (level 0) or a
|
|
32
|
+
// Non-WP sub-category (level 1).
|
|
33
|
+
interface Group {
|
|
34
|
+
id: string
|
|
35
|
+
label: string
|
|
36
|
+
level: 0 | 1
|
|
37
|
+
color: string
|
|
38
|
+
sites: Site[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Non-WP sub-categories, in display order. `kind: null` = not yet probed.
|
|
42
|
+
const NONWP_SUBS: { kind: ProbeKind | null; label: string }[] = [
|
|
43
|
+
{ kind: "whmcs", label: "WHMCS" },
|
|
44
|
+
{ kind: "laravel", label: "Laravel" },
|
|
45
|
+
{ kind: "static", label: "Static HTML" },
|
|
46
|
+
{ kind: "unknown", label: "Unknown" },
|
|
47
|
+
{ kind: null, label: "unprobed" },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
export function Stacks({ rows }: { rows: number }) {
|
|
51
|
+
const store = useStore()
|
|
52
|
+
const { sites, serverById, route, inputMode, overlayOpen, probes, probingIds, probeErrors, runProbe, runProbeMany, isProbeStale, isPhpEol, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, setDiscoverOpen, setForgottenOpen, setForgottenStack, sshSite, setKumaSite } =
|
|
53
|
+
store
|
|
54
|
+
|
|
55
|
+
const [groupIndex, setGroupIndex] = useState(0)
|
|
56
|
+
const [siteIndex, setSiteIndex] = useState(0)
|
|
57
|
+
const [focus, setFocus] = useState<Focus>("groups")
|
|
58
|
+
const [flash, setFlash] = useState<string | null>(null)
|
|
59
|
+
|
|
60
|
+
const flashMsg = (m: string) => {
|
|
61
|
+
setFlash(m)
|
|
62
|
+
setTimeout(() => setFlash(null), 1500)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Bucket every site by EFFECTIVE stack (probe overrides Tier-1), build the
|
|
66
|
+
// selectable groups (with Non-WP sub-rows), and the fleet PHP histogram.
|
|
67
|
+
const { groups, php } = useMemo(() => {
|
|
68
|
+
const byStack = new Map<Stack, Site[]>(STACKS.map((s) => [s, []]))
|
|
69
|
+
const phpCounts = new Map<string, number>()
|
|
70
|
+
for (const site of sites) {
|
|
71
|
+
const st = effectiveStack(site, probes.get(site.id)?.result.kind)
|
|
72
|
+
byStack.get(st)!.push(site)
|
|
73
|
+
const v = site.php_version ?? "—"
|
|
74
|
+
phpCounts.set(v, (phpCounts.get(v) ?? 0) + 1)
|
|
75
|
+
}
|
|
76
|
+
for (const list of byStack.values()) list.sort((a, b) => a.domain.localeCompare(b.domain))
|
|
77
|
+
|
|
78
|
+
const groups: Group[] = []
|
|
79
|
+
for (const st of STACKS) {
|
|
80
|
+
const bucket = byStack.get(st)!
|
|
81
|
+
groups.push({ id: st, label: st, level: 0, color: stackColor(st), sites: bucket })
|
|
82
|
+
if (st === "Non-WP") {
|
|
83
|
+
for (const sub of NONWP_SUBS) {
|
|
84
|
+
const subSites = bucket.filter((site) => (probes.get(site.id)?.result.kind ?? null) === sub.kind)
|
|
85
|
+
if (subSites.length === 0) continue
|
|
86
|
+
groups.push({
|
|
87
|
+
id: `nonwp:${sub.label}`,
|
|
88
|
+
label: sub.label,
|
|
89
|
+
level: 1,
|
|
90
|
+
color: sub.kind ? probeKindColor(sub.kind) : theme.textFaint,
|
|
91
|
+
sites: subSites,
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const php = [...phpCounts.entries()].sort((a, b) => phpSortKey(b[0]) - phpSortKey(a[0]))
|
|
97
|
+
return { groups, php }
|
|
98
|
+
}, [sites, probes])
|
|
99
|
+
|
|
100
|
+
// Keep selection in range as groups appear/disappear with probing.
|
|
101
|
+
const safeGroupIndex = Math.min(groupIndex, groups.length - 1)
|
|
102
|
+
const selectedGroup = groups[safeGroupIndex]
|
|
103
|
+
const groupSites = selectedGroup?.sites ?? []
|
|
104
|
+
const total = sites.length || 1
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
setSiteIndex(0)
|
|
108
|
+
}, [safeGroupIndex])
|
|
109
|
+
|
|
110
|
+
const isActive = route === "stacks" && !inputMode && !overlayOpen
|
|
111
|
+
|
|
112
|
+
useKeyboard((key) => {
|
|
113
|
+
if (!isActive) return
|
|
114
|
+
|
|
115
|
+
// OpenTUI delivers letters lowercased with a `shift` flag (Shift+D → name
|
|
116
|
+
// "d", shift true), so normalize to an uppercase letter when shift is held
|
|
117
|
+
// before matching. This is what makes D distinct from d (and G from g).
|
|
118
|
+
const name = key.name ?? ""
|
|
119
|
+
const k = key.shift && name.length === 1 ? name.toUpperCase() : name
|
|
120
|
+
|
|
121
|
+
const moveBy = (delta: number) => {
|
|
122
|
+
if (focus === "groups") setGroupIndex((i) => moveSelection(i, delta, groups.length))
|
|
123
|
+
else setSiteIndex((i) => moveSelection(i, delta, groupSites.length))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
switch (k) {
|
|
127
|
+
case "up":
|
|
128
|
+
case "k":
|
|
129
|
+
return moveBy(-1)
|
|
130
|
+
case "down":
|
|
131
|
+
case "j":
|
|
132
|
+
return moveBy(1)
|
|
133
|
+
case "g":
|
|
134
|
+
return focus === "groups" ? setGroupIndex(0) : setSiteIndex(0)
|
|
135
|
+
case "G":
|
|
136
|
+
return focus === "groups" ? setGroupIndex(groups.length - 1) : setSiteIndex(groupSites.length - 1)
|
|
137
|
+
case "right":
|
|
138
|
+
case "l":
|
|
139
|
+
case "return":
|
|
140
|
+
case "tab":
|
|
141
|
+
if (focus === "groups" && groupSites.length > 0) setFocus("sites")
|
|
142
|
+
return
|
|
143
|
+
case "left":
|
|
144
|
+
case "escape":
|
|
145
|
+
if (focus === "sites") setFocus("groups")
|
|
146
|
+
return
|
|
147
|
+
case "o":
|
|
148
|
+
if (focus === "sites" && groupSites[siteIndex]) {
|
|
149
|
+
const s = groupSites[siteIndex]
|
|
150
|
+
openUrl((s.https?.enabled ? "https://" : "http://") + s.domain)
|
|
151
|
+
flashMsg(`Opening ${s.domain}…`)
|
|
152
|
+
}
|
|
153
|
+
return
|
|
154
|
+
case "w":
|
|
155
|
+
// Open the selected site in the SpinupWP web app.
|
|
156
|
+
if (focus === "sites" && groupSites[siteIndex]) {
|
|
157
|
+
openUrl(siteWebUrl(groupSites[siteIndex].id, accountSlug))
|
|
158
|
+
flashMsg(accountSlug ? "Opening in SpinupWP…" : "Set accountSlug for deep links — opening dashboard")
|
|
159
|
+
}
|
|
160
|
+
return
|
|
161
|
+
case "d":
|
|
162
|
+
// Single probe of the selected site (sites pane only).
|
|
163
|
+
if (focus === "sites" && groupSites[siteIndex]) {
|
|
164
|
+
const s = groupSites[siteIndex]
|
|
165
|
+
runProbe(s)
|
|
166
|
+
flashMsg(`Identifying the app on ${s.domain}…`)
|
|
167
|
+
}
|
|
168
|
+
return
|
|
169
|
+
case "u":
|
|
170
|
+
// Upgrade the selected site's PHP version (sites pane only).
|
|
171
|
+
if (focus === "sites" && groupSites[siteIndex]) setPhpUpgradeSite(groupSites[siteIndex])
|
|
172
|
+
return
|
|
173
|
+
case "H":
|
|
174
|
+
// Enable/disable HTTPS on the selected site (sites pane only).
|
|
175
|
+
if (focus === "sites" && groupSites[siteIndex]) setHttpsToggleSite(groupSites[siteIndex])
|
|
176
|
+
return
|
|
177
|
+
case "P":
|
|
178
|
+
// Purge page cache + object cache on the selected site (sites pane only).
|
|
179
|
+
if (focus === "sites" && groupSites[siteIndex]) setPurgeCacheSite(groupSites[siteIndex])
|
|
180
|
+
return
|
|
181
|
+
case "M":
|
|
182
|
+
// Site monitoring (Uptime Kuma) — capital to match Search, where
|
|
183
|
+
// lowercase m means media fallback.
|
|
184
|
+
if (focus === "sites" && groupSites[siteIndex]) setKumaSite(groupSites[siteIndex])
|
|
185
|
+
return
|
|
186
|
+
case "t":
|
|
187
|
+
// Open the selected site's local working copy in a terminal (inline; no
|
|
188
|
+
// modal — the sweep workflow). The strip below shows availability.
|
|
189
|
+
if (focus === "sites" && groupSites[siteIndex]) flashMsg(openLocalTerminal(groupSites[siteIndex].id))
|
|
190
|
+
return
|
|
191
|
+
case "v":
|
|
192
|
+
// Open the selected site's stored local URL in the browser.
|
|
193
|
+
if (focus === "sites" && groupSites[siteIndex]) flashMsg(openLocalUrl(groupSites[siteIndex].id))
|
|
194
|
+
return
|
|
195
|
+
case "s":
|
|
196
|
+
// Open a terminal and SSH into the selected site.
|
|
197
|
+
if (focus === "sites" && groupSites[siteIndex]) flashMsg(sshSite(groupSites[siteIndex].id))
|
|
198
|
+
return
|
|
199
|
+
case "L":
|
|
200
|
+
// Link / edit the selected site's local working copy (form overlay).
|
|
201
|
+
if (focus === "sites" && groupSites[siteIndex]) setLocalLinkSite(groupSites[siteIndex])
|
|
202
|
+
return
|
|
203
|
+
case "S":
|
|
204
|
+
// Scan configured roots to auto-discover & batch-link local copies.
|
|
205
|
+
return setDiscoverOpen(true)
|
|
206
|
+
case "f": {
|
|
207
|
+
// Report: sites with no usable local copy (the inverse of S). Seed the
|
|
208
|
+
// report's stack filter from the selected group — a top-level stack maps
|
|
209
|
+
// to itself; a Non-WP sub-group (WHMCS/Laravel/…) maps to Non-WP.
|
|
210
|
+
const filter: Stack | null = selectedGroup?.level === 0 ? (selectedGroup.id as Stack) : selectedGroup ? "Non-WP" : null
|
|
211
|
+
setForgottenStack(filter)
|
|
212
|
+
return setForgottenOpen(true)
|
|
213
|
+
}
|
|
214
|
+
case "D":
|
|
215
|
+
// Probe the ENTIRE selected group, in list order (top→down), regardless
|
|
216
|
+
// of cursor or focus. runProbeMany skips any already in flight; target
|
|
217
|
+
// only un-probed sites by selecting the "unprobed" sub-group first.
|
|
218
|
+
if (selectedGroup && selectedGroup.sites.length > 0) {
|
|
219
|
+
runProbeMany(selectedGroup.sites)
|
|
220
|
+
flashMsg(`Identifying apps in ${selectedGroup.label} (${selectedGroup.sites.length})…`)
|
|
221
|
+
}
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const listRows = Math.max(3, rows - 6 - SITE_CONTEXT_STRIP_HEIGHT)
|
|
227
|
+
const maxPhp = Math.max(1, ...php.map(([, n]) => n))
|
|
228
|
+
|
|
229
|
+
const hints =
|
|
230
|
+
focus === "groups"
|
|
231
|
+
? [
|
|
232
|
+
{ key: "↑↓/jk", label: "select" },
|
|
233
|
+
{ key: "→/⏎", label: "view sites" },
|
|
234
|
+
{ key: "D", label: "identify all" },
|
|
235
|
+
{ key: "S", label: "find local copies" },
|
|
236
|
+
{ key: "f", label: "needs local copy" },
|
|
237
|
+
]
|
|
238
|
+
: [
|
|
239
|
+
{ key: "↑↓/jk", label: "select site" },
|
|
240
|
+
{ key: "←/esc", label: "back" },
|
|
241
|
+
{ key: "d", label: "identify app" },
|
|
242
|
+
{ key: "u", label: "change PHP" },
|
|
243
|
+
{ key: "o", label: "open" },
|
|
244
|
+
{ key: "w", label: "SpinupWP" },
|
|
245
|
+
{ key: "s", label: "ssh" },
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
// Status priority: transient flash > batch progress > selected site's error.
|
|
249
|
+
const selectedSite = focus === "sites" ? groupSites[siteIndex] : undefined
|
|
250
|
+
const selectedError = selectedSite ? probeErrors.get(selectedSite.id) : undefined
|
|
251
|
+
const statusMessage = flash ?? (probingIds.size > 0 ? `⟳ probing ${probingIds.size}…` : selectedError ? `⚠ ${selectedError}` : undefined)
|
|
252
|
+
const statusColorMsg = selectedError && !flash && probingIds.size === 0 ? theme.bad : theme.brand
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
256
|
+
<box style={{ flexGrow: 1, flexDirection: "row", padding: 1, gap: 1 }}>
|
|
257
|
+
{/* Composition groups (top-level buckets + Non-WP sub-rows) */}
|
|
258
|
+
<Panel title={` Stacks (${sites.length}) `} active={focus === "groups"} width={36}>
|
|
259
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
260
|
+
{groups.map((grp, i) => {
|
|
261
|
+
const n = grp.sites.length
|
|
262
|
+
const selected = i === safeGroupIndex
|
|
263
|
+
const sel = selected && focus === "groups"
|
|
264
|
+
const rowBg = selected ? (focus === "groups" ? theme.selectedBg : theme.bgAlt) : undefined
|
|
265
|
+
if (grp.level === 1) {
|
|
266
|
+
// Indented sub-row: label + count (no bar).
|
|
267
|
+
return (
|
|
268
|
+
<box key={grp.id} style={{ flexDirection: "row", height: 1, backgroundColor: rowBg }}>
|
|
269
|
+
<text content=" └ " fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
270
|
+
<text
|
|
271
|
+
content={grp.label}
|
|
272
|
+
fg={sel ? theme.text : grp.color}
|
|
273
|
+
wrapMode="none"
|
|
274
|
+
style={{ flexGrow: 1, flexShrink: 1 }}
|
|
275
|
+
/>
|
|
276
|
+
<text content={String(n).padStart(4)} fg={sel ? theme.text : theme.textDim} style={{ flexShrink: 0 }} />
|
|
277
|
+
</box>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
const frac = n / total
|
|
281
|
+
return (
|
|
282
|
+
<box key={grp.id} style={{ flexDirection: "row", height: 1, backgroundColor: rowBg }}>
|
|
283
|
+
<text content={grp.label.padEnd(12)} fg={sel ? theme.text : grp.color} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
284
|
+
<text content={bar(frac, 8)} fg={sel ? theme.text : grp.color} style={{ flexShrink: 0 }} />
|
|
285
|
+
<text content={String(n).padStart(4)} fg={sel ? theme.text : theme.text} style={{ flexShrink: 0 }} />
|
|
286
|
+
<text content={`${(frac * 100).toFixed(0)}%`.padStart(5)} fg={sel ? theme.text : theme.textFaint} style={{ flexShrink: 0 }} />
|
|
287
|
+
</box>
|
|
288
|
+
)
|
|
289
|
+
})}
|
|
290
|
+
</box>
|
|
291
|
+
</Panel>
|
|
292
|
+
|
|
293
|
+
{/* Sites in the selected group */}
|
|
294
|
+
<Panel title={` ${selectedGroup?.label ?? "—"} · sites (${groupSites.length}) `} active={focus === "sites"} flexGrow={1}>
|
|
295
|
+
<List
|
|
296
|
+
items={groupSites}
|
|
297
|
+
selectedIndex={siteIndex}
|
|
298
|
+
viewportRows={listRows}
|
|
299
|
+
focused={focus === "sites"}
|
|
300
|
+
keyFor={(s) => s.id}
|
|
301
|
+
emptyText="No sites in this group"
|
|
302
|
+
renderRow={(s, selected) => {
|
|
303
|
+
const cached = probes.get(s.id)
|
|
304
|
+
const probing = probingIds.has(s.id)
|
|
305
|
+
const errored = probeErrors.has(s.id)
|
|
306
|
+
const faint = selected ? theme.text : theme.textFaint
|
|
307
|
+
const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
|
|
308
|
+
return (
|
|
309
|
+
<>
|
|
310
|
+
<text content={statusDot(s.status) + " "} fg={statusColor(s.status)} style={{ flexShrink: 0 }} />
|
|
311
|
+
<text
|
|
312
|
+
content={truncate(s.domain, 40)}
|
|
313
|
+
fg={selected ? theme.text : theme.textDim}
|
|
314
|
+
wrapMode="none"
|
|
315
|
+
style={{ flexGrow: 1, flexShrink: 1 }}
|
|
316
|
+
/>
|
|
317
|
+
<box style={{ marginLeft: 1 }}>
|
|
318
|
+
<SiteMetaCell linked={localLinks.has(s.id)} updates={updates} selected={selected} />
|
|
319
|
+
</box>
|
|
320
|
+
<box style={{ flexShrink: 0, flexDirection: "row", marginLeft: 1 }}>
|
|
321
|
+
{probing ? (
|
|
322
|
+
<Spinner color={selected ? theme.text : theme.brand} />
|
|
323
|
+
) : cached ? (
|
|
324
|
+
<text
|
|
325
|
+
content={truncate(cached.result.label, 20) + (isProbeStale(s) ? "*" : "")}
|
|
326
|
+
fg={probeKindColor(cached.result.kind, selected)}
|
|
327
|
+
wrapMode="none"
|
|
328
|
+
/>
|
|
329
|
+
) : errored ? (
|
|
330
|
+
<text content="probe failed" fg={selected ? theme.text : theme.bad} wrapMode="none" />
|
|
331
|
+
) : (
|
|
332
|
+
<text content="· press d" fg={faint} wrapMode="none" />
|
|
333
|
+
)}
|
|
334
|
+
</box>
|
|
335
|
+
<text
|
|
336
|
+
content={truncate(serverById(s.server_id)?.name ?? "", 16)}
|
|
337
|
+
fg={faint}
|
|
338
|
+
wrapMode="none"
|
|
339
|
+
style={{ flexShrink: 0, marginLeft: 1 }}
|
|
340
|
+
/>
|
|
341
|
+
<box style={{ flexShrink: 0, marginLeft: 1 }}>
|
|
342
|
+
<PhpVersionCell version={s.php_version} upgrade={phpUpgrades.get(s.id)} selected={selected} eol={isPhpEol(s.php_version)} />
|
|
343
|
+
</box>
|
|
344
|
+
</>
|
|
345
|
+
)
|
|
346
|
+
}}
|
|
347
|
+
/>
|
|
348
|
+
</Panel>
|
|
349
|
+
|
|
350
|
+
{/* PHP version distribution (fleet-wide) */}
|
|
351
|
+
<Panel title=" PHP versions " width={30}>
|
|
352
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
353
|
+
{php.map(([v, n]) => {
|
|
354
|
+
const eol = isPhpEol(v)
|
|
355
|
+
return (
|
|
356
|
+
<box key={v} style={{ flexDirection: "row", height: 1 }}>
|
|
357
|
+
<text content={v.padEnd(6)} fg={eol ? theme.bad : theme.text} style={{ flexShrink: 0 }} />
|
|
358
|
+
<text content={bar(n / maxPhp, 8)} fg={eol ? theme.bad : theme.brandDim} style={{ flexShrink: 0 }} />
|
|
359
|
+
<text content={String(n).padStart(4)} fg={theme.textDim} style={{ flexShrink: 0 }} />
|
|
360
|
+
<text content={eol ? " EOL" : ""} fg={theme.bad} style={{ flexShrink: 0 }} />
|
|
361
|
+
</box>
|
|
362
|
+
)
|
|
363
|
+
})}
|
|
364
|
+
</box>
|
|
365
|
+
</Panel>
|
|
366
|
+
</box>
|
|
367
|
+
<SiteContextStrip site={selectedSite ?? null} />
|
|
368
|
+
<StatusBar hints={hints} message={statusMessage} messageColor={statusColorMsg} />
|
|
369
|
+
</box>
|
|
370
|
+
)
|
|
371
|
+
}
|