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.
Files changed (88) hide show
  1. package/CHANGELOG.md +943 -0
  2. package/LICENSE +21 -0
  3. package/README.md +856 -0
  4. package/bin/spinuptui.js +13 -0
  5. package/package.json +55 -0
  6. package/src/api/client.ts +354 -0
  7. package/src/api/types.ts +206 -0
  8. package/src/config.ts +309 -0
  9. package/src/dev/devMode.ts +7 -0
  10. package/src/dev/fixtures.ts +238 -0
  11. package/src/dev/mockClient.ts +214 -0
  12. package/src/index.tsx +68 -0
  13. package/src/lib/appUpdate.ts +206 -0
  14. package/src/lib/awsSigV4.ts +133 -0
  15. package/src/lib/cloneDomains.ts +112 -0
  16. package/src/lib/cloneLog.ts +85 -0
  17. package/src/lib/dbBackup.ts +198 -0
  18. package/src/lib/dbSync.ts +253 -0
  19. package/src/lib/discover.ts +165 -0
  20. package/src/lib/dns.ts +144 -0
  21. package/src/lib/dnsCache.ts +94 -0
  22. package/src/lib/dnsQuery.ts +180 -0
  23. package/src/lib/dnsRecords.ts +411 -0
  24. package/src/lib/format.ts +106 -0
  25. package/src/lib/gitDeployKey.ts +0 -0
  26. package/src/lib/gitStatus.ts +37 -0
  27. package/src/lib/jobs.ts +32 -0
  28. package/src/lib/keychain.ts +49 -0
  29. package/src/lib/local.ts +116 -0
  30. package/src/lib/mediaFallback.ts +288 -0
  31. package/src/lib/open.ts +103 -0
  32. package/src/lib/phpEol.ts +151 -0
  33. package/src/lib/probe.ts +183 -0
  34. package/src/lib/providers.ts +284 -0
  35. package/src/lib/providersCache.ts +105 -0
  36. package/src/lib/releaseNotes.ts +67 -0
  37. package/src/lib/serverClone.ts +782 -0
  38. package/src/lib/serverCreate.ts +161 -0
  39. package/src/lib/siteDoctor.ts +260 -0
  40. package/src/lib/siteFingerprint.ts +108 -0
  41. package/src/lib/spinupweb.ts +33 -0
  42. package/src/lib/ssh.ts +669 -0
  43. package/src/lib/stack.ts +78 -0
  44. package/src/lib/stackCache.ts +113 -0
  45. package/src/lib/theme.ts +54 -0
  46. package/src/lib/ubuntuEol.ts +124 -0
  47. package/src/lib/uptimeKuma.ts +551 -0
  48. package/src/lib/vanitySite.ts +297 -0
  49. package/src/lib/wpInventory.ts +153 -0
  50. package/src/ui/App.tsx +265 -0
  51. package/src/ui/Details.tsx +403 -0
  52. package/src/ui/Explain.tsx +158 -0
  53. package/src/ui/Header.tsx +117 -0
  54. package/src/ui/Help.tsx +296 -0
  55. package/src/ui/List.tsx +95 -0
  56. package/src/ui/Onboarding.tsx +93 -0
  57. package/src/ui/Splash.tsx +51 -0
  58. package/src/ui/StatusBar.tsx +60 -0
  59. package/src/ui/components.tsx +392 -0
  60. package/src/ui/store.tsx +4253 -0
  61. package/src/ui/views/Browser.tsx +365 -0
  62. package/src/ui/views/CloneWizard.tsx +932 -0
  63. package/src/ui/views/Dashboard.tsx +149 -0
  64. package/src/ui/views/DbBackup.tsx +228 -0
  65. package/src/ui/views/DbSync.tsx +307 -0
  66. package/src/ui/views/Discover.tsx +271 -0
  67. package/src/ui/views/DnsInventory.tsx +580 -0
  68. package/src/ui/views/DnsRecords.tsx +562 -0
  69. package/src/ui/views/Events.tsx +98 -0
  70. package/src/ui/views/Forgotten.tsx +165 -0
  71. package/src/ui/views/GrantKey.tsx +504 -0
  72. package/src/ui/views/Health.tsx +322 -0
  73. package/src/ui/views/HttpsToggle.tsx +195 -0
  74. package/src/ui/views/KumaSite.tsx +571 -0
  75. package/src/ui/views/LocalLink.tsx +220 -0
  76. package/src/ui/views/MediaFallback.tsx +180 -0
  77. package/src/ui/views/NewServer.tsx +750 -0
  78. package/src/ui/views/PhpUpgrade.tsx +301 -0
  79. package/src/ui/views/ProviderConnect.tsx +527 -0
  80. package/src/ui/views/PurgeCache.tsx +186 -0
  81. package/src/ui/views/ReleaseNotes.tsx +84 -0
  82. package/src/ui/views/Search.tsx +448 -0
  83. package/src/ui/views/ServerActions.tsx +328 -0
  84. package/src/ui/views/Stacks.tsx +371 -0
  85. package/src/ui/views/SudoConnect.tsx +358 -0
  86. package/src/ui/views/VanityNewSite.tsx +407 -0
  87. package/src/ui/views/WpInventory.tsx +218 -0
  88. 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
+ }