Files
technitium-console/src/screens/dnsclient/DnsClient.tsx
T
DenozordecandClaude Opus 5.5 ba3cdd08d2
CI / check (push) Successful in 2m39s
Hub / check (push) Successful in 50s
CI / image (push) Successful in 1m30s
Hub / image (push) Successful in 1m37s
UI polish across sections: one server selector, skeletons, inline errors
- 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]>
2026-11-02 21:42:28 +07:00

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>
)
}