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,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
|
+
}
|