- Every section puts the hub's server selector in the chrome slot, the same place and gap as the Dashboard. - Data regions load with a Skeleton and fail inline with Retry; KPIs show the no-data dash instead of 0 when a server did not answer. Settings keeps its header and selector while loading or failing. - Cache, Apps and Logs headers group secondary or hub-refused actions in menus; app descriptions clamp to two lines; Unknown cells say why. - DNS Client: Protocol label, Resolve and Import together. DHCP and Query Logs empty states with actions. View Logs list capped with scroll. - Blocking: Lists KPI tiles like the Overview, app list table fits its panel with one-line URLs. Status re-reads on tab return at most once a minute. Co-Authored-By: Claude Opus 5.5 <[email protected]>
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
import { ServerSelect } from '../../ui/ServerSelect'
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import type { DnsClientRequest } from '../../app/handoff'
|
|
import { PROTOCOLS, TYPES, prepareServer, resolve } from '../../api/dnsclient'
|
|
import { type AlertType } from '../../ui/Alert'
|
|
import { Button } from '../../ui/Button'
|
|
import { PermissionButton } from '../../ui/PermissionButton'
|
|
import { useHubDefaultNode, useHubWrite } from '../../lib/hub-write'
|
|
import { HubWriteHint } from '../../ui/HubWriteHint'
|
|
import { Check } from '../../ui/Check'
|
|
import { Field, Input, LabeledInput, LabeledSelect } from '../../ui/Field'
|
|
import { Menu } from '../../ui/Menu'
|
|
import { loadQuickList, protocolForServer, serverListOptions, type QuickServer } from '../../lib/quick-lists'
|
|
import { SectionHeader } from '../../ui/SectionHeader'
|
|
import { Empty, FailureRetry } from '../../ui/Empty'
|
|
import { Raw } from '../../ui/Raw'
|
|
import { Details } from '../../ui/Details'
|
|
import styles from './DnsClient.module.css'
|
|
import { Body, Panel } from '../../ui/Panel'
|
|
import { noticeFromFailure } from '../../lib/notice'
|
|
import { Notifier } from '../../ui/Notifier'
|
|
|
|
/*
|
|
A replica of `resolveQuery()` (dnsclient.js:95-210). Both buttons call the same
|
|
endpoint: "Import" only adds `import=true`.
|
|
|
|
The alert texts are upstream literals.
|
|
|
|
`request` is `queryDnsServer` (dnsclient.js:231-257), the jump other screens' row
|
|
menus make: the form starts filled with it and the query runs on arrival.
|
|
*/
|
|
interface AlertState { type: AlertType; title: string; text: string }
|
|
|
|
export function DnsClient({
|
|
token,
|
|
nodes = [],
|
|
clusterInitialised = false,
|
|
canImport = true,
|
|
request,
|
|
}: {
|
|
token: string | null
|
|
/** The cluster nodes, for the node selector. */
|
|
nodes?: { name: string; type: string }[]
|
|
clusterInitialised?: boolean
|
|
/** `DnsClient.canModify`: Import writes the answer into a zone of the server. */
|
|
canImport?: boolean
|
|
/** A query another screen asked for: fill the form with it and run it. */
|
|
request?: DnsClientRequest
|
|
}) {
|
|
/* Upstream mounts one here (`optDnsClientClusterNode`), no aggregate, no
|
|
persistence. The jump picks a node only when it was given one. */
|
|
const defaultNode = useHubDefaultNode(nodes)
|
|
const [clusterNode, setClusterNode] = useState<string>(() => (request?.node != null && request.node !== 'cluster' ? request.node : defaultNode))
|
|
/* Behind a hub, Import is allowed only on a chosen server the hub can write to. */
|
|
const write = useHubWrite(clusterNode, canImport)
|
|
|
|
const [server, setServer] = useState('This Server {this-server}')
|
|
|
|
/*
|
|
The known name servers. Upstream attaches a dropdown to this field
|
|
(`index.html:840-846`, filled by `dnsclient.js:78`) and this console kept the
|
|
field and lost the list. The field stays free text: the list only fills it.
|
|
*/
|
|
const [servers, setServers] = useState<string[]>([])
|
|
const combo = useRef<HTMLDivElement>(null)
|
|
useEffect(() => {
|
|
let alive = true
|
|
void loadQuickList<QuickServer>('dnsclient-server-list').then(
|
|
(e) => alive && setServers(serverListOptions(e)),
|
|
)
|
|
return () => {
|
|
alive = false
|
|
}
|
|
}, [])
|
|
const [domain, setDomain] = useState(() => request?.domain ?? '')
|
|
const [type, setType] = useState(() => request?.type ?? 'A')
|
|
const [protocol, setProtocol] = useState('UDP')
|
|
const [ecs, setEcs] = useState('')
|
|
/* The one field the jump does not leave at its default: it UNCHECKS validation
|
|
(dnsclient.js:240). */
|
|
const [dnssec, setDnssec] = useState(() => request == null)
|
|
const [output, setOutput] = useState<string | null>(null)
|
|
/*
|
|
The raw responses of each hop of the resolution.
|
|
|
|
Upstream shows them in the second panel of its accordion ("Raw Responses (N)",
|
|
collapsed and hidden if there are none (`dnsclient.js:178-194`), and here it was
|
|
missing entirely: the API type already declared `rawResponses`, but nobody drew
|
|
it. It is what lets you see what each server answered along the way when a
|
|
recursive query goes wrong, which is exactly when this screen gets opened.
|
|
*/
|
|
const [raw, setRaw] = useState<unknown[]>([])
|
|
const [alert, setAlert] = useState<AlertState | null>(null)
|
|
const [busy, setBusy] = useState(false)
|
|
/* Why the last query failed, drawn where its response would be: no answer is not an empty answer. */
|
|
const [failure, setFailure] = useState<string | null>(null)
|
|
|
|
async function fire(runImport: boolean) {
|
|
// The order is upstream's: extract first, check afterwards.
|
|
const ready = prepareServer(server, protocol)
|
|
|
|
if (ready.server === '') {
|
|
setAlert({ type: 'warning', title: 'Missing!', text: 'Please enter a valid Name Server.' })
|
|
return
|
|
}
|
|
if (domain.trim() === '') {
|
|
setAlert({ type: 'warning', title: 'Missing!', text: 'Please enter a domain name to query.' })
|
|
return
|
|
}
|
|
|
|
setBusy(true)
|
|
setAlert(null)
|
|
const outcome = await resolve(token, {
|
|
server: ready.server,
|
|
domain,
|
|
type,
|
|
protocol: ready.protocol,
|
|
dnssec,
|
|
eDnsClientSubnet: ecs,
|
|
runImport,
|
|
node: clusterNode,
|
|
})
|
|
setBusy(false)
|
|
|
|
if (outcome.kind !== 'ok') {
|
|
setOutput(null)
|
|
setRaw([])
|
|
setAlert(noticeFromFailure(outcome))
|
|
setFailure(noticeFromFailure(outcome).text)
|
|
return
|
|
}
|
|
setFailure(null)
|
|
|
|
const r = outcome.data.response
|
|
setOutput(JSON.stringify(r.result, null, 2))
|
|
setRaw(r.rawResponses ?? [])
|
|
|
|
if (r.warningMessage) {
|
|
setAlert({ type: 'warning', title: 'Warning!', text: r.warningMessage })
|
|
} else if (runImport) {
|
|
setAlert({
|
|
type: 'success',
|
|
title: 'Records Imported!',
|
|
text: 'Resource records resolved by this DNS client query were successfully imported into this server.',
|
|
})
|
|
}
|
|
}
|
|
|
|
/* `resolveQuery()` on arrival (dnsclient.js:256), once per request. The ref
|
|
outlives the development double-run of effects, so it is still once there. */
|
|
const ran = useRef<DnsClientRequest | null>(null)
|
|
useEffect(() => {
|
|
if (request == null || ran.current === request) return
|
|
ran.current = request
|
|
void fire(false)
|
|
// Only a new request runs it; `fire` reads the state this render holds.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [request])
|
|
|
|
return (
|
|
<div className={styles.wrap}>
|
|
<SectionHeader title="DNS Client" subtitle="Resolve a name through this server or another resolver" />
|
|
|
|
<ServerSelect
|
|
hint={<HubWriteHint write={write} />}
|
|
nodes={nodes}
|
|
initialised={clusterInitialised}
|
|
value={clusterNode}
|
|
onChange={setClusterNode}
|
|
label="Cluster Node"
|
|
/>
|
|
|
|
<Notifier notice={alert} onClose={() => setAlert(null)} />
|
|
|
|
{/*
|
|
The query bar used labels made by hand with inline `style` (`fontSize: 11`,
|
|
`gap: 5`, `marginBottom: 1`) instead of the system's fields. They were six
|
|
values off the scale on a single screen, and that is why the "Type" and
|
|
"DNS-over-" labels did not match the ones next to them.
|
|
*/}
|
|
<Panel title="Query" description="Name, type and server">
|
|
<div className={styles.flt}>
|
|
<div className={styles.width}>
|
|
{/*
|
|
A field with a menu hanging off it, as upstream's (`index.html:840-846`):
|
|
the field stays free text, and the arrow opens EVERY server whatever the
|
|
field holds. It was a `<datalist>`, and the browser filters a datalist by
|
|
the field's text, so with the default "This Server {this-server}" in
|
|
place the arrow showed nothing (issue #15). A select would lose the
|
|
free text. Choosing also moves the protocol (`dnsclient.js:30-46`).
|
|
*/}
|
|
<Field label="Server">
|
|
{(id) => (
|
|
<div className={styles.combo} ref={combo}>
|
|
<Input id={id} mono value={server} onChange={(e) => setServer(e.target.value)} />
|
|
<Menu label="Server list" icon="chevronDown" anchor="left" alignTo={combo}>
|
|
{(close) =>
|
|
servers.map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => {
|
|
setServer(s)
|
|
setProtocol((p) => protocolForServer(s, p))
|
|
close()
|
|
}}
|
|
>
|
|
{s}
|
|
</button>
|
|
))
|
|
}
|
|
</Menu>
|
|
</div>
|
|
)}
|
|
</Field>
|
|
</div>
|
|
<div className={styles.medium}>
|
|
<LabeledInput label="Domain" placeholder="example.com" mono value={domain} onChange={(e) => setDomain(e.target.value)} />
|
|
</div>
|
|
<div className={styles.short}>
|
|
<LabeledSelect label="Type" value={type} onChange={(e) => setType(e.target.value)}>
|
|
{TYPES.map((t) => <option key={t}>{t}</option>)}
|
|
</LabeledSelect>
|
|
</div>
|
|
<div className={styles.short}>
|
|
{/* Upstream's label is "DNS-over-" (the protocol's suffix); on its own it was cut off. */}
|
|
<LabeledSelect label="Protocol" value={protocol} onChange={(e) => setProtocol(e.target.value)}>
|
|
{PROTOCOLS.map((t) => <option key={t}>{t}</option>)}
|
|
</LabeledSelect>
|
|
</div>
|
|
<div className={styles.ecs}>
|
|
<LabeledInput label="EDNS Client Subnet" mono value={ecs} onChange={(e) => setEcs(e.target.value)} />
|
|
</div>
|
|
{/* It was a bare `<input>` inside a `<label>` with its own `.chk`: a
|
|
THIRD name for the same gesture, and the only one in the console
|
|
that came out at 34 px high. */}
|
|
<Check label="Enable DNSSEC Validation" checked={dnssec} onChange={setDnssec} />
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="primary" disabled={busy} onClick={() => void fire(false)}>
|
|
Resolve
|
|
</Button>
|
|
<PermissionButton permission={canImport ? undefined : 'DnsClient.canModify'} disabled={busy || !write.allowed} onClick={() => void fire(true)}>
|
|
Import
|
|
</PermissionButton>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
|
|
{output === null ? (
|
|
failure !== null ? (
|
|
<FailureRetry onRetry={() => void fire(false)}>The query failed. {failure}</FailureRetry>
|
|
) : (
|
|
<Empty>Run a query to see the response.</Empty>
|
|
)
|
|
) : (
|
|
<Panel
|
|
title="Response"
|
|
description="Answer section"
|
|
actions={<span className={styles.meta}>{protocol} · {type}</span>}
|
|
className={styles.panel}
|
|
>
|
|
<Body>
|
|
{/* The label says which response it is: with `Import` and several
|
|
queries in a row, a plain "DNS response" tells none of them apart. */}
|
|
<Raw text={`DNS response for ${domain} ${type}`}>{output}</Raw>
|
|
|
|
{raw.length > 0 && (
|
|
<Details className={styles.raw} summary={`Raw Responses (${raw.length})`}>
|
|
{raw.map((c, i) => (
|
|
<Raw key={i} text={`Raw response ${i + 1} of ${raw.length}`} height={220}>
|
|
{JSON.stringify(c, null, 2)}
|
|
</Raw>
|
|
))}
|
|
</Details>
|
|
)}
|
|
</Body>
|
|
</Panel>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|