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,527 @@
|
|
|
1
|
+
// DNS provider connect / manage overlay — Phase 2 (access detection, read-only).
|
|
2
|
+
//
|
|
3
|
+
// Opened with `c` from the DNS inventory on a zone whose host is an API provider
|
|
4
|
+
// (Route 53 → AWS, Cloudflare). It's BOTH the connect form and the per-provider
|
|
5
|
+
// manager: it lists every credential ("connection") for that provider — because a
|
|
6
|
+
// dev has many accounts — and lets you add another, re-verify, or remove one.
|
|
7
|
+
//
|
|
8
|
+
// Connecting = verify the credential by listing the account's zones; that same
|
|
9
|
+
// data drives the inventory's ACCESS column. Verification runs before anything is
|
|
10
|
+
// stored, so a bad credential never persists. Secrets live in config.json (0600).
|
|
11
|
+
//
|
|
12
|
+
// Secret fields (CF token, AWS secret key) use the masked SecretInput; once saved
|
|
13
|
+
// a secret is never shown again (the list shows label + zone count + status only).
|
|
14
|
+
|
|
15
|
+
import { useEffect, useMemo, useState } from "react"
|
|
16
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react"
|
|
17
|
+
import { theme } from "../../lib/theme.ts"
|
|
18
|
+
import { truncate, timeAgo } from "../../lib/format.ts"
|
|
19
|
+
import { Panel, SecretInput, Spinner } from "../components.tsx"
|
|
20
|
+
import { List, moveSelection } from "../List.tsx"
|
|
21
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
22
|
+
import { useStore } from "../store.tsx"
|
|
23
|
+
import { openUrl, copyToClipboard } from "../../lib/open.ts"
|
|
24
|
+
import { apiProviderFor, consoleForHost, DEFAULT_WEB_ACCESS_NOTE, PROVIDER_REGISTRY, type Connection } from "../../lib/providers.ts"
|
|
25
|
+
import { normalizeDomain } from "../../lib/dns.ts"
|
|
26
|
+
|
|
27
|
+
type Mode = "list" | "add" | "note"
|
|
28
|
+
type Pane = "connections" | "zones"
|
|
29
|
+
|
|
30
|
+
// Fixed width (incl. the leading "● "/" " marker) for the delegate-zones apex
|
|
31
|
+
// column, so every row's note lands at the same column — sized for the longest
|
|
32
|
+
// domains actually seen (e.g. "collisionsafetyconsultantstn.com").
|
|
33
|
+
const ZONE_APEX_W = 38
|
|
34
|
+
|
|
35
|
+
export function ProviderConnect() {
|
|
36
|
+
const store = useStore()
|
|
37
|
+
const {
|
|
38
|
+
connectZoneTarget,
|
|
39
|
+
setConnectZoneTarget,
|
|
40
|
+
connectionsFor,
|
|
41
|
+
providerZones,
|
|
42
|
+
addConnection,
|
|
43
|
+
removeConnection,
|
|
44
|
+
verifyConnectionById,
|
|
45
|
+
setInputMode,
|
|
46
|
+
zonesForHostKey,
|
|
47
|
+
zoneAccessNotes,
|
|
48
|
+
setZoneAccessNote,
|
|
49
|
+
resolveAllFleetDomains,
|
|
50
|
+
dnsResolving,
|
|
51
|
+
dnsZones,
|
|
52
|
+
} = store
|
|
53
|
+
|
|
54
|
+
const target = connectZoneTarget
|
|
55
|
+
const provider = target ? apiProviderFor(target.hostKey) : null
|
|
56
|
+
const descriptor = provider ? PROVIDER_REGISTRY[provider] : null
|
|
57
|
+
const fields = descriptor?.fields ?? []
|
|
58
|
+
const connections: Connection[] = provider ? connectionsFor(provider) : []
|
|
59
|
+
// The web handoff for this host — an API provider's console (GoDaddy's Clients
|
|
60
|
+
// hub) OR a pure web-only host with no API at all (Namecheap, Network
|
|
61
|
+
// Solutions, ...). Its presence is what makes this a "delegate access" host.
|
|
62
|
+
const consoleInfo = target ? consoleForHost(target.hostKey) : null
|
|
63
|
+
const isDelegateMode = !!consoleInfo
|
|
64
|
+
// Display name: the ConnProvider's proper name when there is one, else the
|
|
65
|
+
// human label already resolved for this zone (e.g. "Namecheap"), else the
|
|
66
|
+
// raw host key as a last resort.
|
|
67
|
+
const providerName =
|
|
68
|
+
descriptor?.name ?? (target ? dnsZones.get(normalizeDomain(target.apex))?.zone?.providerLabel : undefined) ?? target?.hostKey ?? ""
|
|
69
|
+
|
|
70
|
+
// Jump straight to the add form when there are no connections — unless the
|
|
71
|
+
// host has a web handoff (GoDaddy, or any pure web-only host), where the
|
|
72
|
+
// list's `a`/`w` choice (add a key vs open the web console) should be visible first.
|
|
73
|
+
const [mode, setMode] = useState<Mode>(connections.length === 0 && !isDelegateMode ? "add" : "list")
|
|
74
|
+
const openConsole = () => {
|
|
75
|
+
if (!consoleInfo) return
|
|
76
|
+
if (target) copyToClipboard(target.apex)
|
|
77
|
+
openUrl(consoleInfo.url)
|
|
78
|
+
note(target ? `${consoleInfo.label} opened · ${target.apex} copied — Login as the client, then paste.` : `${consoleInfo.label} opened.`)
|
|
79
|
+
}
|
|
80
|
+
const [sel, setSel] = useState(0)
|
|
81
|
+
// Delegate-access hosts have no connections to select on the left — the Zones
|
|
82
|
+
// pane on the right is the whole point of this screen — so land there
|
|
83
|
+
// directly instead of hiding its `n`/`r` hints behind a → press.
|
|
84
|
+
const [pane, setPane] = useState<Pane>(() => (isDelegateMode ? "zones" : "connections"))
|
|
85
|
+
const [zoneIndex, setZoneIndex] = useState(0)
|
|
86
|
+
const [field, setField] = useState(0) // 0 = label; 1..N = descriptor.fields
|
|
87
|
+
const [label, setLabel] = useState("")
|
|
88
|
+
const [creds, setCreds] = useState<Record<string, string>>({})
|
|
89
|
+
const setCred = (name: string, val: string) => setCreds((c) => ({ ...c, [name]: val }))
|
|
90
|
+
const [busy, setBusy] = useState(false)
|
|
91
|
+
const [error, setError] = useState<string | null>(null)
|
|
92
|
+
const [flash, setFlash] = useState<string | null>(null)
|
|
93
|
+
const [noteDraft, setNoteDraft] = useState("")
|
|
94
|
+
const [resolvingAll, setResolvingAll] = useState(false)
|
|
95
|
+
|
|
96
|
+
const { height } = useTerminalDimensions()
|
|
97
|
+
const safeSel = Math.min(sel, Math.max(0, connections.length - 1))
|
|
98
|
+
const selectedConn = connections[safeSel]
|
|
99
|
+
const selectedVerified = selectedConn ? providerZones.get(selectedConn.id) : undefined
|
|
100
|
+
// The selected connection's zones (idea B: the right-pane drill-down).
|
|
101
|
+
const zones = useMemo(
|
|
102
|
+
() => (selectedVerified?.ok ? [...selectedVerified.zones].sort((a, b) => a.apex.localeCompare(b.apex)) : []),
|
|
103
|
+
[selectedVerified],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// Delegate-access hosts (no API, or API we're not connected to) have no
|
|
107
|
+
// API-verified zones list. Instead, list every zone already known (fleet-wide,
|
|
108
|
+
// from prior DNS lookups) to be hosted here — this pane doubles as the edit
|
|
109
|
+
// surface for each zone's access note.
|
|
110
|
+
const delegateZones = useMemo(
|
|
111
|
+
() => (isDelegateMode && target ? zonesForHostKey(target.hostKey) : []),
|
|
112
|
+
[isDelegateMode, target, zonesForHostKey],
|
|
113
|
+
)
|
|
114
|
+
// Apex-only view of whichever zone list is active, for navigation/selection.
|
|
115
|
+
const zoneRows = isDelegateMode ? delegateZones : zones.map((z) => z.apex)
|
|
116
|
+
const noteFor = (apex: string): { text: string; isOverride: boolean } => {
|
|
117
|
+
const override = zoneAccessNotes.get(apex)
|
|
118
|
+
return override ? { text: override, isOverride: true } : { text: DEFAULT_WEB_ACCESS_NOTE, isOverride: false }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Stop the "resolving…" indicator once nothing's in flight anymore.
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (resolvingAll && dnsResolving.size === 0) setResolvingAll(false)
|
|
124
|
+
}, [resolvingAll, dnsResolving])
|
|
125
|
+
|
|
126
|
+
// The form owns the keyboard while adding or editing a note (so typing doesn't navigate).
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
setInputMode(mode === "add" || mode === "note")
|
|
129
|
+
return () => setInputMode(false)
|
|
130
|
+
}, [mode, setInputMode])
|
|
131
|
+
|
|
132
|
+
// Reset the zone scroll when the selected connection changes.
|
|
133
|
+
useEffect(() => {
|
|
134
|
+
setZoneIndex(0)
|
|
135
|
+
}, [safeSel])
|
|
136
|
+
|
|
137
|
+
// Land the zones-pane cursor on the zone this screen was opened FROM (marked
|
|
138
|
+
// ●) rather than always the top of a list that can run 40+ deep — otherwise
|
|
139
|
+
// `n` right after opening silently edits the wrong (alphabetically-first) zone.
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!target) return
|
|
142
|
+
const idx = zoneRows.indexOf(target.apex)
|
|
143
|
+
if (idx >= 0) setZoneIndex(idx)
|
|
144
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
145
|
+
}, [target?.apex])
|
|
146
|
+
|
|
147
|
+
const close = () => {
|
|
148
|
+
setInputMode(false)
|
|
149
|
+
setConnectZoneTarget(null)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const note = (msg: string) => {
|
|
153
|
+
setFlash(msg)
|
|
154
|
+
setTimeout(() => setFlash(null), 2500)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const resetForm = () => {
|
|
158
|
+
setLabel("")
|
|
159
|
+
setCreds({})
|
|
160
|
+
setField(0)
|
|
161
|
+
setError(null)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const submit = async () => {
|
|
165
|
+
if (busy || !provider) return
|
|
166
|
+
setError(null)
|
|
167
|
+
const missing = fields.find((f) => !f.optional && !(creds[f.name] ?? "").trim())
|
|
168
|
+
if (missing) return setError(`${missing.label.replace(/ \(.*\)$/, "")} is required.`)
|
|
169
|
+
setBusy(true)
|
|
170
|
+
const res = await addConnection(provider, label, creds)
|
|
171
|
+
setBusy(false)
|
|
172
|
+
if (res.ok) {
|
|
173
|
+
note(`Connected — ${res.zones.length} zone${res.zones.length === 1 ? "" : "s"}`)
|
|
174
|
+
resetForm()
|
|
175
|
+
setMode("list")
|
|
176
|
+
} else {
|
|
177
|
+
const base = res.error || "Verification failed."
|
|
178
|
+
setError(consoleInfo ? `${base} — Esc, then w for the ${consoleInfo.label}.` : base)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const fieldCount = fields.length + 1 // + the label field
|
|
183
|
+
|
|
184
|
+
const selectedZoneApex = pane === "zones" ? zoneRows[Math.min(zoneIndex, zoneRows.length - 1)] : undefined
|
|
185
|
+
|
|
186
|
+
const saveNote = () => {
|
|
187
|
+
if (selectedZoneApex) setZoneAccessNote(selectedZoneApex, noteDraft)
|
|
188
|
+
setMode("list")
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
useKeyboard((key) => {
|
|
192
|
+
const raw = key.name ?? ""
|
|
193
|
+
if (busy) return // ignore input while verifying
|
|
194
|
+
|
|
195
|
+
if (mode === "add") {
|
|
196
|
+
if (raw === "escape") return connections.length > 0 || isDelegateMode ? (resetForm(), setMode("list")) : close()
|
|
197
|
+
if (raw === "up") return setField((f) => (f - 1 + fieldCount) % fieldCount)
|
|
198
|
+
if (raw === "down") return setField((f) => (f + 1) % fieldCount)
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (mode === "note") {
|
|
203
|
+
if (raw === "escape") setMode("list")
|
|
204
|
+
return
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// list mode — two panes: connections (left) and that account's zones (right)
|
|
208
|
+
switch (raw) {
|
|
209
|
+
case "escape":
|
|
210
|
+
return pane === "zones" ? setPane("connections") : close()
|
|
211
|
+
case "q":
|
|
212
|
+
return close()
|
|
213
|
+
case "up":
|
|
214
|
+
case "k":
|
|
215
|
+
return pane === "zones"
|
|
216
|
+
? setZoneIndex((i) => moveSelection(i, -1, zoneRows.length))
|
|
217
|
+
: setSel((i) => moveSelection(i, -1, connections.length))
|
|
218
|
+
case "down":
|
|
219
|
+
case "j":
|
|
220
|
+
return pane === "zones"
|
|
221
|
+
? setZoneIndex((i) => moveSelection(i, 1, zoneRows.length))
|
|
222
|
+
: setSel((i) => moveSelection(i, 1, connections.length))
|
|
223
|
+
case "right":
|
|
224
|
+
case "l":
|
|
225
|
+
case "tab":
|
|
226
|
+
if (pane === "connections" && zoneRows.length > 0) {
|
|
227
|
+
setZoneIndex(0)
|
|
228
|
+
setPane("zones")
|
|
229
|
+
}
|
|
230
|
+
return
|
|
231
|
+
case "left":
|
|
232
|
+
return pane === "zones" ? setPane("connections") : undefined
|
|
233
|
+
case "a":
|
|
234
|
+
// Adding a credential only makes sense for a real API provider.
|
|
235
|
+
if (pane !== "connections" || !provider) return
|
|
236
|
+
resetForm()
|
|
237
|
+
return setMode("add")
|
|
238
|
+
case "n":
|
|
239
|
+
// Edit the selected zone's access-note override (delegate-access providers only).
|
|
240
|
+
if (!isDelegateMode || pane !== "zones" || !selectedZoneApex) return
|
|
241
|
+
setNoteDraft(zoneAccessNotes.get(selectedZoneApex) ?? "")
|
|
242
|
+
return setMode("note")
|
|
243
|
+
case "r":
|
|
244
|
+
// Resolve every fleet domain so this pane's zone list can become complete.
|
|
245
|
+
if (!isDelegateMode) return
|
|
246
|
+
setResolvingAll(true)
|
|
247
|
+
resolveAllFleetDomains()
|
|
248
|
+
note("Resolving fleet DNS…")
|
|
249
|
+
return
|
|
250
|
+
case "w":
|
|
251
|
+
// Web-console handoff (e.g. GoDaddy without API access, or any pure
|
|
252
|
+
// web-only host like Namecheap/Network Solutions).
|
|
253
|
+
if (consoleInfo) openConsole()
|
|
254
|
+
return
|
|
255
|
+
case "v": {
|
|
256
|
+
if (pane !== "connections") return
|
|
257
|
+
if (selectedConn) {
|
|
258
|
+
verifyConnectionById(selectedConn.id)
|
|
259
|
+
note(`Re-verifying ${selectedConn.label || selectedConn.id}…`)
|
|
260
|
+
}
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
case "x": {
|
|
264
|
+
if (pane !== "connections") return
|
|
265
|
+
if (selectedConn && !selectedConn.env) {
|
|
266
|
+
removeConnection(selectedConn.id)
|
|
267
|
+
setSel(0)
|
|
268
|
+
setPane("connections")
|
|
269
|
+
note("Removed connection")
|
|
270
|
+
} else if (selectedConn?.env) {
|
|
271
|
+
note("Env connection — unset the variable to remove")
|
|
272
|
+
}
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
// Render as long as there's something to manage: a real API provider, or at
|
|
279
|
+
// least a web handoff (any recognized delegate-access-style host).
|
|
280
|
+
if (!target || (!provider && !isDelegateMode)) return null
|
|
281
|
+
|
|
282
|
+
return (
|
|
283
|
+
<box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 230 }}>
|
|
284
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
285
|
+
<text content={`${provider ? "🔑 Connect" : "📝"} ${providerName} `} fg={theme.brand} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
286
|
+
<text content={`for ${truncate(target.apex, 40)}`} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
287
|
+
</box>
|
|
288
|
+
|
|
289
|
+
<box style={{ flexGrow: 1, flexDirection: "column", padding: 1 }}>
|
|
290
|
+
{mode === "list" ? renderList() : mode === "note" ? renderNoteEditor() : renderAdd()}
|
|
291
|
+
</box>
|
|
292
|
+
|
|
293
|
+
<StatusBar hints={hints()} message={busy ? "Verifying…" : (flash ?? error ?? undefined)} messageColor={error && !flash ? theme.bad : theme.brand} showGlobal={false} />
|
|
294
|
+
</box>
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
function renderList() {
|
|
298
|
+
const zoneTitle = isDelegateMode
|
|
299
|
+
? ` Zones${delegateZones.length ? ` (${delegateZones.length})` : ""} `
|
|
300
|
+
: selectedConn
|
|
301
|
+
? ` Zones · ${truncate(selectedConn.label || selectedConn.id, 18)}${selectedVerified?.ok ? ` (${zones.length})` : ""} `
|
|
302
|
+
: " Zones "
|
|
303
|
+
return (
|
|
304
|
+
<box style={{ flexGrow: 1, flexDirection: "row", gap: 1 }}>
|
|
305
|
+
{/* Left: the provider's connections (accounts) */}
|
|
306
|
+
<Panel title={provider ? ` ${providerName} connections ` : ` ${providerName} `} active={pane === "connections"} width={48}>
|
|
307
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingTop: 1 }}>
|
|
308
|
+
{descriptor?.accessNote ? (
|
|
309
|
+
<box style={{ flexDirection: "column", marginBottom: 1 }}>
|
|
310
|
+
<text content={descriptor.accessNote} fg={theme.textDim} />
|
|
311
|
+
<box style={{ height: 1 }} />
|
|
312
|
+
</box>
|
|
313
|
+
) : null}
|
|
314
|
+
{provider && connections.length === 0 ? (
|
|
315
|
+
<text
|
|
316
|
+
content={consoleInfo ? `No API key — press a to add one, or w for the ${consoleInfo.label}.` : "No connections yet — press a to add one."}
|
|
317
|
+
fg={theme.textFaint}
|
|
318
|
+
/>
|
|
319
|
+
) : null}
|
|
320
|
+
{!provider ? <text content={`No API access for ${providerName}.`} fg={theme.textFaint} /> : null}
|
|
321
|
+
{isDelegateMode ? (
|
|
322
|
+
<box style={{ flexDirection: "column", marginTop: 1 }}>
|
|
323
|
+
<text
|
|
324
|
+
content={`Every zone on the right is a domain SpinupTUI has already seen hosted here. Each shows an access note — "${DEFAULT_WEB_ACCESS_NOTE}" by default, or your own note (e.g. a third-party IT contact) once you set one.`}
|
|
325
|
+
fg={theme.textFaint}
|
|
326
|
+
/>
|
|
327
|
+
<box style={{ height: 1 }} />
|
|
328
|
+
<text content="n edits the selected zone's note · r resolves your whole fleet's DNS to fill in any missing zones." fg={theme.textFaint} />
|
|
329
|
+
</box>
|
|
330
|
+
) : null}
|
|
331
|
+
{connections.length > 0 && (
|
|
332
|
+
connections.map((c, i) => {
|
|
333
|
+
const v = providerZones.get(c.id)
|
|
334
|
+
const selected = i === safeSel
|
|
335
|
+
const status = !v
|
|
336
|
+
? "not verified · v"
|
|
337
|
+
: v.ok
|
|
338
|
+
? `${v.zones.length} zone${v.zones.length === 1 ? "" : "s"} · ${timeAgo(new Date(v.verifiedAt).toISOString())}`
|
|
339
|
+
: `error: ${truncate(v.error ?? "failed", 22)}`
|
|
340
|
+
const statusColor = !v ? theme.textFaint : v.ok ? theme.good : theme.bad
|
|
341
|
+
return (
|
|
342
|
+
<box key={c.id} style={{ flexDirection: "row", backgroundColor: selected && pane === "connections" ? theme.selectedBg : undefined }}>
|
|
343
|
+
<text content={selected ? "› " : " "} fg={theme.brand} style={{ flexShrink: 0 }} />
|
|
344
|
+
<text content={truncate(c.label || c.id, 16).padEnd(17)} fg={selected ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
345
|
+
{c.env ? <text content="env " fg={theme.purple} style={{ flexShrink: 0 }} /> : null}
|
|
346
|
+
<text content={status} fg={selected ? theme.text : statusColor} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
347
|
+
</box>
|
|
348
|
+
)
|
|
349
|
+
})
|
|
350
|
+
)}
|
|
351
|
+
<box style={{ flexGrow: 1 }} />
|
|
352
|
+
<text
|
|
353
|
+
content={`${provider ? "a add · v re-verify · x remove · " : ""}→ zones${consoleInfo ? ` · w ${consoleInfo.label}` : ""}`}
|
|
354
|
+
fg={theme.textFaint}
|
|
355
|
+
wrapMode="none"
|
|
356
|
+
/>
|
|
357
|
+
</box>
|
|
358
|
+
</Panel>
|
|
359
|
+
|
|
360
|
+
{/* Right: the selected connection's zones (idea B drill-down) */}
|
|
361
|
+
<Panel title={zoneTitle} active={pane === "zones"} flexGrow={1}>
|
|
362
|
+
{renderZones()}
|
|
363
|
+
</Panel>
|
|
364
|
+
</box>
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function renderZones() {
|
|
369
|
+
if (isDelegateMode) return renderDelegateZones()
|
|
370
|
+
if (!selectedConn) return <text content="—" fg={theme.textFaint} />
|
|
371
|
+
if (!selectedVerified) return <text content="Not verified yet — press v on the connection." fg={theme.textFaint} wrapMode="none" />
|
|
372
|
+
if (!selectedVerified.ok) return <text content={`Verification failed: ${selectedVerified.error ?? "unknown error"}`} fg={theme.bad} wrapMode="none" />
|
|
373
|
+
if (zones.length === 0) return <text content="No DNS zones in this account." fg={theme.textFaint} wrapMode="none" />
|
|
374
|
+
return (
|
|
375
|
+
<List
|
|
376
|
+
items={zones}
|
|
377
|
+
selectedIndex={Math.min(zoneIndex, zones.length - 1)}
|
|
378
|
+
viewportRows={Math.max(3, height - 7)}
|
|
379
|
+
focused={pane === "zones"}
|
|
380
|
+
keyFor={(z) => z.apex}
|
|
381
|
+
renderRow={(z, selected) => {
|
|
382
|
+
const isTarget = z.apex === target!.apex
|
|
383
|
+
return (
|
|
384
|
+
<text
|
|
385
|
+
content={(isTarget ? "● " : " ") + truncate(z.apex, 48)}
|
|
386
|
+
fg={selected ? theme.text : isTarget ? theme.brand : theme.textDim}
|
|
387
|
+
wrapMode="none"
|
|
388
|
+
/>
|
|
389
|
+
)
|
|
390
|
+
}}
|
|
391
|
+
/>
|
|
392
|
+
)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// "Delegate Access"-style providers have no API to list zones with, so this
|
|
396
|
+
// draws from every zone this host has already been seen hosting, fleet-wide
|
|
397
|
+
// (zonesForHostKey — the same DNS cache DnsInventory uses). Doubles as the
|
|
398
|
+
// per-zone access-note editor (n).
|
|
399
|
+
function renderDelegateZones() {
|
|
400
|
+
return (
|
|
401
|
+
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
402
|
+
{resolvingAll ? (
|
|
403
|
+
<box style={{ flexDirection: "row", height: 1, marginBottom: 1 }}>
|
|
404
|
+
<Spinner color={theme.brand} interval={120} />
|
|
405
|
+
<text content=" resolving fleet DNS…" fg={theme.textDim} wrapMode="none" />
|
|
406
|
+
</box>
|
|
407
|
+
) : null}
|
|
408
|
+
{delegateZones.length === 0 ? (
|
|
409
|
+
<text content="No known zones yet for this host — press r to resolve your fleet's DNS." fg={theme.textFaint} wrapMode="none" />
|
|
410
|
+
) : (
|
|
411
|
+
<List
|
|
412
|
+
items={delegateZones}
|
|
413
|
+
selectedIndex={Math.min(zoneIndex, delegateZones.length - 1)}
|
|
414
|
+
viewportRows={Math.max(3, height - (resolvingAll ? 8 : 7))}
|
|
415
|
+
focused={pane === "zones"}
|
|
416
|
+
keyFor={(apex) => apex}
|
|
417
|
+
renderRow={(apex, selected) => {
|
|
418
|
+
const isTarget = apex === target!.apex
|
|
419
|
+
const { text: noteText, isOverride } = noteFor(apex)
|
|
420
|
+
const noteColor = selected ? theme.text : isOverride ? theme.warn : theme.textDim
|
|
421
|
+
// Fixed-width apex column (baked into the content, like DnsInventory's
|
|
422
|
+
// NAME/HOST columns) so the note column lands at the same spot on
|
|
423
|
+
// every row — a flex-grow apex doesn't reserve trailing space.
|
|
424
|
+
return (
|
|
425
|
+
<box style={{ flexDirection: "row" }}>
|
|
426
|
+
<text
|
|
427
|
+
content={((isTarget ? "● " : " ") + truncate(apex, ZONE_APEX_W - 4)).padEnd(ZONE_APEX_W)}
|
|
428
|
+
fg={selected ? theme.text : isTarget ? theme.brand : theme.textDim}
|
|
429
|
+
wrapMode="none"
|
|
430
|
+
style={{ flexShrink: 0 }}
|
|
431
|
+
/>
|
|
432
|
+
<text content={noteText} fg={noteColor} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
433
|
+
</box>
|
|
434
|
+
)
|
|
435
|
+
}}
|
|
436
|
+
/>
|
|
437
|
+
)}
|
|
438
|
+
</box>
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function renderNoteEditor() {
|
|
443
|
+
const inputStyle = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
|
|
444
|
+
return (
|
|
445
|
+
<Panel title=" Access note " active>
|
|
446
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
447
|
+
<text content={truncate(selectedZoneApex ?? "", 60)} fg={theme.text} wrapMode="none" />
|
|
448
|
+
<box style={{ height: 1 }} />
|
|
449
|
+
<text content={`Who has access, if not the default (${DEFAULT_WEB_ACCESS_NOTE})`} fg={theme.textDim} wrapMode="none" />
|
|
450
|
+
<input
|
|
451
|
+
focused
|
|
452
|
+
value={noteDraft}
|
|
453
|
+
placeholder={DEFAULT_WEB_ACCESS_NOTE}
|
|
454
|
+
onInput={setNoteDraft}
|
|
455
|
+
onSubmit={saveNote}
|
|
456
|
+
style={inputStyle}
|
|
457
|
+
/>
|
|
458
|
+
<box style={{ height: 1 }} />
|
|
459
|
+
<text content="Leave blank to clear the override and fall back to the default." fg={theme.textFaint} wrapMode="none" />
|
|
460
|
+
</box>
|
|
461
|
+
</Panel>
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function renderAdd() {
|
|
466
|
+
const fieldFg = (i: number) => (field === i ? theme.accent : theme.textDim)
|
|
467
|
+
const inputStyle = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
|
|
468
|
+
return (
|
|
469
|
+
<Panel title={` Add ${providerName} connection `} active>
|
|
470
|
+
<box style={{ flexDirection: "column", width: 72, paddingTop: 1, paddingBottom: 1 }}>
|
|
471
|
+
<text content="Label (optional — defaults to the account name)" fg={fieldFg(0)} />
|
|
472
|
+
<input focused={field === 0} value={label} placeholder="e.g. main account" onInput={setLabel} onSubmit={() => setField(1)} style={inputStyle} />
|
|
473
|
+
<box style={{ height: 1 }} />
|
|
474
|
+
|
|
475
|
+
{fields.map((f, j) => {
|
|
476
|
+
const idx = j + 1
|
|
477
|
+
const last = idx === fieldCount - 1
|
|
478
|
+
const onSubmit = last ? submit : () => setField(idx + 1)
|
|
479
|
+
const labelText = f.label + (f.secret ? " (masked)" : "")
|
|
480
|
+
return (
|
|
481
|
+
<box key={f.name} style={{ flexDirection: "column" }}>
|
|
482
|
+
<text content={labelText} fg={fieldFg(idx)} wrapMode="none" />
|
|
483
|
+
{f.secret ? (
|
|
484
|
+
<SecretInput focused={field === idx} value={creds[f.name] ?? ""} placeholder={f.placeholder} onChange={(v) => setCred(f.name, v)} onSubmit={onSubmit} />
|
|
485
|
+
) : (
|
|
486
|
+
<input focused={field === idx} value={creds[f.name] ?? ""} placeholder={f.placeholder} onInput={(v) => setCred(f.name, v)} onSubmit={onSubmit} style={inputStyle} />
|
|
487
|
+
)}
|
|
488
|
+
<box style={{ height: 1 }} />
|
|
489
|
+
</box>
|
|
490
|
+
)
|
|
491
|
+
})}
|
|
492
|
+
<text content={descriptor!.guidance} fg={theme.textFaint} />
|
|
493
|
+
</box>
|
|
494
|
+
</Panel>
|
|
495
|
+
)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function hints() {
|
|
499
|
+
if (mode === "add")
|
|
500
|
+
return [
|
|
501
|
+
{ key: "↑↓", label: "field" },
|
|
502
|
+
{ key: "⏎", label: "next / connect" },
|
|
503
|
+
{ key: "esc", label: connections.length > 0 ? "back" : "cancel" },
|
|
504
|
+
]
|
|
505
|
+
if (mode === "note")
|
|
506
|
+
return [
|
|
507
|
+
{ key: "⏎", label: "save" },
|
|
508
|
+
{ key: "esc", label: "cancel" },
|
|
509
|
+
]
|
|
510
|
+
if (pane === "zones")
|
|
511
|
+
return [
|
|
512
|
+
{ key: "↑↓/jk", label: "scroll zones" },
|
|
513
|
+
...(isDelegateMode ? [{ key: "n", label: "access note" }, { key: "r", label: "resolve all" }] : []),
|
|
514
|
+
{ key: "←", label: isDelegateMode ? "back" : "back to accounts" },
|
|
515
|
+
{ key: "esc", label: "back" },
|
|
516
|
+
]
|
|
517
|
+
return [
|
|
518
|
+
{ key: "↑↓", label: "account" },
|
|
519
|
+
{ key: "→", label: "its zones" },
|
|
520
|
+
...(provider ? [{ key: "a", label: "add" }] : []),
|
|
521
|
+
...(consoleInfo ? [{ key: "w", label: consoleInfo.label }] : []),
|
|
522
|
+
...(isDelegateMode ? [{ key: "r", label: "resolve all" }] : []),
|
|
523
|
+
...(provider ? [{ key: "v", label: "re-verify" }, { key: "x", label: "remove" }] : []),
|
|
524
|
+
{ key: "esc", label: "close" },
|
|
525
|
+
]
|
|
526
|
+
}
|
|
527
|
+
}
|