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.
@@ -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, showControl = false }: { server: Server; siteCount: number; showControl?: boolean }) {
16
- const { rebootInfo, serverOps, isServerOsEol, sitesForServer, vanityJob } = useStore()
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
- // 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(): ActionGroup[] {
95
- const resumable = vanityJob != null && vanityJob.step !== "done" && vanityJob.serverId === server.id
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 this server →"]] },
105
- { title: "Access", items: [["S", "Connect sudo"]] },
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 services"],
110
- ["h", "Server health"],
111
- ["N", "DNS records (migrate lens)"],
113
+ ["a", "Reboot / restart"],
114
+ ["h", "Health"],
115
+ ["N", "DNS"],
112
116
  ],
113
117
  },
114
- { title: "Open", items: [["w", "Open in SpinupWP"]] },
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
- <box style={{ flexDirection: "row" }}>
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
+ }
@@ -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. Open https://spinupwp.app/account/api/" fg={theme.textDim} />
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 }} />
@@ -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 a list of `key → outcome` rows. Shared by the Search
373
- // actions pane and the Servers detail pane so the suite reads the same everywhere.
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
- <text content={heading} fg={theme.accent} />
379
- {groups.map((group) => (
380
- <box key={group.title} style={{ flexDirection: "column" }}>
381
- <text content={group.title.toUpperCase()} fg={theme.textFaint} wrapMode="none" />
382
- {group.items.map(([k, label]) => (
383
- <box key={k} style={{ flexDirection: "row" }}>
384
- <text content={` ${k} `} fg={theme.bg} bg={theme.brandDim} style={{ flexShrink: 0 }} />
385
- <text content={` ${label}`} fg={theme.text} wrapMode="none" />
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
- </box>
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)
@@ -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, SiteStatusColumn } from "../components.tsx"
13
+ import { Panel, PhpVersionCell, Spinner } from "../components.tsx"
14
14
  import { List, moveSelection } from "../List.tsx"
15
- import { ServerDetail, SiteDetail, SiteContextStrip, SITE_CONTEXT_STRIP_HEIGHT } from "../Details.tsx"
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, grantedKeyKinds, setServerActionsServer, serverOps, setLocalLinkSite, openLocalTerminal, openLocalUrl, localLinks, sshSite, setDnsInventoryServer, setNewServerSource, setNewServerOpen, setVanityServer, vanityJob, beginClone, isServerOsEol, setKumaSite } = store
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 - SITE_CONTEXT_STRIP_HEIGHT)
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={44}>
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
- <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. */}
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={44}>
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} showControl />
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
- <SiteContextStrip site={focus === "sites" ? focusedSite ?? null : null} />
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
  )