Files
technitium-console/src/screens/dashboard/Figures.tsx
T
DenozordecandClaude Sonnet 5.5 87281a87d3
CI / check (push) Failing after 37s
CI / image (push) Skipped
Match the Technitium canvas: palette, shell, KPI strips, framed tables
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]>
2026-10-27 12:47:27 +07:00

119 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}