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,93 @@
|
|
|
1
|
+
// First-run onboarding wizard.
|
|
2
|
+
//
|
|
3
|
+
// Shown when no token is found in the environment or config file. Collects an
|
|
4
|
+
// API token, validates it against the live API, persists it to ~/.config, then
|
|
5
|
+
// hands control back to the app.
|
|
6
|
+
|
|
7
|
+
import { useState } from "react"
|
|
8
|
+
import { theme } from "../lib/theme.ts"
|
|
9
|
+
import { Spinner, SecretInput } from "./components.tsx"
|
|
10
|
+
import { SpinupWPClient } from "../api/client.ts"
|
|
11
|
+
import { DEFAULT_BASE_URL, saveConfig, configPath } from "../config.ts"
|
|
12
|
+
|
|
13
|
+
type Phase = "input" | "validating" | "error"
|
|
14
|
+
|
|
15
|
+
export function Onboarding({ onComplete }: { onComplete: () => void }) {
|
|
16
|
+
const [value, setValue] = useState("")
|
|
17
|
+
const [phase, setPhase] = useState<Phase>("input")
|
|
18
|
+
const [error, setError] = useState<string | null>(null)
|
|
19
|
+
|
|
20
|
+
async function submit(token: string) {
|
|
21
|
+
const trimmed = token.trim()
|
|
22
|
+
if (!trimmed) return
|
|
23
|
+
setPhase("validating")
|
|
24
|
+
setError(null)
|
|
25
|
+
const client = new SpinupWPClient({ token: trimmed, baseUrl: DEFAULT_BASE_URL })
|
|
26
|
+
const result = await client.validateToken()
|
|
27
|
+
if (result.ok) {
|
|
28
|
+
await saveConfig({ token: trimmed, baseUrl: DEFAULT_BASE_URL })
|
|
29
|
+
onComplete()
|
|
30
|
+
} else {
|
|
31
|
+
setError(result.reason)
|
|
32
|
+
setPhase("error")
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<box
|
|
38
|
+
style={{
|
|
39
|
+
width: "100%",
|
|
40
|
+
height: "100%",
|
|
41
|
+
flexDirection: "column",
|
|
42
|
+
justifyContent: "center",
|
|
43
|
+
alignItems: "center",
|
|
44
|
+
backgroundColor: theme.bg,
|
|
45
|
+
}}
|
|
46
|
+
>
|
|
47
|
+
<ascii-font text="SPINUPTUI" font="block" color={[theme.brand, theme.accent]} />
|
|
48
|
+
<box style={{ height: 1 }} />
|
|
49
|
+
<box
|
|
50
|
+
title=" Welcome "
|
|
51
|
+
titleColor={theme.brand}
|
|
52
|
+
border
|
|
53
|
+
borderColor={theme.border}
|
|
54
|
+
style={{ flexDirection: "column", width: 70, paddingLeft: 2, paddingRight: 2, paddingTop: 1, paddingBottom: 1 }}
|
|
55
|
+
>
|
|
56
|
+
<text content="Let's connect this tool to your SpinupWP account." fg={theme.text} />
|
|
57
|
+
<box style={{ height: 1 }} />
|
|
58
|
+
<text content="1. Open https://spinupwp.app/account/api/" fg={theme.textDim} />
|
|
59
|
+
<text content="2. Create a token (Read Only to browse; Read/Write to upgrade PHP)" fg={theme.textDim} />
|
|
60
|
+
<text content="3. Paste it below and press Enter" fg={theme.textDim} />
|
|
61
|
+
<box style={{ height: 1 }} />
|
|
62
|
+
|
|
63
|
+
<text content="API Access Token" fg={theme.accent} />
|
|
64
|
+
<SecretInput
|
|
65
|
+
focused={phase !== "validating"}
|
|
66
|
+
value={value}
|
|
67
|
+
placeholder="paste your token hereโฆ"
|
|
68
|
+
onChange={setValue}
|
|
69
|
+
onSubmit={() => submit(value)}
|
|
70
|
+
/>
|
|
71
|
+
<box style={{ height: 1 }} />
|
|
72
|
+
|
|
73
|
+
{phase === "validating" && (
|
|
74
|
+
<box style={{ flexDirection: "row" }}>
|
|
75
|
+
<Spinner />
|
|
76
|
+
<text content=" Validating tokenโฆ" fg={theme.textDim} />
|
|
77
|
+
</box>
|
|
78
|
+
)}
|
|
79
|
+
{phase === "error" && (
|
|
80
|
+
<box style={{ flexDirection: "column" }}>
|
|
81
|
+
<text content={"โ " + (error ?? "Validation failed")} fg={theme.bad} />
|
|
82
|
+
<text content="Press Enter to try again." fg={theme.textFaint} />
|
|
83
|
+
</box>
|
|
84
|
+
)}
|
|
85
|
+
{phase === "input" && (
|
|
86
|
+
<text content="Your token is stored locally only, never transmitted elsewhere." fg={theme.textFaint} />
|
|
87
|
+
)}
|
|
88
|
+
</box>
|
|
89
|
+
<box style={{ height: 1 }} />
|
|
90
|
+
<text content={`Config will be saved to ${configPath()}`} fg={theme.textFaint} />
|
|
91
|
+
</box>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Animated splash shown while the first batch of data loads.
|
|
2
|
+
// Renders a gradient ASCII logo, a tagline, and a live loading status.
|
|
3
|
+
|
|
4
|
+
import { useEffect, useState } from "react"
|
|
5
|
+
import { theme } from "../lib/theme.ts"
|
|
6
|
+
import { Spinner } from "./components.tsx"
|
|
7
|
+
|
|
8
|
+
const TAGLINE = "Terminal control center for your SpinupWP fleet"
|
|
9
|
+
|
|
10
|
+
// A subtle animated "scanline" of dots beneath the logo for some life.
|
|
11
|
+
function Pulse() {
|
|
12
|
+
const [t, setT] = useState(0)
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const id = setInterval(() => setT((v) => v + 1), 120)
|
|
15
|
+
return () => clearInterval(id)
|
|
16
|
+
}, [])
|
|
17
|
+
const width = 24
|
|
18
|
+
const pos = t % width
|
|
19
|
+
let line = ""
|
|
20
|
+
for (let i = 0; i < width; i++) {
|
|
21
|
+
const dist = Math.abs(i - pos)
|
|
22
|
+
line += dist === 0 ? "โ" : dist <= 1 ? "โข" : "ยท"
|
|
23
|
+
}
|
|
24
|
+
return <text content={line} fg={theme.brandDim} />
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function Splash({ status }: { status: string }) {
|
|
28
|
+
return (
|
|
29
|
+
<box
|
|
30
|
+
style={{
|
|
31
|
+
width: "100%",
|
|
32
|
+
height: "100%",
|
|
33
|
+
flexDirection: "column",
|
|
34
|
+
justifyContent: "center",
|
|
35
|
+
alignItems: "center",
|
|
36
|
+
backgroundColor: theme.bg,
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<ascii-font text="SPINUPTUI" font="block" color={[theme.brand, theme.accent]} />
|
|
40
|
+
<box style={{ height: 1 }} />
|
|
41
|
+
<text content={TAGLINE} fg={theme.text} />
|
|
42
|
+
<box style={{ height: 1 }} />
|
|
43
|
+
<Pulse />
|
|
44
|
+
<box style={{ height: 2 }} />
|
|
45
|
+
<box style={{ flexDirection: "row" }}>
|
|
46
|
+
<Spinner />
|
|
47
|
+
<text content={" " + status} fg={theme.textDim} />
|
|
48
|
+
</box>
|
|
49
|
+
</box>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Bottom status bar showing context-sensitive key hints plus always-present
|
|
2
|
+
// global hints (help / quit). Views render their own StatusBar so hints can be
|
|
3
|
+
// contextual; the global set is appended automatically for consistency.
|
|
4
|
+
|
|
5
|
+
import { theme } from "../lib/theme.ts"
|
|
6
|
+
|
|
7
|
+
export interface KeyHint {
|
|
8
|
+
key: string
|
|
9
|
+
label: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const GLOBAL_HINTS: KeyHint[] = [
|
|
13
|
+
{ key: "r", label: "refresh" },
|
|
14
|
+
{ key: "i", label: "explain" },
|
|
15
|
+
{ key: "?", label: "help" },
|
|
16
|
+
{ key: "q", label: "quit" },
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
function HintGroup({ hints, dim = false }: { hints: KeyHint[]; dim?: boolean }) {
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
{hints.map((h, i) => (
|
|
23
|
+
<box key={i} style={{ flexDirection: "row" }}>
|
|
24
|
+
<text content={` ${h.key} `} fg={theme.bg} bg={dim ? theme.textFaint : theme.brandDim} />
|
|
25
|
+
<text content={` ${h.label} `} fg={theme.textDim} />
|
|
26
|
+
</box>
|
|
27
|
+
))}
|
|
28
|
+
</>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function StatusBar({
|
|
33
|
+
hints,
|
|
34
|
+
message,
|
|
35
|
+
messageColor,
|
|
36
|
+
showGlobal = true,
|
|
37
|
+
}: {
|
|
38
|
+
hints: KeyHint[]
|
|
39
|
+
message?: string
|
|
40
|
+
messageColor?: string
|
|
41
|
+
showGlobal?: boolean
|
|
42
|
+
}) {
|
|
43
|
+
return (
|
|
44
|
+
<box
|
|
45
|
+
style={{
|
|
46
|
+
flexDirection: "row",
|
|
47
|
+
height: 1,
|
|
48
|
+
backgroundColor: theme.bgAlt,
|
|
49
|
+
paddingLeft: 1,
|
|
50
|
+
paddingRight: 1,
|
|
51
|
+
alignItems: "center",
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<HintGroup hints={hints} />
|
|
55
|
+
{message && <text content={message} fg={messageColor ?? theme.brand} />}
|
|
56
|
+
<box style={{ flexGrow: 1 }} />
|
|
57
|
+
{showGlobal && <HintGroup hints={GLOBAL_HINTS} dim />}
|
|
58
|
+
</box>
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// Small shared presentational components used across views.
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useState, type ReactNode } from "react"
|
|
4
|
+
import { useKeyboard, usePaste } from "@opentui/react"
|
|
5
|
+
import { theme, statusColor, statusDot } from "../lib/theme.ts"
|
|
6
|
+
import { middleTruncate } from "../lib/format.ts"
|
|
7
|
+
import { isUpgradeInFlight, type PhpUpgradeProgress } from "./store.tsx"
|
|
8
|
+
|
|
9
|
+
// Shorten a home-relative absolute path for display (~/project/sql/file.sql.gz).
|
|
10
|
+
export function shortPath(path: string): string {
|
|
11
|
+
const home = process.env.HOME
|
|
12
|
+
return home && path.startsWith(home + "/") ? "~/" + path.slice(home.length + 1) : path
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// A filesystem destination shown as two lines โ folder (dim, middle-truncated)
|
|
16
|
+
// above the filename (colored, middle-truncated) โ so the filename always stays
|
|
17
|
+
// readable instead of being clipped off the end of one long line.
|
|
18
|
+
export function DestPath({ path, fileColor, width = 62 }: { path: string; fileColor: string; width?: number }) {
|
|
19
|
+
const s = shortPath(path)
|
|
20
|
+
const i = s.lastIndexOf("/")
|
|
21
|
+
const dir = i < 0 ? "" : s.slice(0, i + 1)
|
|
22
|
+
const file = i < 0 ? s : s.slice(i + 1)
|
|
23
|
+
return (
|
|
24
|
+
<box style={{ flexDirection: "column" }}>
|
|
25
|
+
{dir ? <text content={` ${middleTruncate(dir, width)}`} fg={theme.textDim} wrapMode="none" /> : null}
|
|
26
|
+
<text content={` ${middleTruncate(file, width)}`} fg={fileColor} wrapMode="none" />
|
|
27
|
+
</box>
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const SPINNER_FRAMES = ["โ ", "โ ", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ", "โ "]
|
|
32
|
+
|
|
33
|
+
// An animated braille spinner. `interval` ms per frame.
|
|
34
|
+
export function Spinner({ color = theme.brand, interval = 80 }: { color?: string; interval?: number }) {
|
|
35
|
+
const [frame, setFrame] = useState(0)
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const id = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), interval)
|
|
38
|
+
return () => clearInterval(id)
|
|
39
|
+
}, [interval])
|
|
40
|
+
return <text content={SPINNER_FRAMES[frame]} fg={color} />
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SPARKLE_FRAMES = ["โฆ", "โง", "โถ", "โง"]
|
|
44
|
+
|
|
45
|
+
// A gentle twinkling glyph for drawing the eye to a notice (e.g. an available
|
|
46
|
+
// update) without the urgency of a spinner.
|
|
47
|
+
export function Sparkle({ color = theme.brand, interval = 360 }: { color?: string; interval?: number }) {
|
|
48
|
+
const [frame, setFrame] = useState(0)
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const id = setInterval(() => setFrame((f) => (f + 1) % SPARKLE_FRAMES.length), interval)
|
|
51
|
+
return () => clearInterval(id)
|
|
52
|
+
}, [interval])
|
|
53
|
+
return <text content={SPARKLE_FRAMES[frame]} fg={color} />
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A vertical checklist of stages that fills in as work progresses: completed
|
|
57
|
+
// rows get a green โ, the in-flight row spins, not-yet-reached rows are faint
|
|
58
|
+
// โ, and a failed row gets a red โ. A `waiting` row (paused for the user to act)
|
|
59
|
+
// gets an amber โฏ โ distinct from the spinner so it reads as "your turn", not
|
|
60
|
+
// "system working". Used by the DB backup/sync + vanity overlays.
|
|
61
|
+
export type StepState = "done" | "active" | "pending" | "failed" | "waiting"
|
|
62
|
+
export interface StepRow {
|
|
63
|
+
label: string
|
|
64
|
+
state: StepState
|
|
65
|
+
detail?: string // optional trailing text (e.g. a timer), shown dim after the label
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function Steps({ rows }: { rows: StepRow[] }) {
|
|
69
|
+
return (
|
|
70
|
+
<box style={{ flexDirection: "column" }}>
|
|
71
|
+
{rows.map((r, i) => (
|
|
72
|
+
<box key={i} style={{ flexDirection: "row", height: 1 }}>
|
|
73
|
+
{r.state === "active" ? (
|
|
74
|
+
<Spinner color={theme.brand} />
|
|
75
|
+
) : (
|
|
76
|
+
<text
|
|
77
|
+
content={r.state === "done" ? "โ" : r.state === "failed" ? "โ" : r.state === "waiting" ? "โฏ" : "โ"}
|
|
78
|
+
fg={r.state === "done" ? theme.good : r.state === "failed" ? theme.bad : r.state === "waiting" ? theme.warn : theme.textFaint}
|
|
79
|
+
/>
|
|
80
|
+
)}
|
|
81
|
+
<text
|
|
82
|
+
content={` ${r.label}`}
|
|
83
|
+
fg={r.state === "pending" ? theme.textFaint : r.state === "waiting" ? theme.warn : theme.text}
|
|
84
|
+
wrapMode="none"
|
|
85
|
+
/>
|
|
86
|
+
{r.detail ? <text content={` ${r.detail}`} fg={theme.textDim} wrapMode="none" /> : null}
|
|
87
|
+
</box>
|
|
88
|
+
))}
|
|
89
|
+
</box>
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// A bordered panel with a consistent title style. `active` highlights the border.
|
|
94
|
+
export function Panel({
|
|
95
|
+
title,
|
|
96
|
+
active = false,
|
|
97
|
+
children,
|
|
98
|
+
flexGrow,
|
|
99
|
+
width,
|
|
100
|
+
bottomTitle,
|
|
101
|
+
style,
|
|
102
|
+
}: {
|
|
103
|
+
title?: string
|
|
104
|
+
active?: boolean
|
|
105
|
+
children?: ReactNode
|
|
106
|
+
flexGrow?: number
|
|
107
|
+
width?: number | `${number}%`
|
|
108
|
+
bottomTitle?: string
|
|
109
|
+
style?: Record<string, unknown>
|
|
110
|
+
}) {
|
|
111
|
+
return (
|
|
112
|
+
<box
|
|
113
|
+
title={title}
|
|
114
|
+
titleColor={active ? theme.brand : theme.textDim}
|
|
115
|
+
bottomTitle={bottomTitle}
|
|
116
|
+
bottomTitleAlignment="right"
|
|
117
|
+
border
|
|
118
|
+
borderColor={active ? theme.borderActive : theme.border}
|
|
119
|
+
flexGrow={flexGrow}
|
|
120
|
+
width={width}
|
|
121
|
+
style={{ flexDirection: "column", paddingLeft: 1, paddingRight: 1, ...style }}
|
|
122
|
+
>
|
|
123
|
+
{children}
|
|
124
|
+
</box>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A label / value row used in detail panels. Label is dimmed and fixed-width.
|
|
129
|
+
export function Field({
|
|
130
|
+
label,
|
|
131
|
+
value,
|
|
132
|
+
valueColor = theme.text,
|
|
133
|
+
labelWidth = 16,
|
|
134
|
+
}: {
|
|
135
|
+
label: string
|
|
136
|
+
value: ReactNode
|
|
137
|
+
valueColor?: string
|
|
138
|
+
labelWidth?: number
|
|
139
|
+
}) {
|
|
140
|
+
return (
|
|
141
|
+
<box style={{ flexDirection: "row" }}>
|
|
142
|
+
<text content={label.padEnd(labelWidth)} fg={theme.textDim} />
|
|
143
|
+
{typeof value === "string" || typeof value === "number" ? (
|
|
144
|
+
<text content={String(value)} fg={valueColor} />
|
|
145
|
+
) : (
|
|
146
|
+
value
|
|
147
|
+
)}
|
|
148
|
+
</box>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// A colored status dot followed by its label (e.g. "โ connected").
|
|
153
|
+
export function StatusBadge({ status, label }: { status: string | null | undefined; label?: string }) {
|
|
154
|
+
const color = statusColor(status)
|
|
155
|
+
return (
|
|
156
|
+
<box style={{ flexDirection: "row" }}>
|
|
157
|
+
<text content={statusDot(status) + " "} fg={color} />
|
|
158
|
+
<text content={label ?? status ?? "unknown"} fg={color} />
|
|
159
|
+
</box>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// A small inline pill, e.g. for "reboot required" warnings.
|
|
164
|
+
export function Pill({ text, color }: { text: string; color: string }) {
|
|
165
|
+
return <text content={` ${text} `} fg={theme.bg} bg={color} />
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Trailing PHP-version cell for a site row. Normally the version string; while a
|
|
169
|
+
// PHP upgrade is in flight it shows a spinner + the target (e.g. "โ นโ8.3"); a
|
|
170
|
+
// failed upgrade flags the version with "โฌ!". Shared by the Servers & Stacks rows.
|
|
171
|
+
export function PhpVersionCell({
|
|
172
|
+
version,
|
|
173
|
+
upgrade,
|
|
174
|
+
selected,
|
|
175
|
+
eol = false,
|
|
176
|
+
}: {
|
|
177
|
+
version: string | null | undefined
|
|
178
|
+
upgrade?: PhpUpgradeProgress
|
|
179
|
+
selected?: boolean
|
|
180
|
+
eol?: boolean
|
|
181
|
+
}) {
|
|
182
|
+
if (upgrade && isUpgradeInFlight(upgrade)) {
|
|
183
|
+
return (
|
|
184
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
185
|
+
<Spinner color={selected ? theme.text : theme.brand} interval={120} />
|
|
186
|
+
<text content={`โ${upgrade.target}`} fg={selected ? theme.text : theme.warn} wrapMode="none" />
|
|
187
|
+
</box>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
if (upgrade?.status === "failed") {
|
|
191
|
+
return <text content={`${version ?? "โ"} โฌ!`} fg={selected ? theme.text : theme.bad} style={{ flexShrink: 0 }} />
|
|
192
|
+
}
|
|
193
|
+
return (
|
|
194
|
+
<text
|
|
195
|
+
content={version ?? "โ"}
|
|
196
|
+
fg={selected ? theme.text : eol ? theme.bad : theme.textFaint}
|
|
197
|
+
style={{ flexShrink: 0 }}
|
|
198
|
+
/>
|
|
199
|
+
)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Glanceable per-row meta for a site list, shared across the Servers, Stacks,
|
|
203
|
+
// and Search rows so the convention is identical everywhere:
|
|
204
|
+
// โ = a local working copy is linked (blank keeps columns aligned)
|
|
205
|
+
// โN = N pending WordPress updates
|
|
206
|
+
// Presence-only by design โ the inspector strip shows the full detail (path /
|
|
207
|
+
// URL / on-disk validity) for the focused row.
|
|
208
|
+
export function SiteMetaCell({
|
|
209
|
+
linked,
|
|
210
|
+
updates,
|
|
211
|
+
personalKey = false,
|
|
212
|
+
machineKey = false,
|
|
213
|
+
selected = false,
|
|
214
|
+
}: {
|
|
215
|
+
linked: boolean
|
|
216
|
+
updates: number
|
|
217
|
+
personalKey?: boolean // ๐ค your key is on the site
|
|
218
|
+
machineKey?: boolean // ๐ the spinup-tui machine key is on the site
|
|
219
|
+
selected?: boolean
|
|
220
|
+
}) {
|
|
221
|
+
const keyMark = (personalKey ? "๐ค" : "") + (machineKey ? "๐" : "")
|
|
222
|
+
return (
|
|
223
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
224
|
+
<text content={linked ? "โ " : " "} fg={selected ? theme.text : theme.good} wrapMode="none" />
|
|
225
|
+
{keyMark ? <text content={keyMark + " "} wrapMode="none" /> : null}
|
|
226
|
+
{updates > 0 ? <text content={`โ${updates} `} fg={selected ? theme.text : theme.warn} wrapMode="none" /> : null}
|
|
227
|
+
</box>
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// The main Sites-row status column: a FIXED-slot capability strip so the stack /
|
|
232
|
+
// PHP columns after it line up across every row (unlike SiteMetaCell, whose
|
|
233
|
+
// present-or-absent marks shove later columns around). Each slot reserves the
|
|
234
|
+
// same width whether or not the capability is on โ a lit letter when on, a faint
|
|
235
|
+
// "ยท" when off โ giving a scannable H/C/B matrix down the list:
|
|
236
|
+
// โ local working copy linked
|
|
237
|
+
// ๐ค๐ personal / machine SSH key on the site (2-emoji-wide slot, always)
|
|
238
|
+
// H HTTPS enabled C page cache enabled B backups (files or db) enabled
|
|
239
|
+
// โN N pending WordPress updates
|
|
240
|
+
// Detail (which keys, backup schedule, etc.) lives in the Details pane / context
|
|
241
|
+
// strip; this strip is glance-only.
|
|
242
|
+
export function SiteStatusColumn({
|
|
243
|
+
linked,
|
|
244
|
+
personalKey = false,
|
|
245
|
+
machineKey = false,
|
|
246
|
+
https,
|
|
247
|
+
cache,
|
|
248
|
+
backup,
|
|
249
|
+
updates,
|
|
250
|
+
selected = false,
|
|
251
|
+
}: {
|
|
252
|
+
linked: boolean
|
|
253
|
+
personalKey?: boolean
|
|
254
|
+
machineKey?: boolean
|
|
255
|
+
https: boolean
|
|
256
|
+
cache: boolean
|
|
257
|
+
backup: boolean
|
|
258
|
+
updates: number
|
|
259
|
+
selected?: boolean
|
|
260
|
+
}) {
|
|
261
|
+
const onColor = selected ? theme.text : theme.good
|
|
262
|
+
const offColor = selected ? theme.textDim : theme.textFaint
|
|
263
|
+
// A fixed-width toggle slot: the letter when on, a faint dot when off.
|
|
264
|
+
const flag = (label: string, on: boolean) => (
|
|
265
|
+
<text content={`${on ? label : "ยท"} `} fg={on ? onColor : offColor} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
266
|
+
)
|
|
267
|
+
// Key slot is always two emoji-cells wide (pad the empties) so H/C/B never shift.
|
|
268
|
+
const keyCell = (personalKey ? "๐ค" : " ") + (machineKey ? "๐" : " ")
|
|
269
|
+
return (
|
|
270
|
+
<box style={{ flexDirection: "row", flexShrink: 0 }}>
|
|
271
|
+
<text content={`${linked ? "โ" : " "} `} fg={selected ? theme.text : theme.good} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
272
|
+
<text content={`${keyCell} `} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
273
|
+
{flag("H", https)}
|
|
274
|
+
{flag("C", cache)}
|
|
275
|
+
{flag("B", backup)}
|
|
276
|
+
<text
|
|
277
|
+
content={updates > 0 ? `โ${updates} `.padEnd(4) : " "}
|
|
278
|
+
fg={selected ? theme.text : theme.warn}
|
|
279
|
+
wrapMode="none"
|
|
280
|
+
style={{ flexShrink: 0 }}
|
|
281
|
+
/>
|
|
282
|
+
</box>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// A masked text field for secrets/tokens. OpenTUI's <input> keeps its own editor
|
|
287
|
+
// buffer and has no masking/password mode, so a controlled "show dots" value
|
|
288
|
+
// fights that buffer (the secret flashes, then the field clears). Instead this
|
|
289
|
+
// owns the value itself: it captures keypresses + paste while focused and renders
|
|
290
|
+
// only dots โ the secret is never drawn in cleartext. We deliberately do NOT use
|
|
291
|
+
// <input> here. Editing is append/backspace at the end (paste-and-go for tokens).
|
|
292
|
+
const MASK = "โข"
|
|
293
|
+
export function SecretInput({
|
|
294
|
+
value,
|
|
295
|
+
onChange,
|
|
296
|
+
focused,
|
|
297
|
+
placeholder,
|
|
298
|
+
onSubmit,
|
|
299
|
+
width,
|
|
300
|
+
}: {
|
|
301
|
+
value: string
|
|
302
|
+
onChange: (v: string) => void
|
|
303
|
+
focused?: boolean
|
|
304
|
+
placeholder?: string
|
|
305
|
+
onSubmit?: () => void
|
|
306
|
+
width?: number // fixed box width; omit for content-sized (existing call sites)
|
|
307
|
+
}) {
|
|
308
|
+
// Mirror the value in a ref so a rapid burst of key events (or a paste split
|
|
309
|
+
// into per-char events) accumulates instead of each handler closing over a
|
|
310
|
+
// stale `value` and overwriting the last โ we update the ref synchronously.
|
|
311
|
+
const valueRef = useRef(value)
|
|
312
|
+
valueRef.current = value
|
|
313
|
+
const setValue = (next: string) => {
|
|
314
|
+
valueRef.current = next
|
|
315
|
+
onChange(next)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
useKeyboard((key) => {
|
|
319
|
+
if (!focused) return
|
|
320
|
+
const name = key.name ?? ""
|
|
321
|
+
if (name === "return" || name === "enter") return onSubmit?.()
|
|
322
|
+
if (name === "backspace" || name === "delete") return setValue(valueRef.current.slice(0, -1))
|
|
323
|
+
// Let the host overlay handle navigation/dismiss keys.
|
|
324
|
+
if (["up", "down", "left", "right", "tab", "escape"].includes(name)) return
|
|
325
|
+
if (key.ctrl || key.meta) return
|
|
326
|
+
// A single printable character โ append it.
|
|
327
|
+
const seq: string = (key as { sequence?: string; raw?: string }).sequence ?? (key as { raw?: string }).raw ?? ""
|
|
328
|
+
if (seq.length === 1 && seq >= " ") setValue(valueRef.current + seq)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
usePaste((event: { bytes?: Uint8Array }) => {
|
|
332
|
+
if (!focused) return
|
|
333
|
+
const text = event?.bytes ? new TextDecoder().decode(event.bytes) : ""
|
|
334
|
+
const cleaned = text.replace(/[\r\n]+/g, "") // tokens are one line; drop any newlines
|
|
335
|
+
if (cleaned) setValue(valueRef.current + cleaned)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// The field always keeps a visible bgAlt box (same as the regular text inputs);
|
|
339
|
+
// focus is carried by the green โฏ marker + caret, not by dimming the box.
|
|
340
|
+
const hasValue = value.length > 0
|
|
341
|
+
return (
|
|
342
|
+
<box
|
|
343
|
+
style={{
|
|
344
|
+
height: 1,
|
|
345
|
+
flexDirection: "row",
|
|
346
|
+
backgroundColor: theme.bgAlt,
|
|
347
|
+
paddingLeft: 1,
|
|
348
|
+
paddingRight: 1,
|
|
349
|
+
...(width != null ? { width } : {}),
|
|
350
|
+
}}
|
|
351
|
+
>
|
|
352
|
+
<text content={focused ? "โฏ " : " "} fg={focused ? theme.brand : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
353
|
+
{hasValue ? <text content={MASK.repeat(value.length)} fg={theme.text} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
|
|
354
|
+
{focused ? <text content="โ" fg={theme.brand} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
|
|
355
|
+
{!hasValue ? (
|
|
356
|
+
<text content={placeholder ?? ""} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
357
|
+
) : null}
|
|
358
|
+
</box>
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Centered helper text, often for empty/loading states inside a panel.
|
|
363
|
+
export function Centered({ children }: { children: ReactNode }) {
|
|
364
|
+
return (
|
|
365
|
+
<box style={{ flexGrow: 1, justifyContent: "center", alignItems: "center", flexDirection: "column" }}>
|
|
366
|
+
{children}
|
|
367
|
+
</box>
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// 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.
|
|
374
|
+
export type ActionGroup = { title: string; items: [string, string][] }
|
|
375
|
+
export function ControlPanel({ heading, groups }: { heading: string; groups: ActionGroup[] }) {
|
|
376
|
+
return (
|
|
377
|
+
<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" />
|
|
386
|
+
</box>
|
|
387
|
+
))}
|
|
388
|
+
</box>
|
|
389
|
+
))}
|
|
390
|
+
</box>
|
|
391
|
+
)
|
|
392
|
+
}
|