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
|
@@ -562,7 +562,7 @@ export function CloneWizard() {
|
|
|
562
562
|
case "adding":
|
|
563
563
|
return "adding…"
|
|
564
564
|
case "error":
|
|
565
|
-
return
|
|
565
|
+
return k.error ?? "failed"
|
|
566
566
|
default:
|
|
567
567
|
return "add the key by hand, then r to re-check"
|
|
568
568
|
}
|
|
@@ -579,9 +579,8 @@ export function CloneWizard() {
|
|
|
579
579
|
return (
|
|
580
580
|
<box key={k.repo} style={{ flexDirection: "row", height: 1 }}>
|
|
581
581
|
<text content={`${g} `} fg={c} style={{ flexShrink: 0 }} />
|
|
582
|
-
<text content={`${k.owner}/${k.name}`} fg={theme.text} wrapMode="none" style={{ flexShrink:
|
|
583
|
-
<
|
|
584
|
-
<text content={label(k)} fg={k.status === "error" ? theme.bad : k.status === "present" || k.status === "added" ? theme.good : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
582
|
+
<text content={`${k.owner}/${k.name}`} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
583
|
+
<text content={" " + label(k)} fg={k.status === "error" ? theme.bad : k.status === "present" || k.status === "added" ? theme.good : theme.textFaint} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
585
584
|
</box>
|
|
586
585
|
)
|
|
587
586
|
})}
|
|
@@ -682,15 +681,14 @@ export function CloneWizard() {
|
|
|
682
681
|
: st === "done" ? `now → ${r.targetValue ?? targetIp}`
|
|
683
682
|
: st === "ready" ? `${r.currentValue ?? "?"} → ${r.targetValue ?? targetIp}${r.excluded ? " (skipped)" : ""}`
|
|
684
683
|
: st === "manual" ? `manual: ${r.reason ?? "edit by hand"}`
|
|
685
|
-
: st === "error" ?
|
|
684
|
+
: st === "error" ? (r.error ?? "failed")
|
|
686
685
|
: "pending"
|
|
687
686
|
const dcolor = st === "error" ? theme.bad : st === "done" ? theme.good : st === "manual" ? theme.warn : cur ? theme.text : theme.textFaint
|
|
688
687
|
return (
|
|
689
688
|
<box key={`${siteId}|${r.name}`} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
|
|
690
689
|
{busy ? <Spinner color={theme.brand} /> : <text content={glyph} fg={gcolor} style={{ flexShrink: 0 }} />}
|
|
691
|
-
<text content={` ${r.name}`} fg={theme.text} wrapMode="none" style={{ flexShrink:
|
|
692
|
-
<
|
|
693
|
-
<text content={detail} fg={dcolor} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
690
|
+
<text content={` ${r.name}`} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
691
|
+
<text content={" " + detail} fg={dcolor} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
694
692
|
</box>
|
|
695
693
|
)
|
|
696
694
|
}
|
|
@@ -732,13 +730,12 @@ export function CloneWizard() {
|
|
|
732
730
|
return (
|
|
733
731
|
<box key={s.sourceSiteId} style={{ flexDirection: "row", height: 1, backgroundColor: cur ? theme.bgAlt : undefined }}>
|
|
734
732
|
{active ? <Spinner color={theme.brand} /> : <text content={s.step === "done" ? "✓" : s.step === "error" ? "✕" : "○"} fg={s.step === "done" ? theme.good : s.step === "error" ? theme.bad : theme.textFaint} style={{ flexShrink: 0 }} />}
|
|
735
|
-
<text content={` ${s.domain}`} fg={cur || s.step === "error" || s.step === "done" ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink:
|
|
736
|
-
<box style={{ flexGrow: 1 }} />
|
|
733
|
+
<text content={` ${s.domain}`} fg={cur || s.step === "error" || s.step === "done" ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
737
734
|
<text
|
|
738
|
-
content={(s.step === "error" ?
|
|
735
|
+
content={" " + ((s.step === "error" ? s.error ?? "failed" : s.step === "done" ? "done" : s.step === "queued" ? "queued" : `${s.step}${s.detail ? " · " + s.detail : ""}${s.transferBytes != null ? ` · ${formatBytes(s.transferBytes)}${s.transferTarget ? ` of ${s.transferExact ? "" : "~"}${formatBytes(s.transferTarget)}` : ""}${s.transferExact && s.transferTarget ? ` (${Math.min(100, Math.round((s.transferBytes / s.transferTarget) * 100))}%)` : ""}${s.transferRate ? ` · ${formatBytes(s.transferRate)}/s` : ""}` : ""}${s.stageStartedAt ? " · " + fmtElapsed(now - s.stageStartedAt) : ""}`) + vmark)}
|
|
739
736
|
fg={s.step === "error" || (s.verify && !s.verify.ok) ? theme.bad : s.verify?.ok ? theme.good : s.step === "done" ? theme.good : theme.textFaint}
|
|
740
737
|
wrapMode="none"
|
|
741
|
-
style={{ flexShrink:
|
|
738
|
+
style={{ flexGrow: 1, flexShrink: 1 }}
|
|
742
739
|
/>
|
|
743
740
|
</box>
|
|
744
741
|
)
|
package/src/ui/views/Search.tsx
CHANGED
|
@@ -9,7 +9,7 @@ import { useKeyboard } from "@opentui/react"
|
|
|
9
9
|
import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
10
10
|
import { classifyStack, stackColor, stackTag } from "../../lib/stack.ts"
|
|
11
11
|
import { truncate } from "../../lib/format.ts"
|
|
12
|
-
import { Panel, Field, StatusBadge, SiteMetaCell, Spinner, ControlPanel } from "../components.tsx"
|
|
12
|
+
import { Panel, Field, StatusBadge, SiteMetaCell, Spinner, ControlPanel, siteGroups } from "../components.tsx"
|
|
13
13
|
import { isDbBackupInFlight } from "../../lib/dbBackup.ts"
|
|
14
14
|
import { isDbSyncInFlight } from "../../lib/dbSync.ts"
|
|
15
15
|
import { List, moveSelection } from "../List.tsx"
|
|
@@ -17,6 +17,7 @@ import { ServerDetail, SiteDetail, SERVER_CONTROL } from "../Details.tsx"
|
|
|
17
17
|
import { StatusBar } from "../StatusBar.tsx"
|
|
18
18
|
import { openUrl } from "../../lib/open.ts"
|
|
19
19
|
import { serverWebUrl, siteWebUrl } from "../../lib/spinupweb.ts"
|
|
20
|
+
import { isVanityPair } from "../../lib/vanitySite.ts"
|
|
20
21
|
import { useStore } from "../store.tsx"
|
|
21
22
|
import type { Server, Site } from "../../api/types.ts"
|
|
22
23
|
|
|
@@ -34,7 +35,7 @@ function score(haystack: string, q: string): number | null {
|
|
|
34
35
|
|
|
35
36
|
export function Search({ rows }: { rows: number }) {
|
|
36
37
|
const store = useStore()
|
|
37
|
-
const { servers, sites, serverById, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite } = store
|
|
38
|
+
const { servers, sites, serverById, setInputMode, setRoute, route, overlayOpen, setHealthServer, setPhpUpgradeSite, setHttpsToggleSite, setPurgeCacheSite, setServerActionsServer, accountSlug, localLinks, setLocalLinkSite, openLocalTerminal, openLocalUrl, sshSite, setDnsInventoryServer, setDbBackupSite, dbBackups, setDbSyncSite, dbSyncs, localSync, setMediaFallbackSite, beginClone, setSudoConnectServer, setKumaSite, kumaConfigured, startKumaSetup, startVanityReseed } = store
|
|
38
39
|
const [query, setQuery] = useState("")
|
|
39
40
|
const [selected, setSelected] = useState(0)
|
|
40
41
|
// "query" = typing/filtering (input focused); "actions" = input blurred so the
|
|
@@ -195,6 +196,14 @@ export function Search({ rows }: { rows: number }) {
|
|
|
195
196
|
// fallback here; M matches the same overlay's binding in Stacks.
|
|
196
197
|
if (current?.kind === "site") setKumaSite(current.site)
|
|
197
198
|
return
|
|
199
|
+
case "R":
|
|
200
|
+
// Refresh a vanity page's HTML (site-only, and only for the one site per
|
|
201
|
+
// server whose domain equals the server's own name). Same binding/behavior
|
|
202
|
+
// as the m-overlay's R (KumaSite.tsx) — fires directly, no confirm, since
|
|
203
|
+
// it just republishes the same bundled page that's already live.
|
|
204
|
+
if (current?.kind === "site" && isVanityPair(current.site.domain, serverById(current.site.server_id)?.name ?? ""))
|
|
205
|
+
return kumaConfigured ? startKumaSetup(current.site, { reseed: true }) : startVanityReseed(current.site)
|
|
206
|
+
return
|
|
198
207
|
case "n":
|
|
199
208
|
// DNS inventory scoped to this site (its domains + records) — the migrate
|
|
200
209
|
// lens for moving one site to another server.
|
|
@@ -241,7 +250,7 @@ export function Search({ rows }: { rows: number }) {
|
|
|
241
250
|
focus === "query"
|
|
242
251
|
? [
|
|
243
252
|
{ key: "↑↓", label: "select" },
|
|
244
|
-
{ key: "⏎", label: "open site" },
|
|
253
|
+
...(current?.kind === "site" ? [{ key: "⏎", label: "open site" }] : []),
|
|
245
254
|
{ key: "Tab/→", label: "actions" },
|
|
246
255
|
{ key: "esc", label: "dashboard" },
|
|
247
256
|
]
|
|
@@ -254,15 +263,9 @@ export function Search({ rows }: { rows: number }) {
|
|
|
254
263
|
{ key: "←/esc", label: "back" },
|
|
255
264
|
]
|
|
256
265
|
: [
|
|
266
|
+
// The Actions panel (Details pane) already lists every per-site key —
|
|
267
|
+
// this footer is nav-only now, so the two can't drift out of sync.
|
|
257
268
|
{ key: "↑↓", label: "select" },
|
|
258
|
-
{ key: "o", label: "open" },
|
|
259
|
-
{ key: "s", label: "SSH" },
|
|
260
|
-
{ key: "n", label: "DNS" },
|
|
261
|
-
{ key: "d", label: "DB backup" },
|
|
262
|
-
...(localSync ? [{ key: "p", label: "pull to local" }] : []),
|
|
263
|
-
...(current?.kind === "site" && current.site.is_wordpress && localLinks.has(current.site.id) ? [{ key: "m", label: "media fallback" }] : []),
|
|
264
|
-
{ key: "M", label: "monitoring" },
|
|
265
|
-
{ key: "a", label: "server actions" },
|
|
266
269
|
{ key: "←/esc", label: "back" },
|
|
267
270
|
]
|
|
268
271
|
|
|
@@ -352,7 +355,7 @@ export function Search({ rows }: { rows: number }) {
|
|
|
352
355
|
/>
|
|
353
356
|
</Panel>
|
|
354
357
|
|
|
355
|
-
<Panel title={focus === "actions" ? " Actions " : " Details "} active={focus === "actions"} width={
|
|
358
|
+
<Panel title={focus === "actions" ? " Actions " : " Details "} active={focus === "actions"} width={64}>
|
|
356
359
|
{!current ? (
|
|
357
360
|
<text content="No selection" fg={theme.textFaint} />
|
|
358
361
|
) : focus === "actions" ? (
|
|
@@ -374,48 +377,12 @@ export function Search({ rows }: { rows: number }) {
|
|
|
374
377
|
)
|
|
375
378
|
}
|
|
376
379
|
|
|
377
|
-
// Site Control panel shown in the Details pane while in "actions" focus. Groups
|
|
378
|
-
// the live single-key actions (Open / Remote / Local / Server) so the suite is
|
|
379
|
-
// discoverable and scales as more actions land. A site gets the full set; a
|
|
380
|
-
// server gets just the open + server actions.
|
|
381
|
-
type ActionGroup = { title: string; items: [string, string][] }
|
|
382
|
-
|
|
383
|
-
// The DB backup/sync use wp-cli, so they only apply to WordPress sites — omitted
|
|
384
|
-
// for non-WP sites (their `d`/`p` keypresses are also guarded in the handler). DNS
|
|
385
|
-
// (`n`) and the rest apply to any site.
|
|
386
|
-
function siteGroups(isWordpress: boolean, localSync: boolean): ActionGroup[] {
|
|
387
|
-
const remote: [string, string][] = [
|
|
388
|
-
["s", "SSH into the site"],
|
|
389
|
-
["n", "DNS records (migrate lens)"],
|
|
390
|
-
]
|
|
391
|
-
if (isWordpress) {
|
|
392
|
-
remote.push(["d", "Download DB backup"])
|
|
393
|
-
if (localSync) remote.push(["p", "Pull production DB to local"])
|
|
394
|
-
}
|
|
395
|
-
const local: [string, string][] = [
|
|
396
|
-
["t", "Open working copy in a terminal"],
|
|
397
|
-
["v", "Open the local URL"],
|
|
398
|
-
["L", "Link / edit local copy"],
|
|
399
|
-
]
|
|
400
|
-
if (isWordpress) local.push(["m", "Media: serve missing images from production"])
|
|
401
|
-
remote.push(["u", "Upgrade PHP version"])
|
|
402
|
-
remote.push(["H", "Enable/disable HTTPS"])
|
|
403
|
-
remote.push(["P", "Purge page + object cache"])
|
|
404
|
-
remote.push(["M", "Monitoring (Uptime Kuma + front-page check)"])
|
|
405
|
-
return [
|
|
406
|
-
{ title: "Open", items: [["o", "Open site in browser"], ["w", "Open in SpinupWP"]] },
|
|
407
|
-
{ title: "Remote", items: remote },
|
|
408
|
-
{ title: "Local", items: local },
|
|
409
|
-
{ title: "Server", items: [["a", "Server actions (reboot / restart)"], ["h", "Server health"]] },
|
|
410
|
-
]
|
|
411
|
-
}
|
|
412
|
-
|
|
413
380
|
function ActionsCard({ result, serverName, localSync }: { result: Result; serverName: string; localSync: boolean }) {
|
|
414
381
|
const isSite = result.kind === "site"
|
|
415
382
|
const name = isSite ? result.site.domain : result.server.name
|
|
416
383
|
const status = isSite ? result.site.status : result.server.connection_status
|
|
417
384
|
// Server results reuse the SAME Server Control suite as the Browser detail pane.
|
|
418
|
-
const groups = isSite ? siteGroups(result.site.is_wordpress, localSync) : SERVER_CONTROL
|
|
385
|
+
const groups = isSite ? siteGroups(result.site.is_wordpress, localSync, isVanityPair(result.site.domain, serverName)) : SERVER_CONTROL
|
|
419
386
|
return (
|
|
420
387
|
<box style={{ flexDirection: "column" }}>
|
|
421
388
|
<box style={{ flexDirection: "row" }}>
|