spinuptui 0.21.1 → 0.22.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 +96 -1
- package/README.md +26 -10
- package/package.json +1 -1
- package/src/index.tsx +20 -0
- package/src/lib/cliSsh.ts +65 -19
- package/src/lib/dbSync.ts +12 -2
- package/src/lib/probe.ts +13 -3
- package/src/lib/serverClone.ts +127 -17
- package/src/lib/sshExec.ts +93 -0
- package/src/lib/sshExecLog.ts +31 -0
- package/src/lib/sshExecPolicy.ts +51 -0
- package/src/lib/stack.ts +15 -9
- package/src/ui/Details.tsx +151 -53
- package/src/ui/Onboarding.tsx +1 -1
- package/src/ui/components.tsx +84 -14
- package/src/ui/store.tsx +11 -2
- package/src/ui/views/Browser.tsx +35 -43
- package/src/ui/views/CloneWizard.tsx +9 -12
- package/src/ui/views/Search.tsx +16 -49
package/src/ui/Details.tsx
CHANGED
|
@@ -3,17 +3,18 @@
|
|
|
3
3
|
import { useEffect } from "react"
|
|
4
4
|
import { theme, statusColor, diskColor } from "../lib/theme.ts"
|
|
5
5
|
import { formatBytes, diskUsedPct, bar, formatDate, timeAgo, truncate } from "../lib/format.ts"
|
|
6
|
-
import { Field, StatusBadge, ControlPanel, type ActionGroup } from "./components.tsx"
|
|
6
|
+
import { Field, StatusBadge, ControlPanel, siteGroups, type ActionGroup } from "./components.tsx"
|
|
7
7
|
import { effectiveStack, stackColor } from "../lib/stack.ts"
|
|
8
8
|
import { probeKindColor } from "../lib/probe.ts"
|
|
9
|
-
import { resolveLocalLink } from "../lib/local.ts"
|
|
9
|
+
import { resolveLocalLink, type LocalLink } from "../lib/local.ts"
|
|
10
10
|
import { normalizeDomain } from "../lib/dns.ts"
|
|
11
|
+
import { isVanityPair } from "../lib/vanitySite.ts"
|
|
11
12
|
import type { Drift } from "../lib/gitStatus.ts"
|
|
12
13
|
import { useStore, isUpgradeInFlight, isServerOpInFlight, isHttpsToggleInFlight, isPurgeCacheInFlight, purgeCacheFailed } from "./store.tsx"
|
|
13
14
|
import type { Server, Site } from "../api/types.ts"
|
|
14
15
|
|
|
15
|
-
export function ServerDetail({ server, siteCount
|
|
16
|
-
const { rebootInfo, serverOps, isServerOsEol
|
|
16
|
+
export function ServerDetail({ server, siteCount }: { server: Server; siteCount: number }) {
|
|
17
|
+
const { rebootInfo, serverOps, isServerOsEol } = useStore()
|
|
17
18
|
const ds = server.disk_space
|
|
18
19
|
const pct = diskUsedPct(ds?.used, ds?.total)
|
|
19
20
|
const op = serverOps.get(server.id)
|
|
@@ -74,48 +75,53 @@ export function ServerDetail({ server, siteCount, showControl = false }: { serve
|
|
|
74
75
|
valueColor={server.upgrade_required ? theme.warn : theme.good}
|
|
75
76
|
/>
|
|
76
77
|
<Field label="Created" value={formatDate(server.created_at)} />
|
|
77
|
-
|
|
78
|
-
{/* Server Control — mirrors Search's Site Control. Only rendered where the keys
|
|
79
|
-
actually fire (the Browser's Servers pane), via showControl; Search's query
|
|
80
|
-
mode passes it off and surfaces the pressable list in its actions focus. */}
|
|
81
|
-
{showControl ? (
|
|
82
|
-
<>
|
|
83
|
-
<box style={{ height: 1 }} />
|
|
84
|
-
<ControlPanel heading="Server Control" groups={serverControlGroups()} />
|
|
85
|
-
</>
|
|
86
|
-
) : null}
|
|
87
78
|
</box>
|
|
88
79
|
)
|
|
80
|
+
}
|
|
89
81
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const hasVanity = sitesForServer(server.id).some((s) => s.domain.toLowerCase() === server.name.toLowerCase())
|
|
97
|
-
if (!resumable && hasVanity) return SERVER_CONTROL
|
|
98
|
-
const vanityItem: [string, string] = ["V", resumable ? "Resume vanity-site build" : "Vanity site at hostname"]
|
|
99
|
-
return SERVER_CONTROL.map((g) => (g.title === "Manage" ? { ...g, items: [...g.items, vanityItem] } : g))
|
|
100
|
-
}
|
|
82
|
+
// Server Control groups, rendered by the Browser's bottom ControlStrip (layout
|
|
83
|
+
// "flow") or Search's Actions pane (layout "list", the default). Search's
|
|
84
|
+
// query-focus Details still shows ServerDetail without this.
|
|
85
|
+
export function ServerControl({ server, layout }: { server: Server; layout?: "list" | "flow" }) {
|
|
86
|
+
const { vanityJob, sitesForServer } = useStore()
|
|
87
|
+
return <ControlPanel heading="Server Control" groups={serverControlGroups(server, vanityJob, sitesForServer)} layout={layout} />
|
|
101
88
|
}
|
|
102
89
|
|
|
90
|
+
// The static groups, plus a Manage "V" row whenever the V key would actually do
|
|
91
|
+
// something: the server has no site at its own hostname yet (busy servers
|
|
92
|
+
// benefit from a vanity site just like empty ones), or an unfinished build for
|
|
93
|
+
// this server can be reopened.
|
|
94
|
+
function serverControlGroups(
|
|
95
|
+
server: Server,
|
|
96
|
+
vanityJob: ReturnType<typeof useStore>["vanityJob"],
|
|
97
|
+
sitesForServer: ReturnType<typeof useStore>["sitesForServer"],
|
|
98
|
+
): ActionGroup[] {
|
|
99
|
+
const resumable = vanityJob != null && vanityJob.step !== "done" && vanityJob.serverId === server.id
|
|
100
|
+
const hasVanity = sitesForServer(server.id).some((s) => s.domain.toLowerCase() === server.name.toLowerCase())
|
|
101
|
+
if (!resumable && hasVanity) return SERVER_CONTROL
|
|
102
|
+
const vanityItem: [string, string] = ["V", resumable ? "Resume vanity build" : "Vanity at hostname"]
|
|
103
|
+
return SERVER_CONTROL.map((g) => (g.title === "Manage" ? { ...g, items: [...g.items, vanityItem] } : g))
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Labels stay terse and avoid repeating their group's title — see siteGroups().
|
|
103
107
|
export const SERVER_CONTROL: ActionGroup[] = [
|
|
104
|
-
{ title: "Clone", items: [["C", "Clone
|
|
105
|
-
{ title: "Access", items: [["S", "
|
|
108
|
+
{ title: "Clone", items: [["C", "Clone →"]] },
|
|
109
|
+
{ title: "Access", items: [["S", "Sudo"]] },
|
|
106
110
|
{
|
|
107
111
|
title: "Manage",
|
|
108
112
|
items: [
|
|
109
|
-
["a", "Reboot / restart
|
|
110
|
-
["h", "
|
|
111
|
-
["N", "DNS
|
|
113
|
+
["a", "Reboot / restart"],
|
|
114
|
+
["h", "Health"],
|
|
115
|
+
["N", "DNS"],
|
|
112
116
|
],
|
|
113
117
|
},
|
|
114
|
-
{ title: "Open", items: [["w", "
|
|
118
|
+
{ title: "Open", items: [["w", "In SpinupWP"]] },
|
|
115
119
|
]
|
|
116
120
|
|
|
117
121
|
export function SiteDetail({ site, serverName }: { site: Site; serverName: string }) {
|
|
118
|
-
const { probes, probingIds, isProbeStale, phpUpgrades, httpsToggles, purgeCacheProgress, localLinks, grantedKeyKinds, kumaStatus, kumaMonitorFor } = useStore()
|
|
122
|
+
const { probes, probingIds, isProbeStale, phpUpgrades, httpsToggles, purgeCacheProgress, localLinks, grantedKeyKinds, kumaStatus, kumaMonitorFor, kumaOps, localSync } = useStore()
|
|
123
|
+
const isVanity = isVanityPair(site.domain, serverName)
|
|
124
|
+
const reseedOp = kumaOps.get(site.id)
|
|
119
125
|
const kuma = kumaStatus.get(site.domain)
|
|
120
126
|
// The fingerprint check failing while HTTP is up means the site is answering
|
|
121
127
|
// 200 with the WRONG page (stale/corrupt page cache) — a distinct, worse state
|
|
@@ -195,6 +201,8 @@ export function SiteDetail({ site, serverName }: { site: Site; serverName: strin
|
|
|
195
201
|
<Field label="Granted" value={keyValue} valueColor={keyParts.length ? theme.good : theme.textFaint} />
|
|
196
202
|
<Field label="Public dir" value={site.public_folder ?? "/"} />
|
|
197
203
|
<Field label="Monitor" value={kumaValue} valueColor={kumaColor} />
|
|
204
|
+
{isVanity && reseedOp?.status === "running" && <Field label="Refresh" value="publishing…" valueColor={theme.warn} />}
|
|
205
|
+
{isVanity && reseedOp?.status === "error" && <Field label="Refresh" value={`failed: ${reseedOp.error ?? "unknown error"}`} valueColor={theme.bad} />}
|
|
198
206
|
<Field label="Local" value={linkValue} valueColor={linkColor} />
|
|
199
207
|
<SiteDnsSection site={site} />
|
|
200
208
|
<Field
|
|
@@ -265,6 +273,16 @@ export function SiteDetail({ site, serverName }: { site: Site; serverName: strin
|
|
|
265
273
|
)
|
|
266
274
|
}
|
|
267
275
|
|
|
276
|
+
// Site Control groups — see ServerControl above. Recomputes stack/vanity
|
|
277
|
+
// itself since it's not a child of SiteDetail; both derivations are cheap.
|
|
278
|
+
export function SiteControl({ site, serverName, layout }: { site: Site; serverName: string; layout?: "list" | "flow" }) {
|
|
279
|
+
const { probes, localSync } = useStore()
|
|
280
|
+
const stack = effectiveStack(site, probes.get(site.id)?.result.kind)
|
|
281
|
+
const isWordPress = stack !== "Non-WP"
|
|
282
|
+
const isVanity = isVanityPair(site.domain, serverName)
|
|
283
|
+
return <ControlPanel heading="Site Control" groups={siteGroups(isWordPress, localSync, isVanity)} layout={layout} />
|
|
284
|
+
}
|
|
285
|
+
|
|
268
286
|
// DNS zone-host lines for a site's domains, populated on demand (key `n`). Each
|
|
269
287
|
// distinct zone (www + apex collapsed) shows its resolved host; a separate-TLD
|
|
270
288
|
// additional domain surfaces as its own line with its own host. Read-only.
|
|
@@ -325,6 +343,38 @@ function Act({ keyName, label, on }: { keyName: string; label: string; on: boole
|
|
|
325
343
|
)
|
|
326
344
|
}
|
|
327
345
|
|
|
346
|
+
// Shared status line: the selected site's local-link state (not linked / missing
|
|
347
|
+
// / linked, with path + local URL + git drift). Used by both SiteContextStrip
|
|
348
|
+
// (Stacks — status + inline t/v/L only) and ControlStrip (Browser — status +
|
|
349
|
+
// the full action-group list) so the two don't drift apart.
|
|
350
|
+
function siteLinkStatusLine(site: Site, link: LocalLink | undefined, state: ReturnType<typeof resolveLocalLink> | null, d: Drift | null | undefined) {
|
|
351
|
+
if (!link) {
|
|
352
|
+
return (
|
|
353
|
+
<box style={{ flexDirection: "row" }}>
|
|
354
|
+
<text content={truncate(site.domain, 44)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
355
|
+
<text content=" not linked" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
356
|
+
</box>
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
if (!state!.exists) {
|
|
360
|
+
return (
|
|
361
|
+
<box style={{ flexDirection: "row" }}>
|
|
362
|
+
<text content="missing " fg={theme.bad} style={{ flexShrink: 0 }} />
|
|
363
|
+
<text content={link.path} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
364
|
+
</box>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
return (
|
|
368
|
+
<box style={{ flexDirection: "row" }}>
|
|
369
|
+
<text content={state!.label} fg={localLabelColor(state!.kind)} style={{ flexShrink: 0 }} />
|
|
370
|
+
<text content=" · " fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
371
|
+
<text content={link.path} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
372
|
+
{link.localUrl ? <text content={" " + link.localUrl} fg={theme.accent} wrapMode="none" style={{ flexShrink: 1 }} /> : null}
|
|
373
|
+
{d && (d.ahead > 0 || d.dirty) ? <text content={" " + driftLabel(d)} fg={theme.warn} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
|
|
374
|
+
</box>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
328
378
|
// Height (incl. border) of the contextual strip — views subtract this from their
|
|
329
379
|
// list viewport so the panes shrink to make room. Border eats 2 rows → 2 content
|
|
330
380
|
// lines (status + actions).
|
|
@@ -335,6 +385,10 @@ export const SITE_CONTEXT_STRIP_HEIGHT = 4
|
|
|
335
385
|
// open-locally actions (which the host view's keyboard fires on the selection).
|
|
336
386
|
// The space is always reserved (a placeholder when nothing is selected) so the
|
|
337
387
|
// layout doesn't jump as the cursor moves.
|
|
388
|
+
//
|
|
389
|
+
// Used by views (Stacks) whose keyboard handler only implements a subset of
|
|
390
|
+
// siteGroups()'s keys — ControlStrip below, which advertises the FULL action
|
|
391
|
+
// list, would be misleading there (hints for keys that don't actually fire).
|
|
338
392
|
export function SiteContextStrip({ site }: { site: Site | null }) {
|
|
339
393
|
const { localLinks, drift, ensureDrift } = useStore()
|
|
340
394
|
const link = site ? localLinks.get(site.id) : undefined
|
|
@@ -364,28 +418,8 @@ export function SiteContextStrip({ site }: { site: Site | null }) {
|
|
|
364
418
|
<text content="↑N " fg={theme.warn} style={{ flexShrink: 0 }} />
|
|
365
419
|
<text content="pending WordPress updates" fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
366
420
|
</box>
|
|
367
|
-
) : !link ? (
|
|
368
|
-
<box style={{ flexDirection: "row" }}>
|
|
369
|
-
<text content={truncate(site.domain, 44)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
370
|
-
<text content=" not linked" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
371
|
-
</box>
|
|
372
|
-
) : !state!.exists ? (
|
|
373
|
-
<box style={{ flexDirection: "row" }}>
|
|
374
|
-
<text content="missing " fg={theme.bad} style={{ flexShrink: 0 }} />
|
|
375
|
-
<text content={link.path} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
376
|
-
</box>
|
|
377
421
|
) : (
|
|
378
|
-
|
|
379
|
-
<text content={state!.label} fg={localLabelColor(state!.kind)} style={{ flexShrink: 0 }} />
|
|
380
|
-
<text content=" · " fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
381
|
-
<text content={link.path} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
382
|
-
{link.localUrl ? (
|
|
383
|
-
<text content={" " + link.localUrl} fg={theme.accent} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
384
|
-
) : null}
|
|
385
|
-
{d && (d.ahead > 0 || d.dirty) ? (
|
|
386
|
-
<text content={" " + driftLabel(d)} fg={theme.warn} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
387
|
-
) : null}
|
|
388
|
-
</box>
|
|
422
|
+
siteLinkStatusLine(site, link, state, d)
|
|
389
423
|
)}
|
|
390
424
|
|
|
391
425
|
{/* Line 2 — inline actions (fired by the host view on the selection) */}
|
|
@@ -401,3 +435,67 @@ export function SiteContextStrip({ site }: { site: Site | null }) {
|
|
|
401
435
|
</box>
|
|
402
436
|
)
|
|
403
437
|
}
|
|
438
|
+
|
|
439
|
+
// Height (incl. border) of the bottom control strip — views subtract this from
|
|
440
|
+
// their list viewport so the panes shrink to make room. `ControlPanel`'s
|
|
441
|
+
// layout="flow" skips its own heading (the strip's box title already says
|
|
442
|
+
// "Site Control"/"Server Control") and puts each group's title in its own
|
|
443
|
+
// column rather than its own line — both to keep this budget small even
|
|
444
|
+
// though every group also gets a 1-row marginBottom for breathing room.
|
|
445
|
+
// Sized for the worst realistic case (a WP + local-sync + vanity site — 5
|
|
446
|
+
// groups: Open/Remote/Local/Vanity/Server, each ≤1 line of items given how
|
|
447
|
+
// terse the labels are + its trailing blank row): 2 border + 1 status + 1
|
|
448
|
+
// spacer + 5 groups × 2 (content + blank). A lighter site/server just leaves
|
|
449
|
+
// blank rows — fixed and generous beats measuring actual wrapped height,
|
|
450
|
+
// which would need a measure-then-rerender pass this app has no precedent for.
|
|
451
|
+
export const CONTROL_STRIP_HEIGHT = 14
|
|
452
|
+
|
|
453
|
+
// A full-width strip below the three panes (Browser's Servers tab only — see
|
|
454
|
+
// the SiteContextStrip note above for why) showing whichever action list
|
|
455
|
+
// applies to what's focused: the selected site's Site Control (status line +
|
|
456
|
+
// Open/Remote/Local/Vanity/Server, `layout="flow"`) or, with focus on the
|
|
457
|
+
// Servers pane, the selected server's Server Control (status line + Clone/
|
|
458
|
+
// Access/Manage/Open). The space is always reserved (a placeholder when
|
|
459
|
+
// nothing is selected) so the layout doesn't jump as the cursor moves.
|
|
460
|
+
export function ControlStrip({ site, server, serverName }: { site: Site | null; server: Server | null; serverName: string }) {
|
|
461
|
+
const { localLinks, drift, ensureDrift } = useStore()
|
|
462
|
+
const link = site ? localLinks.get(site.id) : undefined
|
|
463
|
+
const state = link ? resolveLocalLink(link) : null
|
|
464
|
+
const d = site ? drift.get(site.id) : undefined
|
|
465
|
+
|
|
466
|
+
// Auto-compute (and cache) git drift when a linked, on-disk copy is shown.
|
|
467
|
+
useEffect(() => {
|
|
468
|
+
if (site && link && state?.exists) ensureDrift(site.id, link.path)
|
|
469
|
+
}, [site?.id, link?.path, state?.exists, ensureDrift])
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<box
|
|
473
|
+
title={site ? " Site Control " : server ? " Server Control " : " Control "}
|
|
474
|
+
titleColor={theme.brand}
|
|
475
|
+
border
|
|
476
|
+
borderColor={link && !state!.exists ? theme.bad : theme.border}
|
|
477
|
+
style={{ height: CONTROL_STRIP_HEIGHT, flexDirection: "column", paddingLeft: 1, paddingRight: 1 }}
|
|
478
|
+
>
|
|
479
|
+
{/* Status line */}
|
|
480
|
+
{site ? (
|
|
481
|
+
siteLinkStatusLine(site, link, state, d)
|
|
482
|
+
) : server ? (
|
|
483
|
+
<box style={{ flexDirection: "row" }}>
|
|
484
|
+
<text content={truncate(server.name, 44)} fg={theme.text} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
485
|
+
<text content={" " + (server.ip_address ?? "—")} fg={theme.accent} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
486
|
+
<text content={" · " + server.connection_status} fg={statusColor(server.connection_status)} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
487
|
+
</box>
|
|
488
|
+
) : (
|
|
489
|
+
<text content="Select a server or site to see what you can do with it" fg={theme.textFaint} wrapMode="none" />
|
|
490
|
+
)}
|
|
491
|
+
<box style={{ height: 1 }} />
|
|
492
|
+
|
|
493
|
+
{/* Action groups */}
|
|
494
|
+
{site ? (
|
|
495
|
+
<SiteControl site={site} serverName={serverName} layout="flow" />
|
|
496
|
+
) : server ? (
|
|
497
|
+
<ServerControl server={server} layout="flow" />
|
|
498
|
+
) : null}
|
|
499
|
+
</box>
|
|
500
|
+
)
|
|
501
|
+
}
|
package/src/ui/Onboarding.tsx
CHANGED
|
@@ -55,7 +55,7 @@ export function Onboarding({ onComplete }: { onComplete: () => void }) {
|
|
|
55
55
|
>
|
|
56
56
|
<text content="Let's connect this tool to your SpinupWP account." fg={theme.text} />
|
|
57
57
|
<box style={{ height: 1 }} />
|
|
58
|
-
<text content="1.
|
|
58
|
+
<text content="1. In SpinupWP: Settings → API Tokens" fg={theme.textDim} />
|
|
59
59
|
<text content="2. Create a token (Read Only to browse; Read/Write to upgrade PHP)" fg={theme.textDim} />
|
|
60
60
|
<text content="3. Paste it below and press Enter" fg={theme.textDim} />
|
|
61
61
|
<box style={{ height: 1 }} />
|
package/src/ui/components.tsx
CHANGED
|
@@ -368,25 +368,95 @@ export function Centered({ children }: { children: ReactNode }) {
|
|
|
368
368
|
)
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
// Widest group title ("REMOTE"/"SERVER"/"VANITY"/"ACCESS"/"MANAGE" = 6 chars)
|
|
372
|
+
// plus a little padding — the fixed label column width for layout="flow".
|
|
373
|
+
const FLOW_LABEL_WIDTH = 8
|
|
374
|
+
|
|
371
375
|
// A grouped, key-chipped action legend ("Site Control" / "Server Control"): a heading,
|
|
372
|
-
// then dim group titles each over
|
|
373
|
-
// actions pane and the Servers
|
|
376
|
+
// then dim group titles each over its `key → outcome` items. Shared by the Search
|
|
377
|
+
// actions pane and the Servers tab's bottom Control strip so the suite reads the
|
|
378
|
+
// same everywhere. `layout="list"` (default) puts the title on its own line above
|
|
379
|
+
// one item per line — right for a narrow column. `layout="flow"` puts the title in
|
|
380
|
+
// its own fixed-width left column (`alignItems: "flex-start"` keeps it pinned to
|
|
381
|
+
// the top rather than stretching/centering when the row wraps) with items flowing
|
|
382
|
+
// and wrapping in the remaining width — right for a full-width strip, where a long
|
|
383
|
+
// group (e.g. REMOTE) can fit several items per line instead of one. Each item is
|
|
384
|
+
// always its own box so "flow" wraps between whole items, never mid-label.
|
|
374
385
|
export type ActionGroup = { title: string; items: [string, string][] }
|
|
375
|
-
export function ControlPanel({ heading, groups }: { heading: string; groups: ActionGroup[] }) {
|
|
386
|
+
export function ControlPanel({ heading, groups, layout = "list" }: { heading: string; groups: ActionGroup[]; layout?: "list" | "flow" }) {
|
|
376
387
|
return (
|
|
377
388
|
<box style={{ flexDirection: "column" }}>
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
<text content={
|
|
389
|
+
{/* "flow" is only ever used inside a bordered box whose own title already
|
|
390
|
+
says "Site Control"/"Server Control" — skip the redundant heading line. */}
|
|
391
|
+
{layout === "list" && <text content={heading} fg={theme.accent} />}
|
|
392
|
+
{groups.map((group) =>
|
|
393
|
+
layout === "flow" ? (
|
|
394
|
+
<box key={group.title} style={{ flexDirection: "row", alignItems: "flex-start", marginBottom: 1 }}>
|
|
395
|
+
<box style={{ width: FLOW_LABEL_WIDTH, flexShrink: 0 }}>
|
|
396
|
+
<text content={group.title.toUpperCase()} fg={theme.textFaint} wrapMode="none" />
|
|
386
397
|
</box>
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
398
|
+
<box style={{ flexDirection: "row", flexWrap: "wrap", flexGrow: 1 }}>
|
|
399
|
+
{group.items.map(([k, label]) => (
|
|
400
|
+
<box key={k} style={{ flexDirection: "row", marginRight: 2 }}>
|
|
401
|
+
<text content={` ${k} `} fg={theme.bg} bg={theme.brandDim} style={{ flexShrink: 0 }} />
|
|
402
|
+
<text content={` ${label}`} fg={theme.text} wrapMode="none" />
|
|
403
|
+
</box>
|
|
404
|
+
))}
|
|
405
|
+
</box>
|
|
406
|
+
</box>
|
|
407
|
+
) : (
|
|
408
|
+
<box key={group.title} style={{ flexDirection: "column" }}>
|
|
409
|
+
<text content={group.title.toUpperCase()} fg={theme.textFaint} wrapMode="none" />
|
|
410
|
+
{group.items.map(([k, label]) => (
|
|
411
|
+
<box key={k} style={{ flexDirection: "row" }}>
|
|
412
|
+
<text content={` ${k} `} fg={theme.bg} bg={theme.brandDim} style={{ flexShrink: 0 }} />
|
|
413
|
+
<text content={` ${label}`} fg={theme.text} wrapMode="none" />
|
|
414
|
+
</box>
|
|
415
|
+
))}
|
|
416
|
+
</box>
|
|
417
|
+
),
|
|
418
|
+
)}
|
|
390
419
|
</box>
|
|
391
420
|
)
|
|
392
421
|
}
|
|
422
|
+
|
|
423
|
+
// Site Control groups for ControlPanel — shared by Search's Actions pane and the
|
|
424
|
+
// Browser tab's Details pane so the suite reads the same, and is discoverable,
|
|
425
|
+
// everywhere a site can be selected. Labels are kept terse and avoid repeating
|
|
426
|
+
// their group's title (e.g. "In browser" under OPEN, not "Open site in browser")
|
|
427
|
+
// since the group label is always visible right next to them (as its own column
|
|
428
|
+
// in layout="flow", or its own line in layout="list").
|
|
429
|
+
//
|
|
430
|
+
// The DB backup/sync use wp-cli, so they only apply to WordPress sites — omitted
|
|
431
|
+
// for non-WP sites (their `d`/`p` keypresses are also guarded in the handler). DNS
|
|
432
|
+
// (`n`) and the rest apply to any site. `isVanity` (the site's domain equals its
|
|
433
|
+
// own server's name, per `isVanityPair`) adds the one-off page-refresh action —
|
|
434
|
+
// vanity pages seeded before this app existed need a way to pick up the current
|
|
435
|
+
// bundled HTML.
|
|
436
|
+
export function siteGroups(isWordpress: boolean, localSync: boolean, isVanity: boolean): ActionGroup[] {
|
|
437
|
+
const remote: [string, string][] = [
|
|
438
|
+
["s", "SSH"],
|
|
439
|
+
["n", "DNS"],
|
|
440
|
+
]
|
|
441
|
+
if (isWordpress) {
|
|
442
|
+
remote.push(["d", "↓ DB backup"])
|
|
443
|
+
if (localSync) remote.push(["p", "Pull prod. DB"])
|
|
444
|
+
}
|
|
445
|
+
const local: [string, string][] = [
|
|
446
|
+
["t", "Terminal"],
|
|
447
|
+
["v", "Local URL"],
|
|
448
|
+
["L", "Link / edit"],
|
|
449
|
+
]
|
|
450
|
+
if (isWordpress) local.push(["m", "Media fallback"])
|
|
451
|
+
remote.push(["u", "PHP ver."])
|
|
452
|
+
remote.push(["H", "Toggle HTTPS"])
|
|
453
|
+
remote.push(["P", "Purge cache"])
|
|
454
|
+
remote.push(["M", "Monitoring"])
|
|
455
|
+
return [
|
|
456
|
+
{ title: "Open", items: [["o", "In browser"], ["w", "In SpinupWP"]] },
|
|
457
|
+
{ title: "Remote", items: remote },
|
|
458
|
+
{ title: "Local", items: local },
|
|
459
|
+
...(isVanity ? [{ title: "Vanity", items: [["R", "Refresh"]] as [string, string][] }] : []),
|
|
460
|
+
{ title: "Server", items: [["a", "Reboot / restart"], ["h", "Health"]] },
|
|
461
|
+
]
|
|
462
|
+
}
|
package/src/ui/store.tsx
CHANGED
|
@@ -45,7 +45,7 @@ import { withKuma, KumaClient, registerMonitors, registerFingerprintMonitor, reg
|
|
|
45
45
|
import { deriveFingerprint } from "../lib/siteFingerprint.ts"
|
|
46
46
|
import type { StoredProviders } from "../config.ts"
|
|
47
47
|
import { fetchRebootInfo, grantSiteSshKey, revokeSiteSshKey, verifySudo, ensureSpinupKey, listPersonalKeys, keyBody, type RebootInfo } from "../lib/ssh.ts"
|
|
48
|
-
import { estimateSourceSiteSizes, runStandardWpPull, runBedrockPull, runFilesOnlyPull, verifyClone, verifyFilesClone, type SudoCtx, type CloneStage, type CloneExecRecord, type VerifyResult as CloneVerifyResult } from "../lib/serverClone.ts"
|
|
48
|
+
import { estimateSourceSiteSizes, runStandardWpPull, runBedrockPull, runFilesOnlyPull, verifyClone, verifyFilesClone, preflightBedrockSource, type SudoCtx, type CloneStage, type CloneExecRecord, type VerifyResult as CloneVerifyResult } from "../lib/serverClone.ts"
|
|
49
49
|
import { CloneLogger } from "../lib/cloneLog.ts"
|
|
50
50
|
import { syncAdditionalDomains } from "../lib/cloneDomains.ts"
|
|
51
51
|
import { parseRepo, deployKeysSettingsUrl, ghAvailable, ghDeployKeyPresent, ghAddDeployKey, generateDeployKeypair, type RepoHost } from "../lib/gitDeployKey.ts"
|
|
@@ -3679,6 +3679,15 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3679
3679
|
}
|
|
3680
3680
|
try {
|
|
3681
3681
|
if (site.stack === "bedrock" && !site.gitRepo) return fail("create", "the source Bedrock site has no git repo to clone from.")
|
|
3682
|
+
// Cheap pre-flight, before creating anything: catches a source whose actual
|
|
3683
|
+
// files don't match its own configured Public Folder — the same mismatch
|
|
3684
|
+
// runBedrockPull's own detect step would hit later, just before wasting a
|
|
3685
|
+
// site-creation + auth cycle on a clone that can't succeed. Not a guarantee
|
|
3686
|
+
// (see preflightBedrockSource) — the real check is still the one after create.
|
|
3687
|
+
if (site.stack === "bedrock") {
|
|
3688
|
+
const pre = await preflightBedrockSource(source, { domain: site.domain, publicFolder: site.publicFolder })
|
|
3689
|
+
if (!pre.ok) return fail("create", pre.error)
|
|
3690
|
+
}
|
|
3682
3691
|
// create — Bedrock → `git` site (SpinupWP clones the repo); Standard WP →
|
|
3683
3692
|
// blank + database; files-only → blank with NO database (nothing to import).
|
|
3684
3693
|
set((s) => ({ ...s, step: "create", error: undefined, failedStep: undefined, detail: undefined, stageStartedAt: Date.now() }))
|
|
@@ -3803,7 +3812,7 @@ export function StoreProvider({ children }: { children: ReactNode }) {
|
|
|
3803
3812
|
const onExec = logger ? (e: CloneExecRecord) => logger.log({ event: "exec", ...e }) : undefined
|
|
3804
3813
|
const res =
|
|
3805
3814
|
site.stack === "bedrock"
|
|
3806
|
-
? await runBedrockPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, excludeUploads: site.excludeUploads }, onProgress, onExec, onTransfer)
|
|
3815
|
+
? await runBedrockPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, excludeUploads: site.excludeUploads, publicFolder: site.publicFolder }, onProgress, onExec, onTransfer)
|
|
3807
3816
|
: site.stack === "files"
|
|
3808
3817
|
? await runFilesOnlyPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, approxFilesBytes: site.sizeWebBytes }, onProgress, onExec, onTransfer)
|
|
3809
3818
|
: await runStandardWpPull(source, dest, { domain: site.domain, sourceSiteUser: site.siteUser, destSiteUser: site.siteUser, destDbName: dbName, destDbUser: dbName, destDbPassword: dbPw, publicFolder: site.publicFolder, approxFilesBytes: site.sizeWebBytes }, onProgress, onExec, onTransfer)
|
package/src/ui/views/Browser.tsx
CHANGED
|
@@ -10,9 +10,9 @@ import { useKeyboard } from "@opentui/react"
|
|
|
10
10
|
import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
11
11
|
import { effectiveStack, stackColor, stackTag } from "../../lib/stack.ts"
|
|
12
12
|
import { truncate } from "../../lib/format.ts"
|
|
13
|
-
import { Panel, PhpVersionCell, Spinner
|
|
13
|
+
import { Panel, PhpVersionCell, Spinner } from "../components.tsx"
|
|
14
14
|
import { List, moveSelection } from "../List.tsx"
|
|
15
|
-
import { ServerDetail, SiteDetail,
|
|
15
|
+
import { ServerDetail, SiteDetail, ControlStrip, CONTROL_STRIP_HEIGHT } from "../Details.tsx"
|
|
16
16
|
import { StatusBar } from "../StatusBar.tsx"
|
|
17
17
|
import { openUrl } from "../../lib/open.ts"
|
|
18
18
|
import { serverWebUrl, siteWebUrl } from "../../lib/spinupweb.ts"
|
|
@@ -23,7 +23,7 @@ type Focus = "servers" | "sites"
|
|
|
23
23
|
|
|
24
24
|
export function Browser({ rows }: { rows: number }) {
|
|
25
25
|
const store = useStore()
|
|
26
|
-
const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setGrantKeySite, setSudoConnectServer, isSudoConnected,
|
|
26
|
+
const { servers, sitesForServer, route, inputMode, overlayOpen, setHealthServer, setWpInventorySite, runProbe, probes, accountSlug, setPhpUpgradeSite, phpUpgrades, setHttpsToggleSite, setPurgeCacheSite, setGrantKeySite, setSudoConnectServer, isSudoConnected, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed } = store
|
|
27
27
|
|
|
28
28
|
const [serverIndex, setServerIndex] = useState(0)
|
|
29
29
|
const [siteIndex, setSiteIndex] = useState(0)
|
|
@@ -117,6 +117,17 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
117
117
|
// is taken), so the capital works everywhere.
|
|
118
118
|
if (focus === "sites" && sites[siteIndex]) setKumaSite(sites[siteIndex])
|
|
119
119
|
return
|
|
120
|
+
case "R": {
|
|
121
|
+
// Refresh a vanity page's HTML — only the one site per server whose
|
|
122
|
+
// domain equals the server's own name. Same binding/behavior as the
|
|
123
|
+
// m-overlay's R (KumaSite.tsx): fires directly, no confirm, since it
|
|
124
|
+
// just republishes the same bundled page that's already live.
|
|
125
|
+
const s = sites[siteIndex]
|
|
126
|
+
if (focus === "sites" && s && server && isVanityPair(s.domain, server.name)) {
|
|
127
|
+
return kumaConfigured ? startKumaSetup(s, { reseed: true }) : startVanityReseed(s)
|
|
128
|
+
}
|
|
129
|
+
return
|
|
130
|
+
}
|
|
120
131
|
case "K":
|
|
121
132
|
// Grant Spinup's machine key to the selected site over SSH (privileged
|
|
122
133
|
// write via the server's sudo user — the API can't do this). Capital K
|
|
@@ -217,41 +228,32 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
217
228
|
}
|
|
218
229
|
})
|
|
219
230
|
|
|
220
|
-
const listRows = Math.max(3, rows - 6 -
|
|
231
|
+
const listRows = Math.max(3, rows - 6 - CONTROL_STRIP_HEIGHT)
|
|
221
232
|
const focusedSite = sites[Math.min(siteIndex, Math.max(0, sites.length - 1))]
|
|
222
233
|
|
|
223
234
|
const hints =
|
|
224
235
|
focus === "servers"
|
|
225
236
|
? [
|
|
237
|
+
// The Control strip's Server Control list now shows every per-server
|
|
238
|
+
// key (S/a/N/h/w) — only nav + list-level actions stay here so the
|
|
239
|
+
// two can't drift out of sync. `c` isn't in Server Control (it's not
|
|
240
|
+
// an action ON the selected server, it creates a new one).
|
|
226
241
|
{ key: "↑↓/jk", label: "select" },
|
|
227
242
|
{ key: "→/⏎", label: "view sites" },
|
|
228
243
|
{ 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
244
|
]
|
|
235
245
|
: [
|
|
246
|
+
// The Control strip's Site Control list now shows every per-site
|
|
247
|
+
// key — this footer is nav-only so the two can't drift out of sync.
|
|
236
248
|
{ key: "↑↓/jk", label: "select site" },
|
|
237
249
|
{ 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
250
|
]
|
|
249
251
|
|
|
250
252
|
return (
|
|
251
253
|
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
252
254
|
<box style={{ flexGrow: 1, flexDirection: "row", padding: 1, gap: 1 }}>
|
|
253
255
|
{/* Servers pane */}
|
|
254
|
-
<Panel title={` Servers (${sortedServers.length}) `} active={focus === "servers"} width=
|
|
256
|
+
<Panel title={` Servers (${sortedServers.length}) `} active={focus === "servers"} width="18%">
|
|
255
257
|
<List
|
|
256
258
|
items={sortedServers}
|
|
257
259
|
selectedIndex={serverIndex}
|
|
@@ -291,11 +293,7 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
291
293
|
|
|
292
294
|
{/* Sites pane */}
|
|
293
295
|
<Panel
|
|
294
|
-
title={
|
|
295
|
-
server
|
|
296
|
-
? ` Sites · ${truncate(server.name, 20)} (${sites.length}) 👤🔑 keys · H·C·B https/cache/backup `
|
|
297
|
-
: " Sites "
|
|
298
|
-
}
|
|
296
|
+
title={server ? ` Sites · ${truncate(server.name, 20)} (${sites.length}) ` : " Sites "}
|
|
299
297
|
active={focus === "sites"}
|
|
300
298
|
flexGrow={1}
|
|
301
299
|
>
|
|
@@ -307,13 +305,11 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
307
305
|
keyFor={(s) => s.id}
|
|
308
306
|
emptyText="No sites yet — press V to create a vanity site and connect this server"
|
|
309
307
|
renderRow={(s, selected) => {
|
|
310
|
-
const updates = (s.wp_plugin_updates || 0) + (s.wp_theme_updates || 0) + (s.wp_core_update ? 1 : 0)
|
|
311
308
|
// Use the DETECTED stack (probe result) so the tag reflects `d identify`
|
|
312
309
|
// — SpinupWP's is_wordpress/git shape misclassifies some sites (e.g. a
|
|
313
310
|
// /public/ WP install shown as "app" until probed). Falls back to the
|
|
314
311
|
// API classification when unprobed. Matches the Stacks / Forgotten views.
|
|
315
312
|
const stack = effectiveStack(s, probes.get(s.id)?.result.kind)
|
|
316
|
-
const keyKinds = grantedKeyKinds(s.id)
|
|
317
313
|
// The server's own vanity/health page blends in perfectly when servers
|
|
318
314
|
// are named like domains — mark it (⌂ + brand tint) so it can't hide.
|
|
319
315
|
const vanity = !!server && isVanityPair(s.domain, server.name)
|
|
@@ -326,19 +322,11 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
326
322
|
wrapMode="none"
|
|
327
323
|
style={{ flexGrow: 1, flexShrink: 1 }}
|
|
328
324
|
/>
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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. */}
|
|
325
|
+
{/* Fixed-width stack column (widest tag = "bedrock" = 7) so it and
|
|
326
|
+
the PHP column stay vertically aligned across every row. Per-site
|
|
327
|
+
linked/key/HTTPS/cache/backup/updates badges moved to the Details
|
|
328
|
+
pane (already shown there for the selected site) to give the
|
|
329
|
+
domain name room on narrower terminals. */}
|
|
342
330
|
<text content={stackTag(stack).padEnd(7) + " "} fg={stackColor(stack, selected)} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
343
331
|
<PhpVersionCell version={s.php_version} upgrade={phpUpgrades.get(s.id)} selected={selected} />
|
|
344
332
|
</>
|
|
@@ -348,17 +336,21 @@ export function Browser({ rows }: { rows: number }) {
|
|
|
348
336
|
</Panel>
|
|
349
337
|
|
|
350
338
|
{/* Detail pane */}
|
|
351
|
-
<Panel title=" Details " width=
|
|
339
|
+
<Panel title=" Details " width="32%">
|
|
352
340
|
{focus === "sites" && focusedSite ? (
|
|
353
341
|
<SiteDetail site={focusedSite} serverName={server?.name ?? "—"} />
|
|
354
342
|
) : server ? (
|
|
355
|
-
<ServerDetail server={server} siteCount={sites.length}
|
|
343
|
+
<ServerDetail server={server} siteCount={sites.length} />
|
|
356
344
|
) : (
|
|
357
345
|
<text content="No data" fg={theme.textFaint} />
|
|
358
346
|
)}
|
|
359
347
|
</Panel>
|
|
360
348
|
</box>
|
|
361
|
-
<
|
|
349
|
+
<ControlStrip
|
|
350
|
+
site={focus === "sites" ? focusedSite ?? null : null}
|
|
351
|
+
server={focus === "servers" ? server ?? null : null}
|
|
352
|
+
serverName={server?.name ?? "—"}
|
|
353
|
+
/>
|
|
362
354
|
<StatusBar hints={hints} message={flash ?? undefined} messageColor={theme.brand} />
|
|
363
355
|
</box>
|
|
364
356
|
)
|