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,580 @@
1
+ // DNS inventory for a server — a MIGRATION lens, not a zone editor. For every site
2
+ // on the server it shows the website-hosting records that "count" when you move
3
+ // the site to another server: each zone, and nested under it the site's own
4
+ // hostnames (apex, www, additional domains) with their live record + TTL, flagged
5
+ // when they point at THIS server. The full per-zone record list lives in the `⏎`
6
+ // drill-down (DnsRecords) — that's where editing happens. Opened with `N`.
7
+ //
8
+ // TTLs here are read CRED-FREE from each zone's authoritative nameserver (the
9
+ // configured, non-decremented value), so they show for every host — not just the
10
+ // ones we hold an API key for. Editing a TTL still needs a connected account.
11
+
12
+ import { useEffect, useMemo, useState } from "react"
13
+ import { useKeyboard, useTerminalDimensions } from "@opentui/react"
14
+ import { theme, statusColor, statusDot } from "../../lib/theme.ts"
15
+ import { truncate, timeAgo } from "../../lib/format.ts"
16
+ import { normalizeDomain, candidateHostnames } from "../../lib/dns.ts"
17
+ import { apiProviderFor, consoleForHost, DEFAULT_WEB_ACCESS_NOTE, type AccessState } from "../../lib/providers.ts"
18
+ import { openUrl, copyToClipboard } from "../../lib/open.ts"
19
+ import { List, moveSelection } from "../List.tsx"
20
+ import { StatusBar } from "../StatusBar.tsx"
21
+ import { Spinner } from "../components.tsx"
22
+ import { useStore, isRecordWriteInFlight, type RecordEditKind } from "../store.tsx"
23
+ import type { Site } from "../../api/types.ts"
24
+
25
+ // Fixed column widths (chars) so columns align on every row; VALUE flexes.
26
+ // Fixed column widths (chars). VALUE flexes; HOST/ACCOUNT sit on the right.
27
+ const NAME_W = 32
28
+ const TYPE_W = 6
29
+ const TTL_W = 8
30
+ const HOST_W = 14
31
+ const ACCOUNT_W = 16
32
+
33
+ // Lowercase a hostname and drop a trailing dot (for comparisons).
34
+ const norm = (d: string) => d.trim().toLowerCase().replace(/\.$/, "")
35
+
36
+ // Glyph + color for an access state. `·` is the resting/unknown state.
37
+ function accessGlyph(state: AccessState, selected: boolean): { glyph: string; color: string } {
38
+ switch (state) {
39
+ case "editable":
40
+ return { glyph: "✓", color: selected ? theme.text : theme.good }
41
+ case "web":
42
+ return { glyph: "↗", color: selected ? theme.text : theme.accent }
43
+ case "needs-key":
44
+ return { glyph: "○", color: selected ? theme.text : theme.warn }
45
+ default:
46
+ return { glyph: "·", color: selected ? theme.text : theme.textFaint }
47
+ }
48
+ }
49
+
50
+ // A folded zone line (zone identity + its apex hosting record, when the site is
51
+ // served at the apex), or a hosting-record row nested under it for the non-apex
52
+ // records that still need action (own-A www, subdomains). Both carry the zone
53
+ // identity (apex/hostKey/access/liveNs) so access/edit actions work from either.
54
+ type InvRow =
55
+ | {
56
+ kind: "zone"
57
+ status: string
58
+ apex: string
59
+ hostKey: string
60
+ host: string
61
+ hostColor: string
62
+ access: AccessState
63
+ account: string
64
+ liveNs: string[]
65
+ note: string
66
+ noteColor: string
67
+ checkedAt: number | null
68
+ // Site grouping: the primary zone is the site's `●` line; additional-domain
69
+ // zones nest under it as `↳` lines. `extraZones` (on the primary) drives the
70
+ // "(+N)" portfolio hint.
71
+ isPrimary: boolean
72
+ extraZones: number
73
+ // The site's own hosting record, folded into this line. `recordName` is the
74
+ // hostname it belongs to (the site's domain — apex for a root site, the
75
+ // subdomain for a subdomain-hosted site), used as the label + the edit target.
76
+ recordName: string
77
+ hasRecord: boolean
78
+ recordType: string // A | AAAA | CNAME | ""
79
+ ttl: number | null
80
+ value: string
81
+ pointsHere: boolean
82
+ resolving: boolean // apex record still being looked up
83
+ wwwFollows: boolean // a www CNAME that just follows the apex → shown as "+www"
84
+ }
85
+ | {
86
+ kind: "record"
87
+ apex: string
88
+ hostKey: string
89
+ access: AccessState
90
+ liveNs: string[]
91
+ name: string // the hostname
92
+ recordType: string // A | CNAME | AAAA | "" while resolving
93
+ ttl: number | null
94
+ value: string
95
+ pointsHere: boolean
96
+ followsApex: boolean // a www/alias CNAME pointing at the apex — follows it on a move, no edit needed
97
+ resolving: boolean
98
+ indent: number // 1 = record under the primary zone; 2 = under an additional-domain zone
99
+ }
100
+
101
+ export function DnsInventory() {
102
+ const {
103
+ dnsInventoryServer,
104
+ dnsInventoryFocusSiteId,
105
+ setDnsInventoryServer,
106
+ setDnsInventoryFocusSiteId,
107
+ sitesForServer,
108
+ zoneForDomain,
109
+ lookupServerDns,
110
+ dnsZones,
111
+ dnsResolving,
112
+ accessForZone,
113
+ accountForZone,
114
+ connForZone,
115
+ setConnectZoneTarget,
116
+ connectZoneTarget,
117
+ dnsRecordsTarget,
118
+ setDnsRecordsTarget,
119
+ connectionCount,
120
+ providerZones,
121
+ connections,
122
+ verifyConnectionById,
123
+ resolveServerHosting,
124
+ hostingFor,
125
+ isHostingResolving,
126
+ recordWriteForHost,
127
+ zoneAccessNotes,
128
+ } = useStore()
129
+ const allConnections = useMemo(() => Object.values(connections).flat(), [connections])
130
+ const showAccount = connectionCount >= 2
131
+ const { height } = useTerminalDimensions()
132
+ const [index, setIndex] = useState(0)
133
+ const [flash, setFlash] = useState<string | null>(null)
134
+ const server = dnsInventoryServer
135
+
136
+ // Kick off zone-host resolution when the overlay opens.
137
+ useEffect(() => {
138
+ if (server) lookupServerDns(server.id)
139
+ }, [server, lookupServerDns])
140
+
141
+ // Resolve hosting records once their zone NS are known — re-runs as zones land
142
+ // (already-resolved/in-flight hostnames are skipped, so this is cheap).
143
+ useEffect(() => {
144
+ if (server) resolveServerHosting(server)
145
+ }, [server, dnsZones, resolveServerHosting])
146
+
147
+ // Verify configured-but-unverified connections so `✓` access appears without a
148
+ // manual re-verify.
149
+ useEffect(() => {
150
+ for (const c of allConnections) if (!providerZones.has(c.id)) verifyConnectionById(c.id)
151
+ // eslint-disable-next-line react-hooks/exhaustive-deps
152
+ }, [allConnections, verifyConnectionById])
153
+
154
+ const allSites = useMemo<Site[]>(
155
+ () => (server ? [...sitesForServer(server.id)].sort((a, b) => a.domain.localeCompare(b.domain)) : []),
156
+ [server, sitesForServer],
157
+ )
158
+ // When opened via `n`, scope the rows to a single site; `N` shows them all.
159
+ const focusSite = dnsInventoryFocusSiteId != null ? allSites.find((s) => s.id === dnsInventoryFocusSiteId) ?? null : null
160
+ const sites = useMemo<Site[]>(() => (focusSite ? [focusSite] : allSites), [focusSite, allSites])
161
+
162
+ const { rows, summary, pending } = useMemo(() => {
163
+ const out: InvRow[] = []
164
+ let zoneCount = 0
165
+ let hereCount = 0
166
+ let resolving = false
167
+
168
+ // Does this hostname point at THE SERVER BEING VIEWED? Computed here, at
169
+ // render, against the current server's IP — the cached HostRecord is shared
170
+ // across servers, so a stored flag would be stale from another server's view.
171
+ // A settled repoint (recordWrites) wins over the cached resolution until the
172
+ // next refresh, so a just-flipped row reads correctly right away.
173
+ const serverIp = server?.ip_address ?? ""
174
+ const hereFor = (name: string, rec: { type: string; resolvedIps: string[] } | undefined): boolean => {
175
+ if (!serverIp || !rec) return false
176
+ const vw = recordWriteForHost(name, rec.type, "value")
177
+ const ips = vw?.status === "done" && vw.value ? [vw.value] : rec.resolvedIps
178
+ return ips.includes(serverIp)
179
+ }
180
+
181
+ for (const site of sites) {
182
+ const entries = [
183
+ { domain: site.domain, redirect: undefined as { enabled: boolean; destination: string } | undefined },
184
+ ...(site.additional_domains?.map((a) => ({ domain: a.domain, redirect: a.redirect })) ?? []),
185
+ ]
186
+ const primaryApex = zoneForDomain(site.domain)?.zone?.apex ?? normalizeDomain(site.domain)
187
+
188
+ const groups = new Map<string, { apex: string; entries: typeof entries }>()
189
+ for (const e of entries) {
190
+ const apex = zoneForDomain(e.domain)?.zone?.apex ?? normalizeDomain(e.domain)
191
+ const g = groups.get(apex) ?? { apex, entries: [] }
192
+ g.entries.push(e)
193
+ groups.set(apex, g)
194
+ }
195
+ const ordered = [...groups.values()].sort((a, b) =>
196
+ a.apex === primaryApex ? -1 : b.apex === primaryApex ? 1 : a.apex.localeCompare(b.apex),
197
+ )
198
+ const additionalZones = ordered.length - 1
199
+
200
+ ordered.forEach((g) => {
201
+ zoneCount++
202
+ const cached = zoneForDomain(g.entries[0].domain)
203
+ const zoneResolving = g.entries.some((e) => dnsResolving.has(normalizeDomain(e.domain)))
204
+ const hostKey = cached?.zone?.providerKey ?? ""
205
+ const liveNs = cached?.zone?.nameservers ?? []
206
+ let host: string
207
+ let hostColor: string
208
+ if (cached === undefined) {
209
+ host = zoneResolving ? "looking up…" : "—"
210
+ hostColor = zoneResolving ? theme.textDim : theme.textFaint
211
+ resolving = resolving || zoneResolving
212
+ } else if (cached.zone === null) {
213
+ host = "no host found"
214
+ hostColor = theme.warn
215
+ } else {
216
+ host = cached.zone.providerLabel
217
+ hostColor = theme.accent
218
+ }
219
+ const isPrimaryZone = g.apex === primaryApex
220
+ const redirect = g.entries.find((e) => e.redirect?.enabled)?.redirect
221
+ const redirectNote = !isPrimaryZone && redirect ? `→ redirects to ${redirect.destination}` : ""
222
+ const access = accessForZone(g.apex, hostKey, liveNs)
223
+ // Fall back to the zone's access note (a user override, e.g. "Integracon",
224
+ // or the assumed default for any web-only host, "Delegate Access") when
225
+ // there's no redirect to show — an override is amber so it stands out.
226
+ const accessOverride = zoneAccessNotes.get(g.apex)
227
+ const note = redirectNote || (access === "web" ? accessOverride || DEFAULT_WEB_ACCESS_NOTE : "")
228
+ const noteColor = redirectNote ? theme.textDim : accessOverride ? theme.warn : theme.textDim
229
+
230
+ // The site's hostnames in this zone (apex first).
231
+ const hosts = candidateHostnames(g.entries.map((e) => e.domain)).sort((a, b) =>
232
+ a === g.apex ? -1 : b === g.apex ? 1 : a.localeCompare(b),
233
+ )
234
+ // The line's head = the SITE's own domain in this zone: the apex when the site
235
+ // is served at the root, otherwise the subdomain the site actually lives at
236
+ // (so three sites on one apex read as three distinct lines, not one).
237
+ const headHost = hosts.includes(g.apex) ? g.apex : norm(g.entries[0].domain)
238
+ const headRec = hostingFor(headHost)
239
+ const headResolving = isHostingResolving(headHost) || (!headRec && cached?.zone != null)
240
+ const hasRecord = !!(headRec && headRec.type !== "none")
241
+ // A www that merely CNAMEs to the head (or apex) follows it on a move → "+www".
242
+ const wwwName = "www." + headHost
243
+ const wwwRec = headHost.startsWith("www.") ? undefined : hostingFor(wwwName)
244
+ const wwwTarget = wwwRec?.type === "CNAME" ? norm(wwwRec.value) : ""
245
+ const wwwFollows = wwwTarget !== "" && (wwwTarget === headHost || wwwTarget === g.apex)
246
+ const headHere = hasRecord && hereFor(headHost, headRec)
247
+ if (headHere) hereCount++
248
+ if (headResolving) resolving = true
249
+
250
+ out.push({
251
+ kind: "zone",
252
+ status: site.status,
253
+ apex: g.apex,
254
+ hostKey,
255
+ host,
256
+ hostColor,
257
+ access,
258
+ account: accountForZone(g.apex, hostKey, liveNs),
259
+ liveNs,
260
+ note,
261
+ noteColor,
262
+ checkedAt: cached?.checkedAt ?? null,
263
+ isPrimary: isPrimaryZone,
264
+ extraZones: isPrimaryZone ? additionalZones : 0,
265
+ recordName: headHost,
266
+ hasRecord,
267
+ recordType: headRec?.type ?? "",
268
+ ttl: headRec?.ttl ?? null,
269
+ value: headRec?.value ?? "",
270
+ pointsHere: headHere,
271
+ resolving: headResolving,
272
+ wwwFollows,
273
+ })
274
+
275
+ // Nested rows: only the non-head records that still need action.
276
+ for (const name of hosts) {
277
+ if (name === headHost) continue // folded into the site/zone line
278
+ if (name === wwwName && wwwFollows) continue // shown as "+www"
279
+ const hr = hostingFor(name)
280
+ if (hr && hr.type === "none") continue // no record for this hostname — nothing to migrate
281
+ const isResolving = isHostingResolving(name) || (!hr && cached?.zone != null)
282
+ const hrHere = hereFor(name, hr)
283
+ if (hrHere) hereCount++
284
+ if (!hr && isResolving) resolving = true
285
+ const cnameTarget = hr?.type === "CNAME" ? hr.value.toLowerCase().replace(/\.$/, "") : ""
286
+ const followsApex = cnameTarget !== "" && cnameTarget === g.apex
287
+ out.push({
288
+ kind: "record",
289
+ apex: g.apex,
290
+ hostKey,
291
+ access,
292
+ liveNs,
293
+ name,
294
+ recordType: hr?.type ?? "",
295
+ ttl: hr?.ttl ?? null,
296
+ value: hr?.value ?? "",
297
+ pointsHere: hrHere,
298
+ followsApex,
299
+ resolving: !hr,
300
+ indent: isPrimaryZone ? 1 : 2,
301
+ })
302
+ }
303
+ })
304
+ }
305
+
306
+ const summary = `${zoneCount} zone${zoneCount === 1 ? "" : "s"}${hereCount ? ` · ${hereCount} point${hereCount === 1 ? "s" : ""} here` : ""}`
307
+ return { rows: out, summary, pending: resolving }
308
+ }, [sites, server, dnsZones, dnsResolving, zoneForDomain, accessForZone, accountForZone, hostingFor, isHostingResolving, recordWriteForHost])
309
+
310
+ const safeIndex = Math.min(index, Math.max(0, rows.length - 1))
311
+ const close = () => setDnsInventoryServer(null)
312
+
313
+ function openDrill(r: InvRow, edit: RecordEditKind) {
314
+ if (r.access === "web") return openWeb(r)
315
+ if (r.access !== "editable") return showFlash("Connect API access first — press c")
316
+ const conn = connForZone(r.apex, r.hostKey, r.liveNs)
317
+ if (!conn) return showFlash("No reachable account for this zone — press c")
318
+ // Editing always targets ONE hosting record — never a zone. A record row edits
319
+ // itself; a zone-header row edits its apex A/AAAA (the record right below it).
320
+ let rec: { name: string; type: string } | null = null
321
+ if (r.kind === "record") {
322
+ if (r.followsApex) return showFlash(`${r.name} follows the apex — no separate change needed.`)
323
+ if (r.resolving) return showFlash("Still looking up this record…")
324
+ if (!r.recordType || r.recordType === "none") return showFlash("No record to edit here.")
325
+ rec = { name: r.name, type: r.recordType }
326
+ } else {
327
+ if (r.resolving) return showFlash("Still looking up this record…")
328
+ if (!r.hasRecord || !r.recordType) return showFlash("No record to edit on this line.")
329
+ rec = { name: r.recordName, type: r.recordType }
330
+ }
331
+ // Repointing is an A/AAAA concern — a CNAME already points at a NAME and moves
332
+ // with it, so the thing to repoint is the record it targets.
333
+ if (edit === "value" && rec.type !== "A" && rec.type !== "AAAA") {
334
+ return showFlash(`A ${rec.type} record follows its target — repoint the A record it points to.`)
335
+ }
336
+ setDnsRecordsTarget({ apex: r.apex, hostKey: r.hostKey, connId: conn.id, record: rec, edit })
337
+ }
338
+
339
+ function openWeb(r: InvRow) {
340
+ const con = consoleForHost(r.hostKey)
341
+ if (con) {
342
+ copyToClipboard(r.apex)
343
+ openUrl(con.url)
344
+ showFlash(`${con.label} opened · ${r.apex} copied`)
345
+ } else {
346
+ showFlash("No web console for this host — c to manage access")
347
+ }
348
+ }
349
+
350
+ function showFlash(msg: string) {
351
+ if (!msg) return
352
+ setFlash(msg)
353
+ setTimeout(() => setFlash(null), 2200)
354
+ }
355
+
356
+ useKeyboard((key) => {
357
+ if (connectZoneTarget || dnsRecordsTarget) return // an overlay on top owns the keyboard
358
+ const raw = key.name ?? ""
359
+ const name = key.shift && raw.length === 1 ? raw.toUpperCase() : raw
360
+ const r = rows[safeIndex]
361
+ switch (name) {
362
+ case "escape":
363
+ case "q":
364
+ return close()
365
+ case "up":
366
+ case "k":
367
+ return setIndex((i) => moveSelection(i, -1, rows.length))
368
+ case "down":
369
+ case "j":
370
+ return setIndex((i) => moveSelection(i, 1, rows.length))
371
+ case "r":
372
+ if (server) {
373
+ lookupServerDns(server.id, true)
374
+ resolveServerHosting(server, true)
375
+ }
376
+ return
377
+ case "return":
378
+ case "right":
379
+ case "l":
380
+ case "t":
381
+ if (r) openDrill(r, "ttl")
382
+ return
383
+ case "p":
384
+ // Point the record somewhere else (the migration repoint) — a picker of
385
+ // the account's servers, since that's where a record almost always goes.
386
+ if (r) openDrill(r, "value")
387
+ return
388
+ case "c":
389
+ if (!r) return
390
+ // Manage access/notes for any host with real API creds OR just a web
391
+ // handoff (GoDaddy, Namecheap, Network Solutions, ...); a host with
392
+ // neither (access "unknown") falls back to the plain web-open.
393
+ if (apiProviderFor(r.hostKey) || r.access === "web") setConnectZoneTarget({ apex: r.apex, hostKey: r.hostKey })
394
+ else openWeb(r)
395
+ return
396
+ case "w":
397
+ if (r) openWeb(r)
398
+ return
399
+ case "a":
400
+ // Expand a site-scoped view (opened via `n`) to the whole server.
401
+ if (focusSite) {
402
+ setDnsInventoryFocusSiteId(null)
403
+ setIndex(0)
404
+ }
405
+ return
406
+ }
407
+ })
408
+
409
+ if (!server) return null
410
+ const listRows = Math.max(3, height - 7)
411
+ const oldest = rows.reduce<number | null>(
412
+ (min, r) => (r.kind === "zone" && r.checkedAt && (min === null || r.checkedAt < min) ? r.checkedAt : min),
413
+ null,
414
+ )
415
+
416
+ return (
417
+ <box style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%", flexDirection: "column", backgroundColor: theme.bg, zIndex: 216 }}>
418
+ <box style={{ flexDirection: "row", height: 1, backgroundColor: theme.bgAlt, paddingLeft: 1, paddingRight: 1, alignItems: "center" }}>
419
+ <text content={`🌐 DNS · ${truncate(focusSite ? focusSite.domain : server.name, 28)} `} fg={theme.brand} wrapMode="none" style={{ flexShrink: 0 }} />
420
+ <text content={focusSite ? `${summary} · a all sites` : summary} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 1 }} />
421
+ <box style={{ flexGrow: 1 }} />
422
+ {pending ? <Spinner color={theme.brand} interval={120} /> : null}
423
+ </box>
424
+
425
+ {/* Column header. The dot column (2) + NAME align across both row kinds; HOST
426
+ sits on the right, pushed there by the growable VALUE region. */}
427
+ <box style={{ flexDirection: "row", paddingLeft: 1, paddingRight: 1, height: 1 }}>
428
+ <text content={" " + "SITE / RECORD".padEnd(NAME_W)} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
429
+ <text content={"TYPE".padEnd(TYPE_W)} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
430
+ <text content={"TTL".padEnd(TTL_W)} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
431
+ <text content="VALUE" fg={theme.textFaint} wrapMode="none" style={{ flexGrow: 1, flexShrink: 1 }} />
432
+ <text content="HOST" fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
433
+ {showAccount ? <text content={" " + "ACCOUNT".padEnd(ACCOUNT_W)} fg={theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
434
+ </box>
435
+
436
+ <box style={{ flexGrow: 1, flexDirection: "column", paddingLeft: 1, paddingRight: 1 }}>
437
+ {rows.length === 0 ? (
438
+ <text content="No sites on this server." fg={theme.textFaint} wrapMode="none" />
439
+ ) : (
440
+ <List
441
+ items={rows}
442
+ selectedIndex={safeIndex}
443
+ viewportRows={listRows}
444
+ focused
445
+ keyFor={(_r, i) => i}
446
+ renderRow={(r, selected) => (r.kind === "zone" ? renderZone(r, selected) : renderRecord(r, selected))}
447
+ />
448
+ )}
449
+ </box>
450
+
451
+ <StatusBar
452
+ hints={[
453
+ { key: "↑↓/jk", label: "select" },
454
+ { key: "⏎", label: "edit TTL" },
455
+ { key: "p", label: "point at…" },
456
+ { key: "c", label: "manage access" },
457
+ ...(focusSite ? [{ key: "a", label: "all sites" }] : []),
458
+ { key: "r", label: "refresh" },
459
+ { key: "esc", label: "close" },
460
+ ]}
461
+ message={flash ?? (oldest ? `checked ${timeAgo(new Date(oldest).toISOString())}` : undefined)}
462
+ messageColor={flash ? theme.brand : theme.textFaint}
463
+ showGlobal={false}
464
+ />
465
+ </box>
466
+ )
467
+
468
+ // The TTL cell, shared by both row kinds: a spinner while resolving, a spinner +
469
+ // "→<ttl>" while a write is in flight, else the value in seconds (300/3600/86400,
470
+ // not "1h"). A settled write shows its new value — the authoritative host has it.
471
+ function ttlCell(name: string, type: string, ttl: number | null, resolving: boolean, faint: boolean, selected: boolean) {
472
+ if (resolving) {
473
+ return (
474
+ <box style={{ flexDirection: "row", width: TTL_W, flexShrink: 0 }}>
475
+ <Spinner color={selected ? theme.text : theme.textDim} interval={120} />
476
+ <text content=" …" fg={selected ? theme.text : theme.textFaint} wrapMode="none" />
477
+ </box>
478
+ )
479
+ }
480
+ const wp = recordWriteForHost(name, type, "ttl")
481
+ if (isRecordWriteInFlight(wp)) {
482
+ return (
483
+ <box style={{ flexDirection: "row", width: TTL_W, flexShrink: 0 }}>
484
+ <Spinner color={selected ? theme.text : theme.brand} interval={120} />
485
+ <text content={` →${wp!.ttl}`} fg={selected ? theme.text : theme.warn} wrapMode="none" />
486
+ </box>
487
+ )
488
+ }
489
+ const effTtl = wp?.status === "done" ? (wp.ttl ?? ttl) : ttl
490
+ const txt = effTtl == null ? "—" : String(effTtl)
491
+ const fg = selected ? theme.text : faint ? theme.textFaint : theme.accent
492
+ return <text content={txt.padEnd(TTL_W)} fg={fg} wrapMode="none" style={{ flexShrink: 0 }} />
493
+ }
494
+
495
+ // The VALUE cell, write-aware like the TTL cell: a spinner + "→ip" while a
496
+ // repoint is in flight, the settled new value once done (the authoritative host
497
+ // has it), else the resolved value.
498
+ function valueCell(name: string, type: string, value: string, selected: boolean) {
499
+ const wp = recordWriteForHost(name, type, "value")
500
+ if (isRecordWriteInFlight(wp)) {
501
+ return (
502
+ <box style={{ flexDirection: "row", flexShrink: 1 }}>
503
+ <Spinner color={selected ? theme.text : theme.brand} interval={120} />
504
+ <text content={` →${truncate(wp!.value ?? "", 36)}`} fg={selected ? theme.text : theme.warn} wrapMode="none" />
505
+ </box>
506
+ )
507
+ }
508
+ const eff = wp?.status === "done" && wp.value ? wp.value : value
509
+ return <text content={truncate(eff, 38)} fg={selected ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 1 }} />
510
+ }
511
+
512
+ // host + access glyph — a right-side cluster on zone lines.
513
+ function hostCell(r: Extract<InvRow, { kind: "zone" }>, selected: boolean) {
514
+ const acc = accessGlyph(r.access, selected)
515
+ return (
516
+ <box style={{ flexDirection: "row", flexShrink: 0 }}>
517
+ <text content={truncate(r.host, HOST_W)} fg={selected ? theme.text : r.hostColor} wrapMode="none" />
518
+ <text content={" " + acc.glyph} fg={acc.color} wrapMode="none" />
519
+ </box>
520
+ )
521
+ }
522
+
523
+ // Indented "↳ name" cell for nested rows. Level 1 = directly under the site's
524
+ // primary line (additional-domain zone, or a record in the primary zone); level 2
525
+ // = a record under an additional-domain zone.
526
+ function indentedName(name: string, level: number): string {
527
+ const prefix = level >= 2 ? " ↳ " : " ↳ "
528
+ return (prefix + truncate(name, NAME_W - prefix.length - 1)).padEnd(NAME_W)
529
+ }
530
+
531
+ function renderZone(r: Extract<InvRow, { kind: "zone" }>, selected: boolean) {
532
+ // Primary zone = the site's `●` line; additional-domain zones nest as `↳`. The
533
+ // label is the site's own domain (recordName), not the zone apex.
534
+ const nameContent = r.isPrimary ? truncate(r.recordName, NAME_W - 1).padEnd(NAME_W) : indentedName(r.recordName, 1)
535
+ const nameColor = selected ? theme.text : r.isPrimary ? theme.text : theme.textDim
536
+ return (
537
+ <>
538
+ <text content={r.isPrimary ? statusDot(r.status) + " " : " "} fg={r.isPrimary ? statusColor(r.status) : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
539
+ <text content={nameContent} fg={nameColor} wrapMode="none" style={{ flexShrink: 0 }} />
540
+ {r.hasRecord || r.resolving ? (
541
+ <>
542
+ <text content={r.recordType.padEnd(TYPE_W)} fg={selected ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
543
+ {ttlCell(r.recordName, r.recordType, r.ttl, r.resolving, false, selected)}
544
+ </>
545
+ ) : (
546
+ <>
547
+ <text content={"".padEnd(TYPE_W)} wrapMode="none" style={{ flexShrink: 0 }} />
548
+ <text content={"".padEnd(TTL_W)} wrapMode="none" style={{ flexShrink: 0 }} />
549
+ </>
550
+ )}
551
+ {valueCell(r.recordName, r.recordType, r.value, selected)}
552
+ {r.pointsHere ? <text content=" ◀ here" fg={selected ? theme.text : theme.good} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
553
+ {r.wwwFollows ? <text content=" +www" fg={selected ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
554
+ {r.note ? <text content={" " + r.note} fg={selected ? theme.text : r.noteColor} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
555
+ <box style={{ flexGrow: 1 }} />
556
+ {hostCell(r, selected)}
557
+ {r.isPrimary && r.extraZones > 0 ? <text content={` (+${r.extraZones})`} fg={selected ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
558
+ {showAccount ? <text content={" " + truncate(r.account, ACCOUNT_W).padEnd(ACCOUNT_W)} fg={selected ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} /> : null}
559
+ </>
560
+ )
561
+ }
562
+
563
+ function renderRecord(r: Extract<InvRow, { kind: "record" }>, selected: boolean) {
564
+ return (
565
+ <>
566
+ <text content=" " wrapMode="none" style={{ flexShrink: 0 }} />
567
+ <text content={indentedName(r.name, r.indent)} fg={selected ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
568
+ <text content={(r.resolving ? "" : r.recordType).padEnd(TYPE_W)} fg={selected ? theme.text : theme.textDim} wrapMode="none" style={{ flexShrink: 0 }} />
569
+ {ttlCell(r.name, r.recordType, r.ttl, r.resolving, r.followsApex, selected)}
570
+ {valueCell(r.name, r.recordType, r.value, selected)}
571
+ {r.followsApex ? (
572
+ <text content=" follows apex" fg={selected ? theme.text : theme.textFaint} wrapMode="none" style={{ flexShrink: 0 }} />
573
+ ) : r.pointsHere ? (
574
+ <text content=" ◀ here" fg={selected ? theme.text : theme.good} wrapMode="none" style={{ flexShrink: 0 }} />
575
+ ) : null}
576
+ <box style={{ flexGrow: 1 }} />
577
+ </>
578
+ )
579
+ }
580
+ }