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,562 @@
|
|
|
1
|
+
// Focused single-record editor — Phase 3, the migration record manager's writes.
|
|
2
|
+
//
|
|
3
|
+
// Opened from the DNS inventory on a hosting record (one whose host we hold a
|
|
4
|
+
// verified API key for), in one of two modes: `⏎` edits the TTL, `p` repoints the
|
|
5
|
+
// VALUE (where the record points — the migration cutover write). This is NOT a
|
|
6
|
+
// zone editor: it reads and edits the ONE record it was handed (no zone listing),
|
|
7
|
+
// so the only records reachable here are a site's own hosting records — MX/TXT/
|
|
8
|
+
// DKIM/etc. are never even fetched. Both modes run the same confirm-before-firing
|
|
9
|
+
// flow as the PHP upgrade; the write + its polling live in the store
|
|
10
|
+
// (startTtlChange / startValueChange), so closing this modal doesn't abandon a
|
|
11
|
+
// Route 53 change mid-propagation.
|
|
12
|
+
//
|
|
13
|
+
// The repoint picker leads with the account's SpinupWP servers (name + IP) —
|
|
14
|
+
// pointing a record at one of your own boxes is what this is for — with a custom
|
|
15
|
+
// IP as the fallback. Editability differs by mode: `editable` gates the TTL (a
|
|
16
|
+
// Route 53 alias / routing-policy set / Cloudflare proxied record has no real
|
|
17
|
+
// TTL), `valueEditable` gates the repoint (a Cloudflare PROXIED record's origin
|
|
18
|
+
// IP stays PATCHable — that repoint is an origin swap behind the proxy). The read
|
|
19
|
+
// is on-demand and ephemeral (no disk cache) — re-opening re-fetches.
|
|
20
|
+
|
|
21
|
+
import { useEffect, useMemo, useState } from "react"
|
|
22
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react"
|
|
23
|
+
import { theme } from "../../lib/theme.ts"
|
|
24
|
+
import { truncate } from "../../lib/format.ts"
|
|
25
|
+
import { apiProviderFor, PROVIDER_REGISTRY, normNameservers, nameserversMatch } from "../../lib/providers.ts"
|
|
26
|
+
import { resolveZone } from "../../lib/dns.ts"
|
|
27
|
+
import { TTL_PRESETS, formatTtl, validateTtl, defaultTtlFor, type DnsRecord } from "../../lib/dnsRecords.ts"
|
|
28
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
29
|
+
import { Panel, Spinner, Centered } from "../components.tsx"
|
|
30
|
+
import { moveSelection } from "../List.tsx"
|
|
31
|
+
import { useStore } from "../store.tsx"
|
|
32
|
+
import { isRecordWriteInFlight } from "../store.tsx"
|
|
33
|
+
|
|
34
|
+
type Phase = "pick" | "custom" | "confirm" | "tracking"
|
|
35
|
+
type LoadState = "loading" | "ready" | "error"
|
|
36
|
+
|
|
37
|
+
// A TTL option in the picker. ttl = -1 is the "Custom…" sentinel.
|
|
38
|
+
interface TtlOption {
|
|
39
|
+
ttl: number
|
|
40
|
+
label: string
|
|
41
|
+
}
|
|
42
|
+
const CUSTOM_TTL = -1
|
|
43
|
+
|
|
44
|
+
// A repoint target in the picker: one of the account's servers, or the custom-IP
|
|
45
|
+
// sentinel (value = "").
|
|
46
|
+
interface ValueOption {
|
|
47
|
+
value: string
|
|
48
|
+
label: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Validate a literal IP for a record type. Returns an error string, or null when ok.
|
|
52
|
+
function validateIp(type: string, s: string): string | null {
|
|
53
|
+
if (type === "AAAA") {
|
|
54
|
+
return s.includes(":") && /^[0-9a-fA-F:]{2,45}$/.test(s) ? null : "Enter a valid IPv6 address."
|
|
55
|
+
}
|
|
56
|
+
const m = s.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
|
|
57
|
+
return m && m.slice(1).every((o) => Number(o) <= 255) ? null : "Enter a valid IPv4 address (e.g. 203.0.113.10)."
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function DnsRecords() {
|
|
61
|
+
const { dnsRecordsTarget, setDnsRecordsTarget, getZoneRecord, startTtlChange, startValueChange, clearRecordWrite, recordWrites, setInputMode, servers } = useStore()
|
|
62
|
+
const target = dnsRecordsTarget
|
|
63
|
+
const mode = target?.edit ?? "ttl"
|
|
64
|
+
const provider = target ? apiProviderFor(target.hostKey) : null
|
|
65
|
+
const providerName = provider ? PROVIDER_REGISTRY[provider].name : ""
|
|
66
|
+
const { height } = useTerminalDimensions()
|
|
67
|
+
|
|
68
|
+
const [loadState, setLoadState] = useState<LoadState>("loading")
|
|
69
|
+
const [loadError, setLoadError] = useState<string | null>(null)
|
|
70
|
+
const [record, setRecord] = useState<DnsRecord | null>(null)
|
|
71
|
+
const [zoneId, setZoneId] = useState("")
|
|
72
|
+
const [phase, setPhase] = useState<Phase>("pick")
|
|
73
|
+
const [pickIndex, setPickIndex] = useState(0)
|
|
74
|
+
const [custom, setCustom] = useState("")
|
|
75
|
+
const [customError, setCustomError] = useState<string | null>(null)
|
|
76
|
+
const [targetTtl, setTargetTtl] = useState<number | null>(null)
|
|
77
|
+
const [targetValue, setTargetValue] = useState<string | null>(null)
|
|
78
|
+
const [activeKey, setActiveKey] = useState<string | null>(null)
|
|
79
|
+
const [flash, setFlash] = useState<string | null>(null)
|
|
80
|
+
// Edit-time NS-match pre-flight: when the account's hosted-zone NS don't match
|
|
81
|
+
// the FRESH live authoritative NS, this account isn't the one serving the domain
|
|
82
|
+
// (a stale/duplicate zone) — editing it would be a silent no-op or, worse, edit
|
|
83
|
+
// the wrong zone. We surface it and hard-stop writes. `null` = not yet known or
|
|
84
|
+
// not checkable (e.g. Cloudflare, whose access is already NS-matched in Phase 2).
|
|
85
|
+
const [ns, setNs] = useState<{ live: string[]; zone: string[] } | null>(null)
|
|
86
|
+
const nsMismatch = ns != null && ns.live.length > 0 && ns.zone.length > 0 && !nameserversMatch(ns.zone, ns.live)
|
|
87
|
+
|
|
88
|
+
const apex = target?.apex ?? ""
|
|
89
|
+
|
|
90
|
+
// Load the single hosting record when the overlay opens. We re-dig the FRESH live
|
|
91
|
+
// NS in parallel — the NS-match gate compares it against the zone's apex NS.
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (!target) return
|
|
94
|
+
let cancelled = false
|
|
95
|
+
setLoadState("loading")
|
|
96
|
+
setNs(null)
|
|
97
|
+
void Promise.all([getZoneRecord(target.connId, target.apex, target.record.name, target.record.type), resolveZone(target.apex)]).then(([res, liveZone]) => {
|
|
98
|
+
if (cancelled) return
|
|
99
|
+
if (res.ok && res.record) {
|
|
100
|
+
setRecord(res.record)
|
|
101
|
+
setZoneId(res.zoneId)
|
|
102
|
+
setLoadState("ready")
|
|
103
|
+
// Open the picker cursor on the current value: a preset lands on its row; an
|
|
104
|
+
// off-list TTL is prepended as "current" at index 0; null (CF auto) → 0.
|
|
105
|
+
const i = TTL_PRESETS.findIndex((o) => o.ttl === res.record!.ttl)
|
|
106
|
+
setPickIndex(target.edit === "ttl" && i >= 0 ? i : 0)
|
|
107
|
+
setNs({ live: normNameservers(liveZone?.nameservers), zone: normNameservers(res.apexNs) })
|
|
108
|
+
} else {
|
|
109
|
+
setLoadError(res.error ?? "Couldn't read this record.")
|
|
110
|
+
setLoadState("error")
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
return () => {
|
|
114
|
+
cancelled = true
|
|
115
|
+
}
|
|
116
|
+
}, [target, getZoneRecord])
|
|
117
|
+
|
|
118
|
+
// The custom-input phase owns the keyboard while it's focused.
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
setInputMode(phase === "custom")
|
|
121
|
+
return () => setInputMode(false)
|
|
122
|
+
}, [phase, setInputMode])
|
|
123
|
+
|
|
124
|
+
// TTL options: presets, the record's current value when off-list, and a Custom… entry.
|
|
125
|
+
const ttlOptions = useMemo<TtlOption[]>(() => {
|
|
126
|
+
const opts: TtlOption[] = [...TTL_PRESETS]
|
|
127
|
+
const cur = record?.ttl
|
|
128
|
+
if (cur != null && !opts.some((o) => o.ttl === cur)) opts.unshift({ ttl: cur, label: "current" })
|
|
129
|
+
opts.push({ ttl: CUSTOM_TTL, label: "Custom…" })
|
|
130
|
+
return opts
|
|
131
|
+
}, [record])
|
|
132
|
+
|
|
133
|
+
// Repoint options: the account's servers (name + IPv4), then Custom…. An AAAA
|
|
134
|
+
// record gets only Custom… — server IPs from the SpinupWP API are IPv4.
|
|
135
|
+
const valueOptions = useMemo<ValueOption[]>(() => {
|
|
136
|
+
const opts: ValueOption[] =
|
|
137
|
+
record?.type === "AAAA"
|
|
138
|
+
? []
|
|
139
|
+
: servers
|
|
140
|
+
.filter((s) => s.ip_address)
|
|
141
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
142
|
+
.map((s) => ({ value: s.ip_address!, label: s.name }))
|
|
143
|
+
opts.push({ value: "", label: "Custom IP…" })
|
|
144
|
+
return opts
|
|
145
|
+
}, [servers, record])
|
|
146
|
+
|
|
147
|
+
const currentValue = record?.values[0] ?? ""
|
|
148
|
+
// The server name behind an IP, for labeling the current/target values.
|
|
149
|
+
const serverNameFor = (ip: string) => servers.find((s) => s.ip_address === ip)?.name
|
|
150
|
+
|
|
151
|
+
// Tracking display derives from the store, like the PHP-upgrade overlay.
|
|
152
|
+
const progress = activeKey ? recordWrites.get(activeKey) : undefined
|
|
153
|
+
const dp: Phase | "done" | "error" =
|
|
154
|
+
phase !== "tracking"
|
|
155
|
+
? phase
|
|
156
|
+
: !progress || isRecordWriteInFlight(progress)
|
|
157
|
+
? "tracking"
|
|
158
|
+
: progress.status === "failed"
|
|
159
|
+
? "error"
|
|
160
|
+
: "done"
|
|
161
|
+
|
|
162
|
+
// Mode-specific editability: TTL needs `editable`; the repoint needs
|
|
163
|
+
// `valueEditable` (defaults to `editable` — Cloudflare sets it explicitly so a
|
|
164
|
+
// proxied record's origin stays repointable even though its TTL isn't).
|
|
165
|
+
const canEdit = record == null ? false : mode === "ttl" ? record.editable : (record.valueEditable ?? record.editable)
|
|
166
|
+
const blocked = loadState === "ready" && (nsMismatch || (record != null && !canEdit))
|
|
167
|
+
|
|
168
|
+
const close = () => {
|
|
169
|
+
// Drop a settled failure so its marker doesn't linger; an in-flight change
|
|
170
|
+
// keeps polling in the store.
|
|
171
|
+
if (activeKey && progress?.status === "failed") clearRecordWrite(activeKey)
|
|
172
|
+
setInputMode(false)
|
|
173
|
+
setDnsRecordsTarget(null)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const showFlash = (msg: string) => {
|
|
177
|
+
setFlash(msg)
|
|
178
|
+
setTimeout(() => setFlash(null), 2000)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const chooseTtl = (ttl: number) => {
|
|
182
|
+
setTargetTtl(ttl)
|
|
183
|
+
setPhase("confirm")
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const chooseValue = (value: string) => {
|
|
187
|
+
if (record && value === currentValue) return showFlash("The record already points there.")
|
|
188
|
+
setTargetValue(value)
|
|
189
|
+
setPhase("confirm")
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const submitCustom = () => {
|
|
193
|
+
if (!provider || !record) return
|
|
194
|
+
const raw = custom.trim()
|
|
195
|
+
if (mode === "value") {
|
|
196
|
+
const err = raw ? validateIp(record.type, raw) : "Enter an IP address."
|
|
197
|
+
if (err) return setCustomError(err)
|
|
198
|
+
setCustomError(null)
|
|
199
|
+
return chooseValue(raw)
|
|
200
|
+
}
|
|
201
|
+
const n = Number(raw)
|
|
202
|
+
const err = !raw || Number.isNaN(n) ? "Enter a TTL in seconds." : validateTtl(provider, n)
|
|
203
|
+
if (err) return setCustomError(err)
|
|
204
|
+
setCustomError(null)
|
|
205
|
+
chooseTtl(n)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const fire = () => {
|
|
209
|
+
if (!target || !record) return
|
|
210
|
+
if (mode === "value") {
|
|
211
|
+
if (targetValue == null) return
|
|
212
|
+
startValueChange(target.connId, zoneId, record, targetValue)
|
|
213
|
+
} else {
|
|
214
|
+
if (targetTtl == null) return
|
|
215
|
+
startTtlChange(target.connId, zoneId, record, targetTtl)
|
|
216
|
+
}
|
|
217
|
+
setActiveKey(record.key)
|
|
218
|
+
setPhase("tracking")
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
useKeyboard((key) => {
|
|
222
|
+
const raw = key.name ?? ""
|
|
223
|
+
const name = key.shift && raw.length === 1 ? raw.toUpperCase() : raw
|
|
224
|
+
|
|
225
|
+
// Esc/q always backs out a step (or closes). In custom mode the input owns
|
|
226
|
+
// typing — only Esc reaches us.
|
|
227
|
+
if (phase === "custom") {
|
|
228
|
+
if (raw === "escape") {
|
|
229
|
+
setCustomError(null)
|
|
230
|
+
setPhase("pick")
|
|
231
|
+
}
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
if (raw === "escape" || name === "q") {
|
|
235
|
+
if (phase === "confirm") return setPhase("pick")
|
|
236
|
+
// After a successful change, Esc closes back to the inventory — we KEEP the
|
|
237
|
+
// settled write so its row there reflects the new state (the authoritative
|
|
238
|
+
// host already has it); only failures are dropped (in close()).
|
|
239
|
+
return close()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (loadState !== "ready" || blocked) return
|
|
243
|
+
|
|
244
|
+
if (phase === "pick") {
|
|
245
|
+
const n = mode === "value" ? valueOptions.length : ttlOptions.length
|
|
246
|
+
switch (name) {
|
|
247
|
+
case "up":
|
|
248
|
+
case "k":
|
|
249
|
+
return setPickIndex((i) => moveSelection(i, -1, n))
|
|
250
|
+
case "down":
|
|
251
|
+
case "j":
|
|
252
|
+
return setPickIndex((i) => moveSelection(i, 1, n))
|
|
253
|
+
case "return":
|
|
254
|
+
case "right":
|
|
255
|
+
case "l": {
|
|
256
|
+
if (mode === "value") {
|
|
257
|
+
const opt = valueOptions[pickIndex]
|
|
258
|
+
if (!opt) return
|
|
259
|
+
if (opt.value === "") {
|
|
260
|
+
setCustom("") // start empty — repointing to the current IP is never the goal
|
|
261
|
+
setCustomError(null)
|
|
262
|
+
return setPhase("custom")
|
|
263
|
+
}
|
|
264
|
+
return chooseValue(opt.value)
|
|
265
|
+
}
|
|
266
|
+
const opt = ttlOptions[pickIndex]
|
|
267
|
+
if (!opt) return
|
|
268
|
+
if (opt.ttl === CUSTOM_TTL) {
|
|
269
|
+
setCustom(record ? String(defaultTtlFor(record)) : "")
|
|
270
|
+
setCustomError(null)
|
|
271
|
+
return setPhase("custom")
|
|
272
|
+
}
|
|
273
|
+
return chooseTtl(opt.ttl)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (phase === "confirm") {
|
|
280
|
+
if (name === "y") return fire()
|
|
281
|
+
if (name === "left" || name === "h") return setPhase("pick")
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (dp === "error") {
|
|
286
|
+
if (name === "r") {
|
|
287
|
+
if (activeKey) clearRecordWrite(activeKey)
|
|
288
|
+
setActiveKey(null)
|
|
289
|
+
setPhase("pick")
|
|
290
|
+
}
|
|
291
|
+
return
|
|
292
|
+
}
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
if (!target || !provider) return null
|
|
296
|
+
|
|
297
|
+
const recLabel = `${truncate(target.record.name || "@", 32)} ${target.record.type}`
|
|
298
|
+
const heading = mode === "value" ? "Point at" : "Edit TTL"
|
|
299
|
+
|
|
300
|
+
return (
|
|
301
|
+
<box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 235 }}>
|
|
302
|
+
<box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
|
|
303
|
+
<text content={`🔧 ${heading} · ${recLabel} `} fg={theme.brand} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
304
|
+
<text content={`${truncate(apex, 28)} · ${providerName}`} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
|
|
305
|
+
<box style={{ flexGrow: 1 }} />
|
|
306
|
+
{loadState === "loading" ? <Spinner color={theme.brand} interval={120} /> : null}
|
|
307
|
+
</box>
|
|
308
|
+
|
|
309
|
+
{nsMismatch && loadState === "ready" ? (
|
|
310
|
+
<box style={{ flexDirection: "column", height: 2, backgroundColor: theme.bad, paddingLeft: 1, paddingRight: 1 }}>
|
|
311
|
+
<text content={`⚠ This account's hosted zone isn't the one serving ${truncate(apex, 30)} live — edits are blocked.`} fg={theme.bg} wrapMode="none" />
|
|
312
|
+
<text content={` live NS: ${truncate(ns!.live.join(" ") || "unknown", 50)} · this zone: ${truncate(ns!.zone.join(" ") || "unknown", 50)}`} fg={theme.bg} wrapMode="none" />
|
|
313
|
+
</box>
|
|
314
|
+
) : null}
|
|
315
|
+
|
|
316
|
+
<box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingRight: 1 }}>
|
|
317
|
+
{loadState === "loading" ? (
|
|
318
|
+
<Centered>
|
|
319
|
+
<text content="Reading record…" fg={theme.textDim} />
|
|
320
|
+
</Centered>
|
|
321
|
+
) : loadState === "error" ? (
|
|
322
|
+
<Centered>
|
|
323
|
+
<text content={`✕ ${loadError}`} fg={theme.bad} wrapMode="none" />
|
|
324
|
+
</Centered>
|
|
325
|
+
) : (
|
|
326
|
+
<Centered>{renderBody()}</Centered>
|
|
327
|
+
)}
|
|
328
|
+
</box>
|
|
329
|
+
|
|
330
|
+
<StatusBar hints={hints()} message={flash ?? undefined} messageColor={theme.brand} showGlobal={false} />
|
|
331
|
+
</box>
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
function renderBody() {
|
|
335
|
+
if (!record) return null
|
|
336
|
+
const curLabel = formatTtl(record.ttl)
|
|
337
|
+
|
|
338
|
+
// NS-mismatch is already shown as the banner; non-editable records open here.
|
|
339
|
+
if (nsMismatch) {
|
|
340
|
+
return (
|
|
341
|
+
<Panel title=" Editing blocked " active>
|
|
342
|
+
<box style={{ flexDirection: "column", width: 56, paddingTop: 1, paddingBottom: 1 }}>
|
|
343
|
+
<text content="This connection can't safely edit this record — see the ⚠ above." fg={theme.textDim} wrapMode="none" />
|
|
344
|
+
<box style={{ height: 1 }} />
|
|
345
|
+
<text content="Esc to close" fg={theme.textFaint} />
|
|
346
|
+
</box>
|
|
347
|
+
</Panel>
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
if (!canEdit) {
|
|
351
|
+
return (
|
|
352
|
+
<Panel title=" Read-only record " active>
|
|
353
|
+
<box style={{ flexDirection: "column", width: 56, paddingTop: 1, paddingBottom: 1 }}>
|
|
354
|
+
<box style={{ flexDirection: "row" }}>
|
|
355
|
+
<text content={`${truncate(record.name || "@", 28)} `} fg={theme.accent} wrapMode="none" />
|
|
356
|
+
<text content={record.type} fg={theme.textDim} wrapMode="none" />
|
|
357
|
+
</box>
|
|
358
|
+
<box style={{ height: 1 }} />
|
|
359
|
+
<text content={`Can't ${mode === "value" ? "repoint this record" : "edit this TTL"} — ${record.reason ?? "read-only"}.`} fg={theme.textDim} wrapMode="none" />
|
|
360
|
+
<box style={{ height: 1 }} />
|
|
361
|
+
<text content="Esc to close" fg={theme.textFaint} />
|
|
362
|
+
</box>
|
|
363
|
+
</Panel>
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (phase === "pick") {
|
|
368
|
+
if (mode === "value") {
|
|
369
|
+
// The current value's server is already tagged "(current)" in the list, so
|
|
370
|
+
// the header keeps just the bare IP — the row must fit width 58.
|
|
371
|
+
return (
|
|
372
|
+
<Panel title=" Point this record at " active>
|
|
373
|
+
<box style={{ flexDirection: "column", width: 58 }}>
|
|
374
|
+
<box style={{ flexDirection: "row" }}>
|
|
375
|
+
<text content={`${truncate(record.name || "@", 30)} `} fg={theme.accent} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
376
|
+
<text content={record.type} fg={theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
377
|
+
<box style={{ flexGrow: 1 }} />
|
|
378
|
+
<text content={`now ${truncate(currentValue || "—", 18)}`} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
|
|
379
|
+
</box>
|
|
380
|
+
<box style={{ height: 1 }} />
|
|
381
|
+
{valueOptions.map((o, i) => {
|
|
382
|
+
const sel = i === pickIndex
|
|
383
|
+
const isCustom = o.value === ""
|
|
384
|
+
const isCurrent = !isCustom && o.value === currentValue
|
|
385
|
+
return (
|
|
386
|
+
<box key={o.label + o.value} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.selectedBg : undefined }}>
|
|
387
|
+
<text content={(sel ? "❯ " : " ") + truncate(o.label, 30)} fg={sel ? theme.text : isCurrent ? theme.textFaint : theme.textDim} style={{ flexGrow: 1 }} wrapMode="none" />
|
|
388
|
+
<text content={isCustom ? "" : o.value + (isCurrent ? " (current)" : "")} fg={sel ? theme.text : theme.textFaint} style={{ flexShrink: 0 }} wrapMode="none" />
|
|
389
|
+
</box>
|
|
390
|
+
)
|
|
391
|
+
})}
|
|
392
|
+
</box>
|
|
393
|
+
</Panel>
|
|
394
|
+
)
|
|
395
|
+
}
|
|
396
|
+
return (
|
|
397
|
+
<Panel title=" Choose a TTL " active>
|
|
398
|
+
<box style={{ flexDirection: "column", width: 44 }}>
|
|
399
|
+
<box style={{ flexDirection: "row" }}>
|
|
400
|
+
<text content={`${truncate(record.name || "@", 28)} `} fg={theme.accent} wrapMode="none" />
|
|
401
|
+
<text content={record.type} fg={theme.textDim} wrapMode="none" />
|
|
402
|
+
<box style={{ flexGrow: 1 }} />
|
|
403
|
+
<text content={`now ${curLabel}`} fg={theme.textFaint} wrapMode="none" />
|
|
404
|
+
</box>
|
|
405
|
+
<box style={{ height: 1 }} />
|
|
406
|
+
{ttlOptions.map((o, i) => {
|
|
407
|
+
const sel = i === pickIndex
|
|
408
|
+
const isCustom = o.ttl === CUSTOM_TTL
|
|
409
|
+
const isCurrent = o.ttl === record.ttl
|
|
410
|
+
const text = isCustom ? "Custom…" : `${formatTtl(o.ttl)} (${o.ttl}s)`
|
|
411
|
+
const tag = isCurrent ? " (current)" : ""
|
|
412
|
+
return (
|
|
413
|
+
<box key={o.label + o.ttl} style={{ flexDirection: "row", height: 1, backgroundColor: sel ? theme.selectedBg : undefined }}>
|
|
414
|
+
<text content={(sel ? "❯ " : " ") + text} fg={sel ? theme.text : isCurrent ? theme.textFaint : theme.textDim} style={{ flexGrow: 1 }} wrapMode="none" />
|
|
415
|
+
<text content={tag} fg={sel ? theme.text : theme.textFaint} style={{ flexShrink: 0 }} />
|
|
416
|
+
</box>
|
|
417
|
+
)
|
|
418
|
+
})}
|
|
419
|
+
</box>
|
|
420
|
+
</Panel>
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (phase === "custom") {
|
|
425
|
+
const inputStyle = { backgroundColor: theme.bgAlt, focusedBackgroundColor: theme.bgAlt, textColor: theme.text }
|
|
426
|
+
const label = mode === "value" ? (record.type === "AAAA" ? "IPv6 address" : "IP address") : "TTL in seconds"
|
|
427
|
+
const placeholder = mode === "value" ? "e.g. 203.0.113.10" : "e.g. 3600"
|
|
428
|
+
return (
|
|
429
|
+
<Panel title={mode === "value" ? " Custom IP " : " Custom TTL "} active>
|
|
430
|
+
<box style={{ flexDirection: "column", width: 48, paddingTop: 1, paddingBottom: 1 }}>
|
|
431
|
+
<text content={label} fg={theme.accent} />
|
|
432
|
+
<input focused value={custom} placeholder={placeholder} onInput={setCustom} onSubmit={submitCustom} style={inputStyle} />
|
|
433
|
+
<box style={{ height: 1 }} />
|
|
434
|
+
{customError ? <text content={customError} fg={theme.bad} wrapMode="none" /> : <text content="⏎ to continue · Esc to go back" fg={theme.textFaint} wrapMode="none" />}
|
|
435
|
+
</box>
|
|
436
|
+
</Panel>
|
|
437
|
+
)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (phase === "confirm") {
|
|
441
|
+
if (mode === "value") {
|
|
442
|
+
const targetServer = targetValue ? serverNameFor(targetValue) : undefined
|
|
443
|
+
const proxied = record.reason === "proxied"
|
|
444
|
+
return (
|
|
445
|
+
<Panel title=" Confirm repoint " active>
|
|
446
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
447
|
+
<box style={{ flexDirection: "row" }}>
|
|
448
|
+
<text content={`${truncate(record.name || "@", 28)} `} fg={theme.accent} wrapMode="none" />
|
|
449
|
+
<text content={record.type} fg={theme.textDim} wrapMode="none" />
|
|
450
|
+
</box>
|
|
451
|
+
<box style={{ flexDirection: "row" }}>
|
|
452
|
+
<text content={truncate(currentValue || "—", 22)} fg={theme.textDim} wrapMode="none" />
|
|
453
|
+
<text content=" → " fg={theme.textFaint} />
|
|
454
|
+
<text content={`${targetValue}${targetServer ? ` (${truncate(targetServer, 24)})` : ""}`} fg={theme.good} wrapMode="none" />
|
|
455
|
+
</box>
|
|
456
|
+
<box style={{ height: 1 }} />
|
|
457
|
+
{record.values.length > 1 ? (
|
|
458
|
+
<text content={`⚠ Replaces all ${record.values.length} current values with this one.`} fg={theme.warn} wrapMode="none" />
|
|
459
|
+
) : null}
|
|
460
|
+
{proxied ? <text content="Proxied by Cloudflare — this swaps the ORIGIN behind the proxy; visitors keep resolving Cloudflare's IPs." fg={theme.textDim} wrapMode="none" /> : null}
|
|
461
|
+
<text content={`This writes directly to ${providerName} — it moves LIVE traffic.`} fg={theme.textDim} wrapMode="none" />
|
|
462
|
+
<box style={{ height: 1 }} />
|
|
463
|
+
<text content="Press y to confirm · ← to go back · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
464
|
+
</box>
|
|
465
|
+
</Panel>
|
|
466
|
+
)
|
|
467
|
+
}
|
|
468
|
+
return (
|
|
469
|
+
<Panel title=" Confirm TTL change " active>
|
|
470
|
+
<box style={{ flexDirection: "column", width: 56, paddingTop: 1, paddingBottom: 1 }}>
|
|
471
|
+
<box style={{ flexDirection: "row" }}>
|
|
472
|
+
<text content={`${truncate(record.name || "@", 28)} `} fg={theme.accent} wrapMode="none" />
|
|
473
|
+
<text content={record.type} fg={theme.textDim} wrapMode="none" />
|
|
474
|
+
</box>
|
|
475
|
+
<box style={{ flexDirection: "row" }}>
|
|
476
|
+
<text content={`TTL ${curLabel}`} fg={theme.textDim} />
|
|
477
|
+
<text content=" → " fg={theme.textFaint} />
|
|
478
|
+
<text content={`${formatTtl(targetTtl)} (${targetTtl}s)`} fg={theme.good} wrapMode="none" />
|
|
479
|
+
</box>
|
|
480
|
+
<box style={{ height: 1 }} />
|
|
481
|
+
<text content={`This writes directly to ${providerName} — it's a live DNS change.`} fg={theme.textDim} wrapMode="none" />
|
|
482
|
+
<box style={{ height: 1 }} />
|
|
483
|
+
<text content="Press y to confirm · ← to go back · Esc to cancel" fg={theme.textFaint} wrapMode="none" />
|
|
484
|
+
</box>
|
|
485
|
+
</Panel>
|
|
486
|
+
)
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (dp === "tracking") {
|
|
490
|
+
const doing = mode === "value" ? `Pointing at ${targetValue}` : `Setting TTL to ${formatTtl(targetTtl)}`
|
|
491
|
+
return (
|
|
492
|
+
<box style={{ flexDirection: "column", alignItems: "center" }}>
|
|
493
|
+
<box style={{ flexDirection: "row" }}>
|
|
494
|
+
<Spinner />
|
|
495
|
+
<text content={` ${doing} — ${progress?.status ?? "queued"}…`} fg={theme.textDim} />
|
|
496
|
+
</box>
|
|
497
|
+
<box style={{ height: 1 }} />
|
|
498
|
+
<text content="You can press Esc — it keeps applying in the background." fg={theme.textFaint} wrapMode="none" />
|
|
499
|
+
</box>
|
|
500
|
+
)
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (dp === "done") {
|
|
504
|
+
const result = mode === "value" ? `now points at ${targetValue}` : `TTL is now ${formatTtl(targetTtl)}`
|
|
505
|
+
return (
|
|
506
|
+
<Panel title=" Done " active>
|
|
507
|
+
<box style={{ flexDirection: "column", width: 56, paddingTop: 1, paddingBottom: 1 }}>
|
|
508
|
+
<box style={{ flexDirection: "row" }}>
|
|
509
|
+
<text content="✓ " fg={theme.good} />
|
|
510
|
+
<text content={`${truncate(record.name || "@", 24)} ${record.type}`} fg={theme.accent} wrapMode="none" />
|
|
511
|
+
<text content={` ${result}`} fg={theme.text} wrapMode="none" />
|
|
512
|
+
</box>
|
|
513
|
+
<box style={{ height: 1 }} />
|
|
514
|
+
<text content="Esc to close · r in the inventory to refresh" fg={theme.textFaint} wrapMode="none" />
|
|
515
|
+
</box>
|
|
516
|
+
</Panel>
|
|
517
|
+
)
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// error
|
|
521
|
+
return (
|
|
522
|
+
<Panel title=" Change failed " active>
|
|
523
|
+
<box style={{ flexDirection: "column", width: 64, paddingTop: 1, paddingBottom: 1 }}>
|
|
524
|
+
<text content={`✕ ${progress?.error ?? "Something went wrong."}`} fg={theme.bad} wrapMode="none" />
|
|
525
|
+
<box style={{ height: 1 }} />
|
|
526
|
+
<text content={`Press r to choose another ${mode === "value" ? "target" : "TTL"} · Esc to close`} fg={theme.textFaint} wrapMode="none" />
|
|
527
|
+
</box>
|
|
528
|
+
</Panel>
|
|
529
|
+
)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function hints() {
|
|
533
|
+
if (loadState !== "ready") return [{ key: "esc", label: "close" }]
|
|
534
|
+
if (blocked) return [{ key: "esc", label: "close" }]
|
|
535
|
+
switch (dp) {
|
|
536
|
+
case "pick":
|
|
537
|
+
return [
|
|
538
|
+
{ key: "↑↓/jk", label: mode === "value" ? "target" : "TTL" },
|
|
539
|
+
{ key: "⏎", label: "choose" },
|
|
540
|
+
{ key: "esc", label: "close" },
|
|
541
|
+
]
|
|
542
|
+
case "custom":
|
|
543
|
+
return [
|
|
544
|
+
{ key: "⏎", label: "continue" },
|
|
545
|
+
{ key: "esc", label: "back" },
|
|
546
|
+
]
|
|
547
|
+
case "confirm":
|
|
548
|
+
return [
|
|
549
|
+
{ key: "y", label: "confirm" },
|
|
550
|
+
{ key: "←", label: "back" },
|
|
551
|
+
{ key: "esc", label: "cancel" },
|
|
552
|
+
]
|
|
553
|
+
case "error":
|
|
554
|
+
return [
|
|
555
|
+
{ key: "r", label: "retry" },
|
|
556
|
+
{ key: "esc", label: "close" },
|
|
557
|
+
]
|
|
558
|
+
default:
|
|
559
|
+
return [{ key: "esc", label: "close" }]
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Events feed: recent provisioning/operation activity across the account.
|
|
2
|
+
// Left pane lists events; right pane shows the selected event's detail + output.
|
|
3
|
+
|
|
4
|
+
import { useEffect, useMemo, useState } from "react"
|
|
5
|
+
import { useKeyboard } from "@opentui/react"
|
|
6
|
+
import { theme, statusColor, statusDot } from "../../lib/theme.ts"
|
|
7
|
+
import { truncate, timeAgo, formatDate } from "../../lib/format.ts"
|
|
8
|
+
import { Panel, Field } from "../components.tsx"
|
|
9
|
+
import { List, moveSelection } from "../List.tsx"
|
|
10
|
+
import { StatusBar } from "../StatusBar.tsx"
|
|
11
|
+
import { useStore } from "../store.tsx"
|
|
12
|
+
|
|
13
|
+
export function Events({ rows }: { rows: number }) {
|
|
14
|
+
const store = useStore()
|
|
15
|
+
const { events, serverById, route, inputMode, overlayOpen } = store
|
|
16
|
+
const [selected, setSelected] = useState(0)
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
setSelected((i) => Math.min(i, Math.max(0, events.length - 1)))
|
|
20
|
+
}, [events.length])
|
|
21
|
+
|
|
22
|
+
const isActive = route === "events" && !inputMode && !overlayOpen
|
|
23
|
+
|
|
24
|
+
useKeyboard((key) => {
|
|
25
|
+
if (!isActive) return
|
|
26
|
+
switch (key.name) {
|
|
27
|
+
case "up":
|
|
28
|
+
case "k":
|
|
29
|
+
return setSelected((i) => moveSelection(i, -1, events.length))
|
|
30
|
+
case "down":
|
|
31
|
+
case "j":
|
|
32
|
+
return setSelected((i) => moveSelection(i, 1, events.length))
|
|
33
|
+
case "g":
|
|
34
|
+
return setSelected(0)
|
|
35
|
+
case "G":
|
|
36
|
+
return setSelected(events.length - 1)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const current = events[Math.min(selected, Math.max(0, events.length - 1))]
|
|
41
|
+
const outputLines = useMemo(() => (current?.output ? current.output.split("\n") : []), [current])
|
|
42
|
+
const listRows = Math.max(3, rows - 4)
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
46
|
+
<box style={{ flexGrow: 1, flexDirection: "row", padding: 1, gap: 1 }}>
|
|
47
|
+
<Panel title={` Events (${events.length}) `} active flexGrow={1}>
|
|
48
|
+
<List
|
|
49
|
+
items={events}
|
|
50
|
+
selectedIndex={selected}
|
|
51
|
+
viewportRows={listRows}
|
|
52
|
+
focused
|
|
53
|
+
emptyText="No recent events"
|
|
54
|
+
keyFor={(e) => e.id}
|
|
55
|
+
renderRow={(e, sel) => (
|
|
56
|
+
<>
|
|
57
|
+
<text content={statusDot(e.status) + " "} fg={statusColor(e.status)} style={{ flexShrink: 0 }} />
|
|
58
|
+
<text content={truncate(e.name, 56)} fg={sel ? theme.text : theme.textDim} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
|
|
59
|
+
<text content={" " + timeAgo(e.created_at)} fg={theme.textFaint} style={{ flexShrink: 0 }} />
|
|
60
|
+
</>
|
|
61
|
+
)}
|
|
62
|
+
/>
|
|
63
|
+
</Panel>
|
|
64
|
+
|
|
65
|
+
<Panel title=" Event detail " width={46}>
|
|
66
|
+
{current ? (
|
|
67
|
+
<box style={{ flexDirection: "column" }}>
|
|
68
|
+
<text content={truncate(current.name, 46)} fg={theme.text} attributes={1} />
|
|
69
|
+
<box style={{ height: 1 }} />
|
|
70
|
+
<Field label="Status" value={current.status} valueColor={statusColor(current.status)} labelWidth={12} />
|
|
71
|
+
<Field label="Server" value={serverById(current.server_id)?.name ?? "—"} labelWidth={12} />
|
|
72
|
+
<Field label="By" value={current.initiated_by ?? "—"} labelWidth={12} />
|
|
73
|
+
<Field label="Created" value={formatDate(current.created_at)} labelWidth={12} />
|
|
74
|
+
<Field label="Finished" value={current.finished_at ? formatDate(current.finished_at) : "—"} labelWidth={12} />
|
|
75
|
+
<box style={{ height: 1 }} />
|
|
76
|
+
<text content="Output" fg={theme.accent} />
|
|
77
|
+
{outputLines.length === 0 ? (
|
|
78
|
+
<text content="(no output)" fg={theme.textFaint} />
|
|
79
|
+
) : (
|
|
80
|
+
outputLines.slice(0, Math.max(1, rows - 14)).map((line, i) => (
|
|
81
|
+
<text key={i} content={truncate(line, 46)} fg={theme.textDim} />
|
|
82
|
+
))
|
|
83
|
+
)}
|
|
84
|
+
</box>
|
|
85
|
+
) : (
|
|
86
|
+
<text content="No event selected" fg={theme.textFaint} />
|
|
87
|
+
)}
|
|
88
|
+
</Panel>
|
|
89
|
+
</box>
|
|
90
|
+
<StatusBar
|
|
91
|
+
hints={[
|
|
92
|
+
{ key: "↑↓/jk", label: "select" },
|
|
93
|
+
{ key: "g/G", label: "top/bottom" },
|
|
94
|
+
]}
|
|
95
|
+
/>
|
|
96
|
+
</box>
|
|
97
|
+
)
|
|
98
|
+
}
|