spinuptui 0.21.2 → 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 +43 -1
- package/README.md +23 -6
- package/package.json +1 -1
- package/src/index.tsx +20 -0
- package/src/lib/cliSsh.ts +65 -19
- package/src/lib/sshExec.ts +93 -0
- package/src/lib/sshExecLog.ts +31 -0
- package/src/lib/sshExecPolicy.ts +51 -0
- package/src/ui/Details.tsx +151 -53
- package/src/ui/components.tsx +84 -14
- package/src/ui/views/Browser.tsx +35 -43
- package/src/ui/views/Search.tsx +16 -49
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,47 @@ versions; such changes are called out here.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [0.22.0] - 2026-07-13
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- **`spinuptui ssh-exec <domain> -- <command>`** — resolves the domain's SSH
|
|
18
|
+
target (same as `spinuptui ssh`) and runs `<command>` on it, but only if
|
|
19
|
+
it's read-only: anything matching a write/restart/destructive pattern
|
|
20
|
+
(plugin/theme changes, DB writes, service restarts, redirecting output to
|
|
21
|
+
a real file, package installs, etc.) is denied before it ever reaches the
|
|
22
|
+
server. Built for external tooling that needs to run diagnostic commands
|
|
23
|
+
unattended — any agent configured to touch a server only through this
|
|
24
|
+
command inherits the read-only guarantee without building its own guard.
|
|
25
|
+
Prints structured JSON (`stdout`/`stderr`/`exitCode` on success,
|
|
26
|
+
`reason:"command_denied"` on refusal) and logs every attempt — allowed,
|
|
27
|
+
denied, or unresolved — to `<config dir>/logs/ssh-exec-audit.jsonl`.
|
|
28
|
+
- **Refreshing a vanity page's HTML is now discoverable from where you'd look
|
|
29
|
+
for it.** A shared, grouped, key-chipped action list (`siteGroups()`) now
|
|
30
|
+
shows every per-site action — including `R` (refresh vanity page) for the
|
|
31
|
+
one site per server whose domain is the server's own hostname — in Search's
|
|
32
|
+
Actions pane and, on the Servers tab, in the bottom **Control strip**
|
|
33
|
+
(below). Previously `R` only lived inside the `m` monitoring overlay.
|
|
34
|
+
- **The Servers tab's bottom "Local" drawer is now a full-width Control
|
|
35
|
+
strip.** With a site selected it shows Site Control (Open/Remote/Local/
|
|
36
|
+
Vanity/Server); with a server selected (no site drilled into) it now shows
|
|
37
|
+
real **Server Control** (Clone/Access/Manage/Open) instead of a generic
|
|
38
|
+
placeholder. Each group's items flow and wrap across the full terminal
|
|
39
|
+
width instead of stacking one per line, so even a long group like Remote
|
|
40
|
+
fits in 1 line — each group's label sits in its own left column (pinned to
|
|
41
|
+
the top if its items wrap), and item labels are terser now that they don't
|
|
42
|
+
need to repeat their group's name (e.g. "In browser" under OPEN, not "Open
|
|
43
|
+
site in browser"). The Sites list also dropped its per-row status badges
|
|
44
|
+
(linked/keys/HTTPS/cache/backup/updates) — that info is already shown in
|
|
45
|
+
the Details pane for the selected site — to give the domain name more room;
|
|
46
|
+
Servers/Sites/Details are back to a simple 3-pane row.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
- **Servers and Search footers no longer duplicate the action list.** Both
|
|
50
|
+
views' key hints for a selected site are now nav-only (`select` / `back`);
|
|
51
|
+
the full, always-in-sync action list lives in the Control strip (or
|
|
52
|
+
Search's Actions pane). Also fixed a stale Search hint (`⏎ open site`) that was
|
|
53
|
+
shown even for a selected server row, where Enter is a no-op.
|
|
54
|
+
|
|
14
55
|
## [0.21.2] - 2026-07-10
|
|
15
56
|
|
|
16
57
|
### Fixed
|
|
@@ -1125,7 +1166,8 @@ Initial tagged release.
|
|
|
1125
1166
|
### Notes
|
|
1126
1167
|
- Read-only release: works with a SpinupWP **Read Only** API token.
|
|
1127
1168
|
|
|
1128
|
-
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.
|
|
1169
|
+
[Unreleased]: https://github.com/mwender/spinupwp-tui/compare/v0.22.0...HEAD
|
|
1170
|
+
[0.22.0]: https://github.com/mwender/spinupwp-tui/compare/v0.21.2...v0.22.0
|
|
1129
1171
|
[0.21.2]: https://github.com/mwender/spinupwp-tui/compare/v0.21.1...v0.21.2
|
|
1130
1172
|
[0.21.1]: https://github.com/mwender/spinupwp-tui/compare/v0.21.0...v0.21.1
|
|
1131
1173
|
[0.21.0]: https://github.com/mwender/spinupwp-tui/compare/v0.20.1...v0.21.0
|
package/README.md
CHANGED
|
@@ -29,8 +29,10 @@
|
|
|
29
29
|
|
|
30
30
|
- **Fleet dashboard** — connection status, disk usage, pending reboots/upgrades,
|
|
31
31
|
WP update counts, EOL Ubuntu flags, and a recent-activity feed for every server.
|
|
32
|
-
- **Server & site browser** — a three-pane navigator
|
|
33
|
-
|
|
32
|
+
- **Server & site browser** — a three-pane navigator (servers → sites →
|
|
33
|
+
details) with a full-width Control strip below showing every action for
|
|
34
|
+
whatever's selected — Site Control or Server Control, grouped and always
|
|
35
|
+
visible, no hidden key combos.
|
|
34
36
|
- **Stack detection & fleet composition** — classifies every site as Standard WP,
|
|
35
37
|
Bedrock, or Non-WP, with a PHP-version breakdown and an on-demand SSH probe for
|
|
36
38
|
precise identification. → [docs/stack-detection.md](docs/stack-detection.md)
|
|
@@ -74,10 +76,13 @@
|
|
|
74
76
|
[docs/uptime-kuma.md](docs/uptime-kuma.md)
|
|
75
77
|
- **Non-interactive CLI subcommands for external tooling** — `spinuptui ssh <domain>`
|
|
76
78
|
resolves a domain to a live SSH target with a live connectivity probe;
|
|
77
|
-
`spinuptui
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
`spinuptui ssh-exec <domain> -- <command>` runs a command over SSH, but only
|
|
80
|
+
if it's read-only — anything that looks like a write/restart/destructive
|
|
81
|
+
action is denied rather than run, so any agent using it inherits that
|
|
82
|
+
guarantee without building its own guard; `spinuptui incidents <domain>` /
|
|
83
|
+
`--all` surfaces Uptime Kuma's down/up history as JSON. Built so another
|
|
84
|
+
agent/script can go from just a domain to "what's wrong and how do I get
|
|
85
|
+
in" with no manual lookup. → [CLI subcommands](#cli-subcommands)
|
|
81
86
|
- **Completion toasts** — a non-focus-stealing toast when a background write
|
|
82
87
|
(PHP upgrade, reboot, DNS resolve, …) finishes.
|
|
83
88
|
- **Release notes** — an in-app "what's new" after every update, sourced
|
|
@@ -166,6 +171,17 @@ spinuptui ssh <domain> Print SSH access info for a site as JSON (resolves the
|
|
|
166
171
|
site's server, builds its SSH target, and runs a live
|
|
167
172
|
connectivity probe — for external tooling, e.g. an
|
|
168
173
|
incident-diagnostics agent handed only a domain)
|
|
174
|
+
spinuptui ssh-exec <domain> -- <command> Resolve the domain's SSH target and
|
|
175
|
+
run <command> on it, but only if the command is
|
|
176
|
+
read-only — anything matching a write/restart/destructive
|
|
177
|
+
pattern (plugin/theme changes, DB writes, service
|
|
178
|
+
restarts, file redirection to a real path, etc.) is
|
|
179
|
+
denied instead of run. Prints JSON: on success,
|
|
180
|
+
{ok:true, exitCode, stdout, stderr, ...}; on denial,
|
|
181
|
+
{ok:false, reason:"command_denied", message}. Every
|
|
182
|
+
attempt (allowed, denied, or unresolved) is appended to
|
|
183
|
+
<config dir>/logs/ssh-exec-audit.jsonl. Quote <command>
|
|
184
|
+
as one shell argument if it needs pipes or redirects.
|
|
169
185
|
spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma down/up
|
|
170
186
|
incidents as JSON, scoped to sites SpinupTUI manages
|
|
171
187
|
monitoring for (config.json's kumaMonitors) — --all
|
|
@@ -248,6 +264,7 @@ These can be set in `config.json` or via an environment variable:
|
|
|
248
264
|
| `H` | Enable / disable HTTPS on a site (Servers / Stacks / Search; needs a Read/Write token) |
|
|
249
265
|
| `P` | Purge a site's page cache + object cache (Servers / Stacks / Search; needs a Read/Write token) |
|
|
250
266
|
| `m` / `M` | Site/server monitoring — a two-pane browser of this site's Uptime Kuma monitors (`M` is an alias, since lowercase `m` is taken in Search/Stacks). Inside: `↑`/`↓` selects a monitor, `a` registers/recalibrates/repairs it (Front page and Cache bypass open a check-window picker), `x` removes Front page or Cache bypass, `o` opens the selected monitor in Kuma, `d` runs the site doctor, `n` shows/edits alert wiring, and vanity sites add `R`/`r` for page refresh/secret rotation (Servers / Stacks / Search) |
|
|
267
|
+
| `R` | Refresh a vanity page's HTML to the currently bundled version — no need to open `m` first. Shown under Vanity in the Servers tab's Control strip and Search's Actions list, for the one site per server whose domain is the server's own hostname |
|
|
251
268
|
| `a` | Server actions: reboot / restart a service (Servers / Search; needs a Read/Write token) |
|
|
252
269
|
| `c` | Create a new server (Servers tab; needs a Read/Write token) |
|
|
253
270
|
| `V` | Add a vanity site at the server's own hostname — DNS + site + HTTPS + SSH-key handoff (Servers tab; offered when no hostname site exists; needs a Read/Write token) |
|
package/package.json
CHANGED
package/src/index.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { isDevMode } from "./dev/devMode.ts"
|
|
|
15
15
|
import { SpinupWPClient } from "./api/client.ts"
|
|
16
16
|
import { resolveSshAccess } from "./lib/cliSsh.ts"
|
|
17
17
|
import { resolveIncidents } from "./lib/cliIncidents.ts"
|
|
18
|
+
import { execSshCommand } from "./lib/sshExec.ts"
|
|
18
19
|
|
|
19
20
|
const args = process.argv.slice(2)
|
|
20
21
|
const positionals = args.filter((a) => !a.startsWith("-"))
|
|
@@ -29,6 +30,8 @@ Usage:
|
|
|
29
30
|
spinuptui login Set or update your saved API token
|
|
30
31
|
spinuptui where Print the config file path and token source
|
|
31
32
|
spinuptui ssh <domain> Print SSH access info for a site (JSON)
|
|
33
|
+
spinuptui ssh-exec <domain> -- <command> Run a read-only command over SSH
|
|
34
|
+
(JSON); denies anything that looks like a remote write
|
|
32
35
|
spinuptui incidents <domain> | --all [--hours N] Print Uptime Kuma
|
|
33
36
|
down/up incidents for a site or the whole fleet (JSON)
|
|
34
37
|
spinuptui --version Print the version
|
|
@@ -68,6 +71,23 @@ if (command === "ssh") {
|
|
|
68
71
|
process.exit(result.ok ? 0 : 1)
|
|
69
72
|
}
|
|
70
73
|
|
|
74
|
+
if (command === "ssh-exec") {
|
|
75
|
+
const dashIdx = args.indexOf("--")
|
|
76
|
+
const domain = positionals[1]
|
|
77
|
+
const remoteCmd = dashIdx !== -1 ? args.slice(dashIdx + 1).join(" ") : ""
|
|
78
|
+
if (!domain || dashIdx === -1 || !remoteCmd.trim()) {
|
|
79
|
+
console.error(
|
|
80
|
+
JSON.stringify({ ok: false, reason: "usage", message: "Usage: spinuptui ssh-exec <domain> -- <command>" }),
|
|
81
|
+
)
|
|
82
|
+
process.exit(1)
|
|
83
|
+
}
|
|
84
|
+
const cfg = loadConfig()
|
|
85
|
+
const client = new SpinupWPClient(cfg)
|
|
86
|
+
const result = await execSshCommand(domain, remoteCmd, client, cfg)
|
|
87
|
+
console.log(JSON.stringify(result))
|
|
88
|
+
process.exit(result.ok ? 0 : 1)
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
if (command === "incidents") {
|
|
72
92
|
const rest = args.slice(1)
|
|
73
93
|
const hoursIdx = rest.indexOf("--hours")
|
package/src/lib/cliSsh.ts
CHANGED
|
@@ -40,13 +40,32 @@ export type SshAccessResult =
|
|
|
40
40
|
const GRANT_KEY_REMEDY =
|
|
41
41
|
"Run `spinuptui`, select this site in the Browser view, and press K to grant this device's key (sudo must be connected on the server first — press S)."
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// Resolved SSH connection details for a domain, without having tested them.
|
|
44
|
+
export interface SshTargetInfo {
|
|
45
|
+
domain: string
|
|
46
|
+
primaryDomain: string
|
|
47
|
+
sshTarget: string
|
|
48
|
+
port: number | null
|
|
49
|
+
server: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type SshTargetResolution =
|
|
53
|
+
| { ok: true; info: SshTargetInfo }
|
|
54
|
+
| { ok: false; result: SshAccessResult & { ok: false } }
|
|
55
|
+
|
|
56
|
+
// Domain -> site -> server -> SSH target/port, with no live connectivity check.
|
|
57
|
+
// Split out of resolveSshAccess so callers that are about to run a real command
|
|
58
|
+
// (which is itself a connectivity test) don't pay for a redundant probe first.
|
|
59
|
+
export async function resolveSshTargetInfo(
|
|
44
60
|
domain: string,
|
|
45
61
|
client: SpinupWPClientLike,
|
|
46
62
|
cfg: AppConfig,
|
|
47
|
-
): Promise<
|
|
63
|
+
): Promise<SshTargetResolution> {
|
|
48
64
|
if (!cfg.token) {
|
|
49
|
-
return {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
result: { ok: false, domain, reason: "no_token", message: "No API token configured. Run `spinuptui login`." },
|
|
68
|
+
}
|
|
50
69
|
}
|
|
51
70
|
|
|
52
71
|
let site
|
|
@@ -59,9 +78,12 @@ export async function resolveSshAccess(
|
|
|
59
78
|
if (matches.length === 0) {
|
|
60
79
|
return {
|
|
61
80
|
ok: false,
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
81
|
+
result: {
|
|
82
|
+
ok: false,
|
|
83
|
+
domain,
|
|
84
|
+
reason: "site_not_found",
|
|
85
|
+
message: `No site matching "${domain}" found in this SpinupWP account.`,
|
|
86
|
+
},
|
|
65
87
|
}
|
|
66
88
|
}
|
|
67
89
|
if (matches.length > 1) {
|
|
@@ -73,10 +95,13 @@ export async function resolveSshAccess(
|
|
|
73
95
|
)
|
|
74
96
|
return {
|
|
75
97
|
ok: false,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
98
|
+
result: {
|
|
99
|
+
ok: false,
|
|
100
|
+
domain,
|
|
101
|
+
reason: "multiple_matches",
|
|
102
|
+
message: `"${domain}" matches ${matches.length} sites in this account — cannot pick one automatically.`,
|
|
103
|
+
candidates,
|
|
104
|
+
},
|
|
80
105
|
}
|
|
81
106
|
}
|
|
82
107
|
site = matches[0]
|
|
@@ -85,27 +110,48 @@ export async function resolveSshAccess(
|
|
|
85
110
|
if (err instanceof ApiError && err.status === 401) {
|
|
86
111
|
return {
|
|
87
112
|
ok: false,
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
113
|
+
result: {
|
|
114
|
+
ok: false,
|
|
115
|
+
domain,
|
|
116
|
+
reason: "token_rejected",
|
|
117
|
+
message: "Saved API token was rejected (401) — it may be expired or revoked.",
|
|
118
|
+
remedy: "Run `spinuptui login` to save a fresh token.",
|
|
119
|
+
},
|
|
92
120
|
}
|
|
93
121
|
}
|
|
94
122
|
const message = err instanceof ApiError ? err.message : `Unexpected error: ${(err as Error).message}`
|
|
95
|
-
return { ok: false, domain, reason: "api_error", message }
|
|
123
|
+
return { ok: false, result: { ok: false, domain, reason: "api_error", message } }
|
|
96
124
|
}
|
|
97
125
|
|
|
98
126
|
if (!server.ip_address) {
|
|
99
127
|
return {
|
|
100
128
|
ok: false,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
129
|
+
result: {
|
|
130
|
+
ok: false,
|
|
131
|
+
domain,
|
|
132
|
+
reason: "server_has_no_ip",
|
|
133
|
+
message: `Server "${server.name}" has no IP address on file.`,
|
|
134
|
+
},
|
|
104
135
|
}
|
|
105
136
|
}
|
|
106
137
|
|
|
107
138
|
const target = resolveSiteSshTarget(site, server, cfg.sshUser)
|
|
108
139
|
const port = server.ssh_port && server.ssh_port !== 22 ? server.ssh_port : null
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
info: { domain, primaryDomain: site.domain, sshTarget: target, port, server: server.name },
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function resolveSshAccess(
|
|
148
|
+
domain: string,
|
|
149
|
+
client: SpinupWPClientLike,
|
|
150
|
+
cfg: AppConfig,
|
|
151
|
+
): Promise<SshAccessResult> {
|
|
152
|
+
const resolution = await resolveSshTargetInfo(domain, client, cfg)
|
|
153
|
+
if (!resolution.ok) return resolution.result
|
|
154
|
+
const { primaryDomain, sshTarget: target, port, server: serverName } = resolution.info
|
|
109
155
|
const portOpt = port ? ["-p", String(port)] : []
|
|
110
156
|
|
|
111
157
|
let proc: ReturnType<typeof Bun.spawn>
|
|
@@ -132,7 +178,7 @@ export async function resolveSshAccess(
|
|
|
132
178
|
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
|
|
133
179
|
|
|
134
180
|
if (exitCode === 0) {
|
|
135
|
-
return { ok: true, domain, primaryDomain
|
|
181
|
+
return { ok: true, domain, primaryDomain, sshTarget: target, port, server: serverName }
|
|
136
182
|
}
|
|
137
183
|
|
|
138
184
|
if (/permission denied/i.test(stderr)) {
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// `spinuptui ssh-exec <domain> -- <command>` — gated, read-only SSH command
|
|
2
|
+
// execution for external tooling (e.g. a diagnostics agent). Resolves domain ->
|
|
3
|
+
// SSH target the same way `spinuptui ssh` does, checks the command against the
|
|
4
|
+
// read-only allowlist (sshExecPolicy.ts), and only then runs it. Every attempt —
|
|
5
|
+
// allowed, denied, or a resolution failure — is written to the audit log
|
|
6
|
+
// (sshExecLog.ts) so the answer to "did this ever touch a server" doesn't depend
|
|
7
|
+
// on the calling agent's own report.
|
|
8
|
+
|
|
9
|
+
import type { AppConfig } from "../config.ts"
|
|
10
|
+
import type { SpinupWPClientLike } from "../api/client.ts"
|
|
11
|
+
import { resolveSshTargetInfo, type SshAccessReason, type SshAccessCandidate } from "./cliSsh.ts"
|
|
12
|
+
import { SSH_OPTS } from "./probe.ts"
|
|
13
|
+
import { classifySshCommand } from "./sshExecPolicy.ts"
|
|
14
|
+
import { logSshExecAttempt } from "./sshExecLog.ts"
|
|
15
|
+
|
|
16
|
+
export type SshExecReason = SshAccessReason | "command_denied"
|
|
17
|
+
|
|
18
|
+
export type SshExecResult =
|
|
19
|
+
| {
|
|
20
|
+
ok: true
|
|
21
|
+
domain: string
|
|
22
|
+
primaryDomain: string
|
|
23
|
+
sshTarget: string
|
|
24
|
+
command: string
|
|
25
|
+
exitCode: number
|
|
26
|
+
stdout: string
|
|
27
|
+
stderr: string
|
|
28
|
+
durationMs: number
|
|
29
|
+
}
|
|
30
|
+
| {
|
|
31
|
+
ok: false
|
|
32
|
+
domain: string
|
|
33
|
+
reason: SshExecReason
|
|
34
|
+
message: string
|
|
35
|
+
command?: string
|
|
36
|
+
remedy?: string
|
|
37
|
+
candidates?: SshAccessCandidate[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const EXEC_TIMEOUT_MS = 30_000
|
|
41
|
+
|
|
42
|
+
export async function execSshCommand(
|
|
43
|
+
domain: string,
|
|
44
|
+
command: string,
|
|
45
|
+
client: SpinupWPClientLike,
|
|
46
|
+
cfg: AppConfig,
|
|
47
|
+
): Promise<SshExecResult> {
|
|
48
|
+
const resolution = await resolveSshTargetInfo(domain, client, cfg)
|
|
49
|
+
if (!resolution.ok) {
|
|
50
|
+
logSshExecAttempt({ domain, command, decision: "error", reason: resolution.result.reason, message: resolution.result.message })
|
|
51
|
+
return { ...resolution.result, command }
|
|
52
|
+
}
|
|
53
|
+
const { primaryDomain, sshTarget, port } = resolution.info
|
|
54
|
+
|
|
55
|
+
const verdict = classifySshCommand(command)
|
|
56
|
+
if (verdict.decision === "deny") {
|
|
57
|
+
logSshExecAttempt({ domain: primaryDomain, command, decision: "denied", reason: verdict.reason })
|
|
58
|
+
return { ok: false, domain, reason: "command_denied", message: verdict.reason, command }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const portOpt = port ? ["-p", String(port)] : []
|
|
62
|
+
const start = Date.now()
|
|
63
|
+
let proc: ReturnType<typeof Bun.spawn>
|
|
64
|
+
try {
|
|
65
|
+
proc = Bun.spawn(["ssh", ...SSH_OPTS, ...portOpt, sshTarget, command], {
|
|
66
|
+
stdout: "pipe",
|
|
67
|
+
stderr: "pipe",
|
|
68
|
+
stdin: "ignore",
|
|
69
|
+
})
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const message = `Failed to launch ssh: ${(err as Error).message}`
|
|
72
|
+
logSshExecAttempt({ domain: primaryDomain, command, decision: "error", reason: "ssh_error", message })
|
|
73
|
+
return { ok: false, domain, reason: "ssh_error", message, command }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const timeout = setTimeout(() => {
|
|
77
|
+
try {
|
|
78
|
+
proc.kill()
|
|
79
|
+
} catch {
|
|
80
|
+
/* already gone */
|
|
81
|
+
}
|
|
82
|
+
}, EXEC_TIMEOUT_MS)
|
|
83
|
+
|
|
84
|
+
const exitCode = await proc.exited
|
|
85
|
+
clearTimeout(timeout)
|
|
86
|
+
const stdout = await new Response(proc.stdout as ReadableStream<Uint8Array>).text()
|
|
87
|
+
const stderr = await new Response(proc.stderr as ReadableStream<Uint8Array>).text()
|
|
88
|
+
const durationMs = Date.now() - start
|
|
89
|
+
|
|
90
|
+
logSshExecAttempt({ domain: primaryDomain, command, decision: "allowed", exitCode, stdout, stderr, durationMs })
|
|
91
|
+
|
|
92
|
+
return { ok: true, domain, primaryDomain, sshTarget, command, exitCode, stdout, stderr, durationMs }
|
|
93
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Audit trail for `spinuptui ssh-exec` — one JSONL line per attempt (allowed,
|
|
2
|
+
// denied, or a resolution failure), so "did this ever write to a server" has a
|
|
3
|
+
// durable answer independent of whatever the calling agent reports. Modeled on
|
|
4
|
+
// src/lib/cloneLog.ts's JSONL-under-configDir/logs pattern.
|
|
5
|
+
|
|
6
|
+
import { appendFileSync, mkdirSync } from "node:fs"
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
import { configDir } from "../config.ts"
|
|
9
|
+
|
|
10
|
+
const FIELD_CAP = 2_000
|
|
11
|
+
|
|
12
|
+
function auditLogPath(): string {
|
|
13
|
+
return join(configDir(), "logs", "ssh-exec-audit.jsonl")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function cap(v: unknown): unknown {
|
|
17
|
+
if (typeof v !== "string" || v.length <= FIELD_CAP) return v
|
|
18
|
+
return `${v.slice(0, FIELD_CAP)}…[${v.length - FIELD_CAP} chars trimmed]`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function logSshExecAttempt(entry: Record<string, unknown>): void {
|
|
22
|
+
try {
|
|
23
|
+
const dir = join(configDir(), "logs")
|
|
24
|
+
mkdirSync(dir, { recursive: true })
|
|
25
|
+
const out: Record<string, unknown> = { ts: new Date().toISOString() }
|
|
26
|
+
for (const [k, v] of Object.entries(entry)) out[k] = cap(v)
|
|
27
|
+
appendFileSync(auditLogPath(), JSON.stringify(out) + "\n")
|
|
28
|
+
} catch {
|
|
29
|
+
/* audit logging is best-effort — must never break a diagnostic command */
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Read-only allowlist for `spinuptui ssh-exec`. Ported from the WordPress Site
|
|
2
|
+
// Diagnostics playbook's Claude-Code-specific PreToolUse hook (guard-ssh-hook.py),
|
|
3
|
+
// which classified raw `ssh ...` Bash invocations for an unattended agent. Moving
|
|
4
|
+
// this into SpinupTUI itself means the guarantee holds for any agent that's
|
|
5
|
+
// configured to touch servers only through `ssh-exec`, not just Claude Code.
|
|
6
|
+
|
|
7
|
+
const DANGEROUS_PATTERNS: RegExp[] = [
|
|
8
|
+
/\b(rm|mv|chmod|chown|reboot|shutdown|kill|truncate)\b/i,
|
|
9
|
+
/systemctl\s+(restart|stop|reload)\b/i,
|
|
10
|
+
/service\s+\S+\s+restart\b/i,
|
|
11
|
+
/wp\s+plugin\s+(activate|deactivate|install|update|delete)\b/i,
|
|
12
|
+
/wp\s+theme\s+(activate|delete|install)\b/i,
|
|
13
|
+
/wp\s+db\s+(query|import|reset)\b/i,
|
|
14
|
+
/wp\s+option\s+(update|delete|add)\b/i,
|
|
15
|
+
/wp\s+user\b/i,
|
|
16
|
+
/wp\s+core\s+(update|install)\b/i,
|
|
17
|
+
/wp\s+(rewrite\s+flush|cache\s+flush)\b/i,
|
|
18
|
+
/crontab\s+-[re]\b/i,
|
|
19
|
+
/sed\s+-i\b/i,
|
|
20
|
+
/\b(DROP\s+TABLE|INSERT\s+INTO|DELETE\s+FROM)\b/i,
|
|
21
|
+
/UPDATE\s+\S+\s+SET\b/i,
|
|
22
|
+
/\b(apt(-get)?|yum|brew)\s+install\b/i,
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
// The Python hook's original check `(?<!=)>{1,2}` denied any `>`/`>>` unless
|
|
26
|
+
// immediately preceded by `=` — which wrongly caught `2>/dev/null`/`2>&1` (the
|
|
27
|
+
// character before `>` there is the fd digit, not `=`). Fix: strip known-safe
|
|
28
|
+
// redirects (discard to /dev/null, or an fd-to-fd dup like `2>&1`) first, then
|
|
29
|
+
// flag anything that still looks like a write to a real path.
|
|
30
|
+
function hasUnsafeRedirect(cmd: string): boolean {
|
|
31
|
+
const stripped = cmd.replace(/\d?>{1,2}\s*(\/dev\/null\b|&\d+)/g, "")
|
|
32
|
+
return />/.test(stripped)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SshCommandVerdict {
|
|
36
|
+
decision: "allow" | "deny"
|
|
37
|
+
reason: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function classifySshCommand(cmd: string): SshCommandVerdict {
|
|
41
|
+
if (DANGEROUS_PATTERNS.some((re) => re.test(cmd)) || hasUnsafeRedirect(cmd)) {
|
|
42
|
+
return {
|
|
43
|
+
decision: "deny",
|
|
44
|
+
reason: "This command looks like a remote write/restart/destructive action — spinuptui ssh-exec only runs read-only diagnostic commands.",
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
decision: "allow",
|
|
49
|
+
reason: "Read-only diagnostic command, no write/restart/destructive pattern detected.",
|
|
50
|
+
}
|
|
51
|
+
}
|
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/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/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
|
)
|
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" }}>
|