The live UI did not follow the canvas because its base layer differed: dark palette in hex (now the canvas oklch values), 13 px body text, flat Frame panels. - Tokens: canvas palette, 14 px body, 1.4 line height; Frame without `dense` gets the 3 px ring; Panel takes a description line. - Shell: 56 px brand button with version, 11 px group headings, user card at the foot, versions moved into the header status popover, Home / group / section crumbs, amber "Attention" status when blocking is off or paused. - New KpiStrip and PageTabs; Dashboard KPIs in three frames and a Refresh button. - Zones: KPI strip, tab with count, views, framed table with the canvas columns. - Apps: Installed / App Store tabs and a grid instead of cards (store inline). - Blocking: state banner and tab counts; one "Blocking" title. - Settings: actions in the header, label-and-help rows with the control on the right. - DHCP, Administration, DNS Client, Logs, Cache, About: subtitles, framed tables, KPI strips where the data exists; Login cover with the three-point list. - docs/compose.shnt.example.yaml: production compose with the console image. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
119 lines
5.2 KiB
TypeScript
119 lines
5.2 KiB
TypeScript
import type { Stats } from '../../api/dashboard'
|
||
import { num2, percentage, type Metric } from './metrics'
|
||
import { formatChange, type Changes } from './compare'
|
||
import { tokenForLabel } from './palette'
|
||
import { Icon, type IconName } from '../../ui/Icon'
|
||
import styles from './Dashboard.module.css'
|
||
|
||
/*
|
||
The eleven figures, in upstream's labels, as the cockpit draws them (Adrián's
|
||
choice of 2026-10-15): one block that answers "how much, of what" without
|
||
scrolling.
|
||
|
||
`Total Queries` is the one read first, so it is the larger card and spans both rows,
|
||
with its trend along its foot. The other ten are compact cards in two rows of five:
|
||
`Clients`, then the response codes (`No Error`, `Server Failure`, `NX Domain`,
|
||
`Refused`) and how the answer was obtained (`Authoritative`, `Recursive`, `Cached`,
|
||
`Blocked`, `Dropped`), which is upstream's own order for those nine. Upstream's words
|
||
for each figure do not change; only their weight and their place do.
|
||
|
||
Every card reads in the same order: what it is, how much with its share of the
|
||
total, and its change against the previous period when there is one. The trend
|
||
beside it is the figure's series in the Queries chart, and the colour of its edge is
|
||
that series' colour.
|
||
*/
|
||
|
||
/*
|
||
A figure that shrinks to fit its card instead of spilling out of it (issue #2: a
|
||
Block List of 2,551,031 domains ran past the `Server` rail at 1440). The card is a
|
||
size container and the figure's font is the smaller of its normal size and the
|
||
width that this many monospace characters can have; `--len` carries the count.
|
||
Short figures keep their size, so only the one that would not fit gives way.
|
||
*/
|
||
export function Figure({ className, text }: { className: string; text: string }) {
|
||
return (
|
||
<div className={className} style={{ ['--len' as string]: String(text.length) }}>
|
||
{text}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/*
|
||
The change against the previous period: an arrow and the size of the change, one
|
||
decimal, in the muted tone of the rest of the context line. The arrow, not a sign,
|
||
is what tells it apart from the share beside it at a glance: two bare percentages
|
||
side by side read as two shares. It is not coloured up or down: more blocked queries
|
||
are not good or bad by themselves, and red and green would say they were.
|
||
|
||
What it is compared with is said once, in the `Total Queries` card (`long`), and in
|
||
full to assistive technology and on hover on every card.
|
||
*/
|
||
function Change({ ratio, against, long = false }: { ratio?: number; against: string; long?: boolean }) {
|
||
if (ratio == null) return null
|
||
const text = formatChange(ratio)
|
||
const arrow = text.startsWith('+') ? '\u25b2 ' : text.startsWith('\u2212') ? '\u25bc ' : ''
|
||
return (
|
||
<span className={styles.change} title={`${text} vs ${against}`}>
|
||
<span aria-hidden="true">{arrow}<b>{text.replace(/^[+\u2212]/, '')}</b></span>
|
||
{long && <span aria-hidden="true"> vs {against}</span>}
|
||
<span className={styles.offscreen}>{text} vs {against}</span>
|
||
</span>
|
||
)
|
||
}
|
||
|
||
/* One glyph per figure, in the series' colour: the icon tile of the KPI strip. */
|
||
const GLYPH: Record<string, IconName> = {
|
||
totalQueries: 'dashboard', totalClients: 'admin', totalNoError: 'check', totalServerFailure: 'warning',
|
||
totalNxDomain: 'about', totalRefused: 'lock', totalAuthoritative: 'zones', totalRecursive: 'dnsclient',
|
||
totalCached: 'cache', totalBlocked: 'blocked', totalDropped: 'power',
|
||
}
|
||
|
||
export function StatCard({
|
||
m,
|
||
s,
|
||
total,
|
||
changes,
|
||
against,
|
||
onOpen,
|
||
}: {
|
||
m: Metric
|
||
s?: Stats
|
||
total: number
|
||
changes: Changes | null
|
||
/** What the change is against, in words ("previous hour"). */
|
||
against: string
|
||
/** The card's jump to Query Logs (`Metric.open`), when it has one and Query Logs
|
||
* is there to land on. */
|
||
onOpen?: () => void
|
||
}) {
|
||
const colour = `var(${tokenForLabel(m.label, 0)})`
|
||
const share = m.pct && s ? percentage(s[m.k], total) : null
|
||
const ratio = s ? changes?.[m.k] : undefined
|
||
return (
|
||
<div className={styles.tile} style={{ ['--tc' as string]: colour }}>
|
||
<span className={styles.ico} aria-hidden="true"><Icon name={GLYPH[m.k] ?? 'dashboard'} size={18} /></span>
|
||
<div className={styles.text}>
|
||
<div className={styles.k}>{m.label}</div>
|
||
{/* With no data it is NOT a zero: it is a dash, and dimmed. A zero is a
|
||
true figure, and here it would be a lie. */}
|
||
<div className={styles.line}>
|
||
<Figure className={`${styles.v}${s ? '' : ` ${styles.noData}`}`} text={s ? num2(s[m.k]) : '—'} />
|
||
{/* The hint beside the figure: its share of the total and its change. */}
|
||
<div className={styles.ctx}>
|
||
{share != null && <span className={styles.p}>{share}</span>}
|
||
{share != null && ratio != null && ' '}
|
||
<Change ratio={ratio} against={against} long={m.k === 'totalQueries'} />
|
||
{share == null && ratio == null && ' '}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/* Upstream's card is a link to Query Logs (v15.6.2). Here a button laid over
|
||
the whole card, so the card's own reading order stays as it is; its name
|
||
is the card's label and its title upstream's menu text. */}
|
||
{onOpen && (
|
||
<button type="button" className={styles.open} aria-label={m.label} title="Show Query Logs" onClick={onOpen} />
|
||
)}
|
||
</div>
|
||
)
|
||
}
|