Move the console onto ReUI PRO

Install ReUI (MCP, skill, @reui registry, Tailwind v4, shadcn base-nova) and move
the shared UI onto it: Button, Tag, Check, Input/Textarea, Segmented, Alert, Panel,
Dialog, Tooltip, Menu and Select (Popover), Toast (sonner), the Table frame
(DataGrid), the Dashboard KPI strip, the shnt Ops palette, Geist and the type scale.

Design only: behaviour, texts and roles are unchanged. Tests that asserted the old
DOM were updated; the tests now format numbers in English wherever they run.
docs/reui-migration-plan.md records the plan and its decisions.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Sonnet 5.5 committed 2026-10-27 00:06:06 +07:00
commit f3531c64f9
680 files changed
+139836

No files matched your search

+128
View File
@@ -0,0 +1,128 @@
/*
The log as the viewer draws it: marked, never changed.
Everything here is checked on the DOM `LogText` builds, because that is what is
copied, searched and read aloud. The CSS that turns the marks into a quieter
timestamp and a hanging indent is in `Logs.module.css` and `ui/Raw.module.css`.
*/
import { describe, expect, it } from 'vitest'
import { render } from '@testing-library/react'
import { LogText, logNodes, stampWidth } from './LogText'
const CLASSES = { stamp: 'stamp', chunk: 'chunk', nul: 'nul' }
const LOG = [
'[2026-10-16 06:06:32 UTC] Logging started.',
'[2026-10-16 06:06:32 UTC] DNS Server is reading block list from: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts',
'System.Net.Http.HttpRequestException: could not resolve host',
' at DnsServerCore.Dns.ZoneManagers.BlockListZoneManager.<>c__DisplayClass40_0 in Z:\\Technitium\\Projects\\DnsServer\\DnsServerCore\\Dns\\ZoneManagers\\BlockListZoneManager.cs:line 607',
'[2026-10-16 06:06:33 Local] Logging stopped.',
'',
].join('\r\n')
describe('the server log in the viewer', () => {
it('the text is the file byte for byte, carriage returns included', () => {
const { container } = render(<LogText text={LOG} />)
expect(container.textContent).toBe(LOG)
})
it('the hanging indent is the width of the timestamp, UTC or local', () => {
expect(stampWidth('[2026-10-16 06:06:32 UTC] x')).toBe(26)
expect(stampWidth('[2026-10-16 06:06:32 Local] x')).toBe(28)
})
/* An error from the server is JSON, with no timestamp to hang under. */
it('a text without the timestamp has none', () => {
expect(stampWidth('{\n "status": "error"\n}')).toBe(0)
})
it('each line timestamp is its own element, and only the timestamp', () => {
const stamps = [...logNodes(LOG, CLASSES).querySelectorAll('.stamp')].map((s) => s.textContent)
expect(stamps).toEqual([
'[2026-10-16 06:06:32 UTC] ',
'[2026-10-16 06:06:32 UTC] ',
'[2026-10-16 06:06:33 Local] ',
])
})
it('a timestamp in the middle of a line is not one', () => {
const text = 'see [2026-10-16 06:06:32 UTC] here'
expect(logNodes(text, CLASSES).querySelectorAll('.stamp')).toHaveLength(0)
})
/*
A long URL or path may end a line only after a separator: what is before each
`<wbr>` ends in `/`, `\`, `.`, `?`, `&` or `=`, never in a letter.
*/
it('a long word may break only after its separators', () => {
const chunk = logNodes(LOG, CLASSES).firstElementChild!
const before = [...chunk.querySelectorAll('wbr')].map((w) => w.previousSibling?.textContent ?? '')
expect(before.length).toBeGreaterThan(10)
for (const text of before) expect(text).toMatch(/[/\\.?&=]$/)
expect(before.some((t) => t.endsWith('https://'))).toBe(true)
expect(before.some((t) => t.endsWith('Projects\\'))).toBe(true)
})
it('a word short enough to fit a continuation line gets no break', () => {
const nodes = logNodes('[2026-10-16 06:06:32 UTC] loaded: /etc/dns/dns.config', CLASSES)
expect(nodes.querySelectorAll('wbr')).toHaveLength(0)
})
/* A hundred lines per chunk, and no line is split between two. */
it('the file is laid out in chunks of a hundred whole lines', () => {
const text = Array.from({ length: 250 }, (_, i) => `line ${i}\r\n`).join('')
const chunks = [...logNodes(text, CLASSES).children]
expect(chunks.map((c) => c.className)).toEqual(['chunk', 'chunk', 'chunk'])
expect(chunks.map((c) => c.textContent?.split('\n').length)).toEqual([101, 101, 51])
expect(chunks[1].textContent?.startsWith('line 100\r\n')).toBe(true)
})
/*
A file the server was writing when the power went can come back with a run of
zero bytes where the lost part was. Wrapped, that invisible run filled the
viewer with blank lines above the first entry (issue #20).
*/
describe('a run of zero bytes', () => {
const ZEROS = '\0'.repeat(5000)
const FILE = `${ZEROS}[2026-10-20 14:43:06 Local] Check for update was done\n[2026-10-20 14:44:00 Local] Logging stopped.\n`
it('is its own element, so it takes no room', () => {
const runs = [...logNodes(FILE, CLASSES).querySelectorAll('.nul')]
expect(runs.map((r) => r.textContent)).toEqual([ZEROS])
})
it('stays in the text, byte for byte', () => {
const { container } = render(<LogText text={FILE} />)
expect(container.textContent).toBe(FILE)
})
it('in front of a timestamp still lets the timestamp be one', () => {
const stamps = [...logNodes(FILE, CLASSES).querySelectorAll('.stamp')].map((s) => s.textContent)
expect(stamps).toEqual(['[2026-10-20 14:43:06 Local] ', '[2026-10-20 14:44:00 Local] '])
})
it('in front of the first timestamp does not change the hanging indent', () => {
expect(stampWidth(FILE)).toBe(28)
})
it('inside a line is set apart too, and the words around it are kept', () => {
const line = `cut short\0\0\0[2026-10-20 14:45:42 Local] Logging started.`
const nodes = logNodes(line, CLASSES)
expect([...nodes.querySelectorAll('.nul')].map((r) => r.textContent)).toEqual(['\0\0\0'])
expect(nodes.textContent).toBe(line)
})
})
/* Built from text nodes, never parsed: a log line is data. */
it('markup in the file is drawn as text', () => {
const { container } = render(<LogText text={'<img src=x onerror=alert(1)>'} />)
expect(container.querySelector('img')).toBeNull()
expect(container.textContent).toBe('<img src=x onerror=alert(1)>')
})
it('a new file replaces the previous one', () => {
const { container, rerender } = render(<LogText text="first" />)
rerender(<LogText text="second" />)
expect(container.textContent).toBe('second')
})
})
+160
View File
@@ -0,0 +1,160 @@
import { useLayoutEffect, useRef } from 'react'
import styles from './Logs.module.css'
/*
The server log as the viewer draws it: the same characters, with three things the
browser cannot see on its own.
1. **Where an entry's time ends.** Every line the server writes starts with
`[yyyy-MM-dd HH:mm:ss UTC] ` or `[… Local] ` (LogManager.cs:605-614). That prefix
goes in a `<span>` drawn in `--faint`, the console's colour for a timestamp
(tokens.css; on `--field` it measures 5.64:1 in light and 5.78:1 in dark), so the
eye runs down the messages and still finds the time. Its length is also the hanging
indent `ui/Raw` gives the continuation lines (`stampWidth`), so they line up
under the message and not under the bracket. Lines without it (the frames of a
stack trace) are drawn as they are.
2. **Where a long word may end a line.** A list URL, a zone file path or a stack
frame has no spaces, so on a narrow box the browser would either overflow or,
with `break-word`, cut it wherever the edge falls (`raw.githubuser` /
`content.com`). A `<wbr>` goes after each run of `/`, `\`, `.`, `?`, `&` and
`=`: the separators of `ui/UrlText`, plus the backslash of the Windows paths
.NET writes into a stack trace. Only words longer than `LONG` get them: a
shorter one fits a continuation line even on a phone, and every `<wbr>` is a
node to lay out.
3. **Where the file is only zero bytes.** A file the server was writing when the
power went can come back with a run of NULs where the lost part was. Each
NUL is drawn a fraction of a pixel wide and none of them is a break, so
wrapped, a run of 160,000 left 268 px of blank above the first entry at 1440
(Firefox and Chromium) and pushed that entry to the right edge (issue #20).
Upstream's `<pre>` does not wrap: there the run is one line, off to the side,
and the file reads from the top. Each run goes in a `<span>` laid out zero wide
(`.logNul`), so it takes no room and the line after it reads as a clean one; a
timestamp right after a leading run is still a timestamp, and `stampWidth`
looks past it.
None of them adds a character. Each `<span>` holds the file's own characters and a
`<wbr>` carries none, so the DOM's text, what is copied and what a screen reader
reads are the file byte for byte, `\r\n` included. Measured in Chromium on the
showcase's log of 2026-10-15 (86,809 characters, 13 of its line ends `\r\n`) and
on a 2 MB file made of it: selecting the viewer and copying gives what it gave
before this change, the response without its final newline, which a browser
never copies out of a `<pre>`.
*/
const STAMP = /^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} (?:UTC|Local)\] /
/* The same, behind the zero bytes a lost write can leave at the start. */
const LEADING_STAMP = /^\0*(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} (?:UTC|Local)\] )/
const NULS = /(\0+)/
/* A word this short fits a continuation line down to a 320 px phone (33
characters, 2 of them indent), so it never needs a break of its own. */
const LONG = 31
const SEPARATOR = /(?<=[/\\.?&=])(?![/\\.?&=])/
/* Lines per chunk; see LogText below. */
const CHUNK = 100
/** The width of the log's timestamp prefix in characters, 0 if it has none. */
export function stampWidth(text: string): number {
return LEADING_STAMP.exec(text)?.[1].length ?? 0
}
/*
The nodes for `text`. Plain text is gathered into one text node between elements,
so a line with nothing to mark costs one node and not one per word. Only text
nodes, `<span>` and `<wbr>` are created, never HTML parsed from the file.
*/
export function logNodes(
text: string,
{ stamp: stampClass, chunk: chunkClass, nul: nulClass }: { stamp: string; chunk: string; nul: string },
): DocumentFragment {
const doc = document.createDocumentFragment()
let out: HTMLElement | DocumentFragment = doc
let plain = ''
const flush = () => {
if (plain) out.append(plain)
plain = ''
}
const zeros = (run: string) => {
flush()
const span = document.createElement('span')
span.className = nulClass
span.textContent = run
out.append(span)
}
const lines = text.split(/(?<=\n)/)
for (const [n, line] of lines.entries()) {
if (n % CHUNK === 0) {
flush()
out = document.createElement('span')
out.className = chunkClass
doc.append(out)
}
let rest = line
const lead = /^\0+/.exec(rest)?.[0]
if (lead) {
zeros(lead)
rest = rest.slice(lead.length)
}
const stamp = STAMP.exec(rest)?.[0]
if (stamp) {
flush()
const span = document.createElement('span')
span.className = stampClass
span.textContent = stamp
out.append(span)
rest = rest.slice(stamp.length)
}
for (const part of rest.split(NULS)) {
if (part[0] === '\0') {
zeros(part)
continue
}
for (const word of part.split(/(\s+)/)) {
if (word.length <= LONG || /\s/.test(word)) {
plain += word
continue
}
const pieces = word.split(SEPARATOR)
pieces.forEach((piece, i) => {
plain += piece
if (i < pieces.length - 1) {
flush()
out.append(document.createElement('wbr'))
}
})
}
}
}
flush()
return doc
}
/*
Built outside React, and laid out a hundred lines at a time, on purpose.
The viewer asks for up to 2 MB. A 2 MB file made of the showcase's own log is
13,809 lines, 6,013 timestamps and about 61,000 break points. Measured on the
production build at 1440, opening it took 0.25 s before this change (one text
node); as React elements it took 1.2 s, and appended as one fragment still 1.0 s,
because laying out that many inline nodes is the cost and not React.
So the lines go in blocks of `CHUNK`, each `content-visibility: auto`
(`Logs.module.css`, `.logChunk`): a block far from the visible part is not laid out at
all, and opening the 2 MB file takes 0.3 s, layout under 10 ms. The text is still
all in the DOM, so selecting, copying, find-in-page and a screen reader see every
line. Each block ends with its line's own newline and the copy is the same as
without blocks (measured, see above). Until a block has been shown its height is
an estimate, a hundred unwrapped lines (`100lh`), so the scrollbar can shift a
little the first time a far part is reached.
React owns the `<span>` and never its children.
*/
export function LogText({ text }: { text: string }) {
const ref = useRef<HTMLSpanElement>(null)
useLayoutEffect(() => {
ref.current?.replaceChildren(logNodes(text, { stamp: styles.logStamp, chunk: styles.logChunk, nul: styles.logNul }))
}, [text])
return <span ref={ref} />
}
+323
View File
@@ -0,0 +1,323 @@
/* Logs. Two very different sub-screens: a two-column file viewer and a table with
fifteen filters. The table classes are the same as in Cache, Allowed and
Blocked. */
.wrap { composes: screen from '../../ui/SectionHeader.module.css'; }
.acts { display: flex; gap: var(--s-4); flex-wrap: wrap; justify-content: flex-end; align-items: center; }
/* ---- View Logs ---- */
/*
Master and detail, both at once, and at 390 too.
At 1440 it is `250px` for the list and the rest for the viewer. On narrowing it
**does not turn into navigation**: they stack and both stay present. And it is a
decision, not a convenience: a paginated collection would be worth turning into
list→detail, but here there are **three files**, so sending the user to another
screen to see one and back to see another costs more than it saves.
Each panel answers for itself: the list can fail without the viewer hearing about
it, and the other way round. That is why there is no "screen" state, there are
two.
The breakpoint is 720, the same one `SectionHeader`, `Segmented` and `PanelForm`
already use: below that, 250 px of list leave the viewer with less than half the
screen and a log line stops fitting anywhere.
*/
.split { display: grid; grid-template-columns: 250px minmax(0, 1fr); gap: var(--s-10); align-items: start; }
@media (max-width: 720px) {
.split { grid-template-columns: minmax(0, 1fr); }
}
/*
The window's height, handed down to the reader (see ViewLogs.tsx). The Shell's body
is a flex column for a screen marked `data-fills-window`; from there each step takes
the rest: the screen, the split under the header, the reader panel (stretched, while
the file list keeps its own height), its body, and `Raw`'s `fill` box at the end.
*/
.viewLogs { composes: wrap; flex: 1 1 auto; }
.viewLogs .split { flex: 1 1 auto; grid-template-rows: minmax(0, 1fr); }
.reader { align-self: stretch; display: flex; flex-direction: column; }
.readerBody { flex: 1 1 auto; display: flex; flex-direction: column; }
/* Stacked under the file list: 70 % of the window, see ViewLogs.tsx. */
@media (max-width: 720px) {
.viewLogs .split { grid-template-rows: auto; }
.readerBody { flex: none; height: 70dvh; }
}
/* The time of an entry, one step below its message: see LogText.tsx. */
.logStamp { color: var(--faint); }
/* A hundred lines of the file, laid out only near the screen: see LogText.tsx. */
.logChunk { display: block; content-visibility: auto; contain-intrinsic-block-size: auto 100lh; }
/* A run of zero bytes, laid out zero wide and on one line: see LogText.tsx. `clip`
and not `hidden`: Chromium leaves what `hidden` cuts out of a selection, and the
run is the file's own text, copied with the rest. */
.logNul { display: inline-block; inline-size: 0; overflow: clip; white-space: pre; vertical-align: top; }
.pbList { padding: var(--s-5); }
.logfiles { display: flex; flex-direction: column; gap: var(--s-1); }
.logfile { composes: row from '../../ui/list.module.css'; justify-content: space-between; }
.logfile .name { font-family: var(--font-mono); }
.sz { composes: annotationQuiet from '../../ui/text.module.css'; }
/* ---- Query Logs ---- */
.fs { composes: panel from '../../ui/Panel.module.css'; margin-bottom: var(--s-9); }
/* The box is a panel, so its title is a panel's (`ui/Panel`, `.title`): one step
above the field labels under it, not another row of small capitals. */
.fsTitle {
composes: title from '../../ui/Panel.module.css';
display: block; width: 100%; padding: var(--s-8) var(--s-10);
border-bottom: 1px solid var(--line);
}
/*
The filters are laid out as GROUPS SIDE BY SIDE, not as one row per group.
They used to be five full-width rows (Source, Mode, Query, Response, Paging), each
with its name in a 110 px column on the left and its fields in a single line on
the right. Every row used less than half of its width, and the five stacked made
the panel so tall that at 1440x980 the results started at y=842, with one row on
screen: the table, which is what the screen is for, was below the fold.
Now each group carries its name ABOVE its fields and the groups flow side by side,
wrapping as the width runs out: two bands at 1440 instead of five rows (Source,
Mode and Paging; then Query and Response), so the table starts at y=629. Nothing
is collapsed or hidden at this width: the same fifteen controls, the same labels and
options. Below 960 the whole panel folds instead (see `.fsToggle` below).
Two things moved: `Live Update` went to the screen's header (QueryLogs.tsx says
why), and Paging now follows Mode in the document, which is also the tab order.
The grouping is the one decided below (the MODE group keeps its four fields and
its edge together).
*/
.fsIn {
display: flex; flex-wrap: wrap; align-items: flex-start;
gap: var(--s-9) var(--s-10);
padding: var(--s-8) var(--s-10) var(--s-10);
}
.group { display: flex; flex-direction: column; gap: var(--s-4); min-width: 0; max-width: 100%; }
/* Every head is as tall as the `While Live Update` pill that appears in Mode's,
so the fields of the groups in one band line up and the pill appearing does
not move anything. */
.groupLabel {
display: flex; align-items: center; gap: var(--s-6) var(--s-8); flex-wrap: wrap;
min-height: var(--s-11);
font-size: var(--t-1); font-weight: 600; letter-spacing: 0.09em;
text-transform: uppercase; color: var(--mute);
}
.groupCtl { display: flex; align-items: flex-end; gap: var(--s-7); flex-wrap: wrap; }
.field { display: flex; flex-direction: column; gap: var(--s-3); width: var(--ql-field, auto); }
/* On a phone each filter takes the whole line instead of its desktop width: the
same column for all fifteen, and no field left half as wide as its neighbour. */
@media (max-width: 560px) {
.group, .groupCtl, .field { width: 100%; }
}
.field label { font-size: var(--t-2); color: var(--mute); font-weight: 550; }
.field input, .field select { width: 100%; }
/*
Folded, below 960 (FOLD_QUERY in QueryLogs.tsx says what and why).
The title becomes the toggle and the whole title row is its target, so the button
carries the padding the heading had. Closed, the row loses its rule: under it there
is either the list of what is set, which brings its own, or the panel's edge.
*/
.fsIn[hidden] { display: none; }
.fsTitleFold { padding: 0; }
.fsTitleClosed { border-bottom: 0; }
.fsToggle {
display: flex; align-items: center; gap: var(--s-5);
width: 100%; padding: var(--s-8) var(--s-10);
border: 0; background: none; color: inherit; font: inherit; text-align: left;
cursor: pointer;
}
/* The panel clips what overflows it, the focus ring included: drawn inside. */
.fsToggle:focus-visible { outline-offset: calc(-1 * var(--s-1)); }
.chevron { composes: chevron from '../../ui/Details.module.css'; color: var(--mute); }
[aria-expanded='true'] > .chevron { transform: rotate(90deg); }
/* What is set, by the fields' own labels: label quiet, value as typed. */
.fsSet {
display: flex; flex-wrap: wrap; gap: var(--s-3) var(--s-9);
margin: 0; padding: var(--s-7) var(--s-10) var(--s-8);
border-top: 1px solid var(--line);
font-size: var(--t-3); color: var(--ink);
}
.fsSetItem { min-width: 0; overflow-wrap: break-word; }
.fsSetLabel { color: var(--mute); }
/* The count is `ui/count.module.css`'s: see there for why. */
.count { composes: bar above from '../../ui/count.module.css'; }
.mono { composes: mono from '../../ui/text.module.css'; }
.nowrap { white-space: nowrap; }
/*
The Query Logs table: one line per row.
Every cell keeps to one line. Domain, Answer and the client address are cut with
an ellipsis and carry the whole value in their text and `title` (`Clip` in
QueryLogs.tsx); the rest are short by nature. Before, those three broke anywhere,
the timestamp took two lines, and a row with a long answer reached 200 px: at
1440x980 one row fitted on the first screen.
Answer is the elastic column: `max-width: 0` takes it out of the table's minimum
width and `width: 100%` hands it whatever the other columns leave, so a long list
of addresses never pushes the row menu out of the panel. Its minimum keeps a
readable piece of it where the table no longer fits and scrolls inside its panel.
Domain is cut at a fixed width instead, wide enough for 89 of 100 names on a page
of the showcase traffic. Written as `table.` to outweigh ui/Table's
`.table thead th` and `.table td`, which weigh the same as one class.
*/
table.queryTable thead th { white-space: normal; padding-left: var(--s-3); padding-right: var(--s-3); }
table.queryTable td { white-space: nowrap; padding-left: var(--s-3); padding-right: var(--s-3); }
table.queryTable td:first-child { padding-left: var(--s-7); }
table.queryTable td.answer { width: 100%; max-width: 0; min-width: 16ch; }
.clipDomain, .clipAnswer, .clipClient {
display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.clipDomain { max-width: 30ch; }
.clipClient { max-width: 24ch; }
/* Narrower windows: Domain gives up width first, and the timestamp breaks
between date and time (`Stamp`), so the table still fits at 1366 and needs
less of a scroll inside its panel below that. */
@media (max-width: 1439px) {
.clipDomain { max-width: 22ch; }
table.queryTable td.answer { min-width: 12ch; }
}
@media (max-width: 1365px) {
table.queryTable td.stamp { white-space: normal; }
}
/* When the rows stack (`stack` in ui/Table: the table no longer fits its panel,
which at 1024 and 768 it does not), each cell is as wide as its value and the
widths above stand down: the timestamp on one line, Domain and Answer cut only
where the row ends, and each value on its label's line. */
table.queryTable[data-stacked] td:first-child { padding-left: 0; }
table.queryTable[data-stacked] td.stamp { white-space: nowrap; }
/* Answer: its label and its one cut line side by side, the line taking the rest
of the row (a flex item shrinks; an inline box beside a label would not). */
table.queryTable[data-stacked] td.answer { display: flex; align-items: baseline; width: auto; max-width: 100%; min-width: 0; }
table.queryTable[data-stacked] .clipAnswer { min-width: 0; }
table.queryTable[data-stacked] .clipDomain,
table.queryTable[data-stacked] .clipClient { display: inline-block; max-width: 100%; vertical-align: bottom; }
table.queryTable[data-stacked] .rtt { display: inline; }
/*
Upstream's row colour code (logs.js:452-508), with the SAME mapping, drawn as a pill.
`rowClass` still decides one category per row from the RCODE and, within it, the
response type, exactly as upstream does. What changed is where it is painted.
Upstream lays it over the whole row; here it went as a 30 % background over every
cell, and on a page of mostly Cached rows the table read as one block of colour,
so the exception (a Blocked row) hardly stood out.
Now the row is neutral and the category sets ONE custom property, `--ql-tone`,
which the pill around the Response Type reads: the same 30 % over the panel the
whole row used to carry, so every measurement of `dev/row-colours.mjs` still
holds as it is (ΔE00 between pairs as a background, ink at 6.5:1 over the
lightest one).
The colour is the ROW's, not the cell's: an `NxDomain` row whose type is
`Recursive` gets the NX Domain colour on a pill that reads `Recursive`, exactly
as the whole row did before. That is why the legend still says "row colours".
**A pill and not an edge**: the edge belongs to the amber, and it means "this is
yours to change". And the colour is still reinforcement, never the only channel:
`RCODE` and `Response Type` are written in their cells, and the legend names the
seven conditions. One pair (`Server Failure`/`Cached` at 8.5) is below the
collision threshold, see `tokens.css`.
The custom property is called `--ql-tone` so that it cannot shadow a token: no
name in `tokens.css` starts with `--ql-`.
*/
.rServerFailure { --ql-tone: var(--ch-fail); }
.rBlocked { --ql-tone: var(--ch-block); }
.rNxDomain { --ql-tone: var(--ch-nx); }
.rRefused { --ql-tone: var(--ch-refuse); }
.rAuthoritative { --ql-tone: var(--ch-auth); }
.rRecursive { --ql-tone: var(--ch-rec); }
.rCached { --ql-tone: var(--ch-cache); }
/* A row with no category gets a pill with no fill, so the response type keeps
the same place on every row. */
.tone {
display: inline-block;
padding: var(--s-1) var(--s-5);
border-radius: var(--r-1);
background: color-mix(in srgb, var(--ql-tone, transparent) 30%, transparent);
white-space: nowrap;
}
.rtt { font-size: var(--t-2); color: var(--faint-on-tint); padding-left: var(--s-5); }
/*
The legend of the seven, and it is ALWAYS complete.
The design rule is explicit: every label that comes from the server carries its
legend entry. Here there were seven colours and none: the user saw coloured rows
and nothing explaining them.
Complete, and not "the ones on this page": a legend that changes with the data
shows six colours on one page and four on the next, and stops being a vocabulary
to become a summary of what is in front of you.
*/
.legend {
display: flex; flex-wrap: wrap; align-items: center;
gap: var(--s-5) var(--s-9);
padding: var(--s-7) var(--s-9);
margin-bottom: var(--s-7);
border: 1px solid var(--line); border-radius: var(--r-3); background: var(--pan);
}
.legendTitle {
font-size: var(--t-1); font-weight: 600; letter-spacing: 0.09em;
text-transform: uppercase; color: var(--mute);
}
.legendItem { display: inline-flex; align-items: center; gap: var(--s-5); font-size: var(--t-2); }
/* Each entry is the pill itself, the same one the Response Type cell carries,
followed by the condition that paints it. */
.legendItem .tone { font-weight: 550; color: var(--ink); }
.legendWhen { color: var(--faint); }
/* Folded (below 960): the seven pills stay, their conditions wait behind the
title, which is the toggle. */
.legendFolded .legendWhen { display: none; }
.legendToggle {
composes: hitArea from '../../ui/interaction.module.css';
display: inline-flex; align-items: center; gap: var(--s-4);
padding: 0; border: 0; background: none; font-family: inherit; cursor: pointer;
}
/* Centred on purpose: it is a bar filter, not a settings row. But the minimum
height is the same as `ui/Check.module.css`'s. */
/* `.check` went in the redesign: it was a hand copy of `ui/Check` with eight px of gap
instead of nine, and `uniformity.js` counted it as a separate signature of the
same checkbox. Query Logs uses the primitive now. */
/*
The group a MODE switches off: the edge, once for all four.
It is the same master-switch signal as `Settings` with the polarity inverted:
there the control is off because its master is OFF and the pill says `Needs …`;
here it is off because `Live Update` is ON and it says `While …`. The mechanics do
not change because what matters is the same: **the user can undo it themselves**,
and that is what the amber means.
It composes from `Form.module.css` so that it is THE SAME edge and not another one
like it: an inner shadow and not a border, because a border eats two pixels of the
box and shifts the content, as measured in `Settings`, where it gave away a third
grid signature that existed only on that screen.
It goes on the GROUP and not on each field because all four switch off together:
four pills saying the same thing are noise, and the `Settings` rule was already
one pill per group.
*/
/* The edge needs room to its left, and it takes it from the gap BEFORE the group
(the negative margin cancels the padding): a group that grew when the mode
switched on would push its neighbours to another band. */
/* The switch in the header: centred on the buttons beside it. */
.live { display: inline-flex; align-items: center; padding-left: var(--s-5); }
.byMode {
composes: dependent from '../../ui/Form.module.css';
padding-left: var(--s-8);
margin-left: calc(-1 * var(--s-8));
}
+754
View File
@@ -0,0 +1,754 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Logs } from './Logs'
import * as api from '../../api/logs'
import * as dashboard from '../../api/dashboard'
import * as apps from '../../api/apps'
import type { InstalledApp } from '../../api/apps'
import type { QueryLogEntry, QueryLogPage } from '../../api/logs'
import { rowClass, pageRange, statusText, LEGEND_ROWS } from './QueryLogs'
import { choose, optionsOf, valueShown } from '../../test/dropdown'
afterEach(() => vi.restoreAllMocks())
const OK = { kind: 'ok' as const, data: {} }
const FILES = [
{ fileName: '2026-08-26', size: '2.96 KB' },
{ fileName: '2026-08-25', size: '20.48 KB' },
]
const DETAIL = {
classPath: 'QueryLogsSqlite.App',
description: 'Logs queries.',
recordDataTemplate: null,
isAppRecordRequestHandler: false,
isRequestController: false,
isAuthoritativeRequestHandler: false,
isRequestBlockingHandler: false,
isQueryLogger: true,
isQueryLogs: true,
isPostProcessor: false,
}
const APP: InstalledApp = {
name: 'Query Logs (Sqlite)',
description: 'Logs all incoming DNS requests.',
version: '8.0',
dnsApps: [DETAIL],
}
const NO_QUERY_LOGS: InstalledApp = {
name: 'NO DATA',
description: 'Returns NO DATA.',
version: '5.0',
dnsApps: [{ ...DETAIL, classPath: 'NoData.App', isQueryLogs: false }],
}
const ENTRY: QueryLogEntry = {
rowNumber: 10,
timestamp: '2026-08-26T05:32:14.1836952Z',
clientIpAddress: '127.0.0.1',
protocol: 'Udp',
responseType: 'Recursive',
responseRtt: 12.8186,
rcode: 'NoError',
qname: 'github.com',
qtype: 'A',
qclass: 'IN',
answer: 'A 140.82.121.3',
}
function page(partial: Partial<QueryLogPage> = {}): QueryLogPage {
return { pageNumber: 1, totalPages: 1, totalEntries: 1, entries: [ENTRY], ...partial }
}
function withApps(list: InstalledApp[]) {
return vi
.spyOn(apps, 'listApps')
.mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: { apps: list } } } as never)
}
describe('Logs › View Logs', () => {
it('it lists the files with their size and offers to delete them all', async () => {
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
render(<Logs token="t" sub="View Logs" />)
expect(await screen.findByText('2026-08-26')).toBeInTheDocument()
expect(screen.getByText('[20.48 KB]')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete All Logs' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeInTheDocument()
})
it('with no files it says \"No Log File Was Found\" and \"Delete All Logs\" disappears, but NOT \"Delete All Stats\"', async () => {
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: [] })
render(<Logs token="t" sub="View Logs" />)
expect(await screen.findByText('No Log File Was Found')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Delete All Logs' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeInTheDocument()
})
it('opening a file asks for only the first 2 MB and draws it', async () => {
const user = userEvent.setup()
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
const spy = vi.spyOn(api, 'downloadLogText').mockResolvedValue('[2026-08-26] Logging started.')
render(<Logs token="t" sub="View Logs" />)
await screen.findByText('2026-08-26')
await user.click(screen.getByText('2026-08-26'))
expect(spy).toHaveBeenCalledWith('t', '2026-08-26', '')
expect(await screen.findByText('[2026-08-26] Logging started.')).toBeInTheDocument()
})
/*
The reader marks the timestamps and the break points of long words, and adds no
character: what it holds is the response as it came, so copying gives it back.
*/
it('the file is drawn as it came, with its timestamps marked and nothing added', async () => {
const user = userEvent.setup()
const text = '[2026-08-26 05:32:14 UTC] DNS Server is reading block list from: https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts\r\n'
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
vi.spyOn(api, 'downloadLogText').mockResolvedValue(text)
render(<Logs token="t" sub="View Logs" />)
await user.click(await screen.findByText('2026-08-26'))
const viewer = await screen.findByLabelText('2026-08-26 log file')
expect(viewer.textContent).toBe(text)
expect(within(viewer).getByText('[2026-08-26 05:32:14 UTC]')).toBeInTheDocument()
expect(viewer.querySelectorAll('wbr').length).toBeGreaterThan(0)
})
/* The Shell gives a flex column only to a screen that asks for it. */
it('the screen asks the Shell for the height of the window', async () => {
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
const { container } = render(<Logs token="t" sub="View Logs" />)
await screen.findByText('2026-08-26')
expect(container.querySelector('[data-fills-window]')).not.toBeNull()
})
it('\"Download\" asks for the whole file through a single-use token', async () => {
const user = userEvent.setup()
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
vi.spyOn(api, 'downloadLogText').mockResolvedValue('x')
const spy = vi.spyOn(api, 'openLogDownload').mockResolvedValue({ ok: true })
render(<Logs token="t" sub="View Logs" />)
await screen.findByText('2026-08-26')
await user.click(screen.getByText('2026-08-26'))
await screen.findByText('x')
await user.click(screen.getByRole('button', { name: 'Download' }))
expect(spy).toHaveBeenCalledWith('t', '2026-08-26', '')
})
it('deleting a file confirms with its name and alerts with the literal text', async () => {
const user = userEvent.setup()
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
vi.spyOn(api, 'downloadLogText').mockResolvedValue('x')
const spy = vi.spyOn(api, 'deleteLog').mockResolvedValue(OK)
render(<Logs token="t" sub="View Logs" />)
await screen.findByText('2026-08-26')
await user.click(screen.getByText('2026-08-26'))
await screen.findByText('x')
await user.click(screen.getByRole('button', { name: 'Delete' }))
expect(
screen.getByText("Are you sure you want to permanently delete the log file '2026-08-26'?"),
).toBeInTheDocument()
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete' }))
expect(spy).toHaveBeenCalledWith('t', '2026-08-26', '')
expect(await screen.findByText('Log Deleted!')).toBeInTheDocument()
expect(screen.getByText('Log file was deleted successfully.')).toBeInTheDocument()
})
it('\"Delete All Logs\" confirms and alerts with \"Logs Deleted!\"', async () => {
const user = userEvent.setup()
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
const spy = vi.spyOn(api, 'deleteAllLogs').mockResolvedValue(OK)
render(<Logs token="t" sub="View Logs" />)
await screen.findByText('2026-08-26')
await user.click(screen.getByRole('button', { name: 'Delete All Logs' }))
expect(
screen.getByText('Are you sure you want to permanently delete all log files?'),
).toBeInTheDocument()
await user.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete All Logs' }),
)
expect(spy).toHaveBeenCalledWith('t', '')
expect(await screen.findByText('Logs Deleted!')).toBeInTheDocument()
expect(screen.getByText('All log files were deleted successfully.')).toBeInTheDocument()
})
it('\"Delete All Stats\" calls the DASHBOARD endpoint, not the logs one', async () => {
const user = userEvent.setup()
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
const spy = vi.spyOn(dashboard, 'deleteAllStats').mockResolvedValue(OK)
render(<Logs token="t" sub="View Logs" />)
await screen.findByText('2026-08-26')
await user.click(screen.getByRole('button', { name: 'Delete All Stats' }))
expect(
screen.getByText('Are you sure you want to permanently delete all stats files?'),
).toBeInTheDocument()
await user.click(
within(screen.getByRole('dialog')).getByRole('button', { name: 'Delete All Stats' }),
)
expect(spy).toHaveBeenCalledWith('t')
expect(await screen.findByText('Stats Deleted!')).toBeInTheDocument()
expect(screen.getByText('All stats files were deleted successfully.')).toBeInTheDocument()
})
/*
Corrected on 2026-09-04: this test asserted THE DEFECT.
It said that without the permission the button "is not in the document", and that
is what the console's design rule forbids: *disabled, never hidden*. It is the same fix
made in `Settings`, and here it matters twice as much because **this screen
already has a legitimate case of disappearing**: with no files, `Delete All Logs`
goes because there is nothing to delete. The two reasons looked alike and now
*/
it('without the permission both stay, disabled and each on its own', async () => {
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
const { unmount } = render(
<Logs token="t" sub="View Logs" canDeleteLogs={false} canDeleteStats />,
)
expect(await screen.findByRole('button', { name: /Delete All Logs/ })).toBeDisabled()
expect(screen.getByRole('button', { name: 'Delete All Stats' })).toBeEnabled()
unmount()
render(<Logs token="t" sub="View Logs" canDeleteLogs canDeleteStats={false} />)
expect(await screen.findByRole('button', { name: 'Delete All Logs' })).toBeEnabled()
expect(screen.getByRole('button', { name: /Delete All Stats/ })).toBeDisabled()
})
/*
And the padlock says WHICH one is missing. The stats one asks for
`Dashboard.canDelete` (another screen's), which is what a redesign gets wrong.
*/
it('the padlock names the permission, and the stats one belongs to another section', async () => {
const user = userEvent.setup()
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
render(<Logs token="t" sub="View Logs" canDeleteLogs={false} canDeleteStats={false} />)
const stats = await screen.findByRole('button', { name: /Delete All Stats/ })
await user.hover(stats.parentElement!)
expect(await screen.findByText('Requires Dashboard: Delete')).toBeInTheDocument()
const logs = screen.getByRole('button', { name: /Delete All Logs/ })
await user.hover(logs.parentElement!)
expect(await screen.findByText('Requires Logs: Delete')).toBeInTheDocument()
})
/* With no files there is NO `Delete All Logs`, even with the permission to
spare: it is not about who you are, it is that there is nothing to delete.
It is the case that has to be told apart. */
it('and with no files it goes away because of its object, not because of a permission', async () => {
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: [] })
render(<Logs token="t" sub="View Logs" canDeleteLogs canDeleteStats />)
expect(await screen.findByText('No Log File Was Found')).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /Delete All Logs/ })).not.toBeInTheDocument()
})
})
describe('Logs › Query Logs: the form', () => {
it('it only offers the apps that declare `isQueryLogs`', async () => {
withApps([NO_QUERY_LOGS, APP])
render(<Logs token="t" sub="Query Logs" />)
const user = userEvent.setup()
const appName = await screen.findByLabelText('App Name')
expect(await optionsOf(user, appName)).toEqual(['Query Logs (Sqlite)'])
expect(valueShown(appName)).toBe('Query Logs (Sqlite)')
expect(valueShown(screen.getByLabelText('Class Path'))).toBe('QueryLogsSqlite.App')
})
it('the default values are those of the upstream form, not the server ones', async () => {
withApps([APP])
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
expect(screen.getByLabelText('Page Number')).toHaveValue(1)
expect(valueShown(screen.getByLabelText('Logs Per Page'))).toBe('10')
expect(valueShown(screen.getByLabelText('Order'))).toBe('Descending')
expect(screen.getByLabelText('From')).toHaveValue('')
expect(screen.getByLabelText('Domain')).toHaveValue('')
})
it('\"Logs Per Page\" is remembered in localStorage under the upstream key', async () => {
const user = userEvent.setup()
withApps([APP])
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await choose(user, screen.getByLabelText('Logs Per Page'), '100')
expect(localStorage.getItem('optQueryLogsEntriesPerPage')).toBe('100')
localStorage.removeItem('optQueryLogsEntriesPerPage')
})
it('\"Query\" sends the fourteen filters with the form values', async () => {
const user = userEvent.setup()
withApps([APP])
const spy = vi
.spyOn(api, 'queryLogs')
.mockResolvedValue({ kind: 'ok', data: { response: page() } } as never)
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.type(screen.getByLabelText('Domain'), 'home.test')
await choose(user, screen.getByLabelText('Protocol'), 'UDP')
await user.click(screen.getByRole('button', { name: 'Query' }))
expect(spy.mock.calls[0][1]).toEqual({
name: 'Query Logs (Sqlite)',
classPath: 'QueryLogsSqlite.App',
pageNumber: '1',
entriesPerPage: '10',
descendingOrder: 'true',
start: '',
end: '',
clientIpAddress: '',
protocol: 'Udp',
responseType: '',
rcode: '',
qname: 'home.test',
qtype: '',
qclass: '',
node: '',
})
})
it('with no query-logs app, \"Query\" alerts with the text that points at Apps', async () => {
const user = userEvent.setup()
withApps([NO_QUERY_LOGS])
const spy = vi.spyOn(api, 'queryLogs')
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.click(screen.getByRole('button', { name: 'Query' }))
expect(spy).not.toHaveBeenCalled()
expect(screen.getByText('Missing!')).toBeInTheDocument()
expect(
screen.getByText(
"Please install the 'Query Logs (Sqlite)' DNS App or any other DNS app that supports query logging feature from the Apps section.",
),
).toBeInTheDocument()
})
it('\"Export\" alerts with the SAME title but WITHOUT \"from the Apps section.\"', async () => {
const user = userEvent.setup()
withApps([NO_QUERY_LOGS])
const spy = vi.spyOn(api, 'exportLogsCsv')
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(spy).not.toHaveBeenCalled()
expect(
screen.getByText(
"Please install the 'Query Logs (Sqlite)' DNS App or any other DNS app that supports query logging feature.",
),
).toBeInTheDocument()
})
it('\"Export\" with a valid app calls the export endpoint', async () => {
const user = userEvent.setup()
withApps([APP])
const spy = vi.spyOn(api, 'exportLogsCsv').mockResolvedValue({ ok: true })
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(spy.mock.calls[0][1]).toMatchObject({
name: 'Query Logs (Sqlite)',
classPath: 'QueryLogsSqlite.App',
})
})
it('\"Reset\" returns the form to its default values', async () => {
const user = userEvent.setup()
withApps([APP])
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.type(screen.getByLabelText('Domain'), 'home.test')
await choose(user, screen.getByLabelText('Order'), 'Ascending')
await user.click(screen.getByRole('button', { name: 'Reset' }))
expect(screen.getByLabelText('Domain')).toHaveValue('')
expect(valueShown(screen.getByLabelText('Order'))).toBe('Descending')
})
it('\"Live Update\" pins page and order, empties the range and disables those four controls', async () => {
const user = userEvent.setup()
withApps([APP])
vi.spyOn(api, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: page() },
} as never)
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await choose(user, screen.getByLabelText('Order'), 'Ascending')
await user.type(screen.getByLabelText('From'), '2026-08-25T00:00')
await user.click(screen.getByLabelText('Live Update'))
expect(valueShown(screen.getByLabelText('Order'))).toBe('Descending')
expect(screen.getByLabelText('From')).toHaveValue('')
expect(screen.getByLabelText('Page Number')).toBeDisabled()
expect(screen.getByLabelText('Order')).toBeDisabled()
expect(screen.getByLabelText('From')).toBeDisabled()
expect(screen.getByLabelText('To')).toBeDisabled()
expect(screen.getByRole('button', { name: 'Query' })).toBeDisabled()
})
it('\"Live Update\" queries again every 2 s, and stopping cuts it', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
try {
const user = userEvent.setup({ delay: null })
withApps([APP])
const spy = vi.spyOn(api, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: page() },
} as never)
render(<Logs token="t" sub="Query Logs" />)
await vi.waitFor(() => expect(screen.queryByLabelText('App Name')).toBeInTheDocument())
await user.click(screen.getByLabelText('Live Update'))
await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(1))
await vi.advanceTimersByTimeAsync(2000)
await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(2))
await user.click(screen.getByLabelText('Live Update'))
await vi.advanceTimersByTimeAsync(6000)
expect(spy).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
})
describe('Logs › Query Logs: the table', () => {
it('it draws one row per entry, with the RTT and the formatted timestamp', async () => {
const user = userEvent.setup()
withApps([APP])
vi.spyOn(api, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: page() },
} as never)
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.click(screen.getByRole('button', { name: 'Query' }))
expect(await screen.findByText('github.com')).toBeInTheDocument()
expect(screen.getByText('(12.82 ms)')).toBeInTheDocument()
expect(screen.getByText('A 140.82.121.3')).toBeInTheDocument()
})
it('the timestamp is two unbreakable pieces, date and time, so a narrow table breaks it only between them', async () => {
const user = userEvent.setup()
withApps([APP])
vi.spyOn(api, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: page() },
} as never)
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.click(screen.getByRole('button', { name: 'Query' }))
const row = (await screen.findByText('github.com')).closest('tr')!
const stamp = row.children[1]
const pieces = [...stamp.children].map((c) => c.textContent)
expect(pieces).toHaveLength(2)
expect(pieces[0]).toMatch(/^\d{4}-\d{2}-\d{2}$/)
expect(pieces[1]).toMatch(/^\d{2}:\d{2}:\d{2}$/)
expect(stamp.textContent).toBe(`${pieces[0]} ${pieces[1]}`)
})
/* The cells are cut to one line by CSS; the value itself is never shortened. */
it('Domain, Answer and the client address carry their whole value, in the text and in the title', async () => {
const user = userEvent.setup()
withApps([APP])
const qname = 'rr1---sn-a5mekn7z.googlevideo.com'
const answer = 'CNAME login.mso.msidentity.com., AAAA 2603:1026:3000:108::7, AAAA 2603:1027:1:108::a'
const client = '2001:db8:85a3:0000:0000:8a2e:0370:7334'
vi.spyOn(api, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: page({ entries: [{ ...ENTRY, qname, answer, clientIpAddress: client }] }) },
} as never)
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.click(screen.getByRole('button', { name: 'Query' }))
for (const value of [qname, answer, client]) {
const cell = await screen.findByText(value)
expect(cell).toHaveAttribute('title', value)
}
})
/* The colour moved from the row to a pill around the response type; the row
itself is neutral, and the legend draws the same pill. */
it('the row colour is painted on the Response Type pill, and the legend uses the same pills', async () => {
const user = userEvent.setup()
withApps([APP])
vi.spyOn(api, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: page({ entries: [{ ...ENTRY, rcode: 'NxDomain', responseType: 'Blocked' }] }) },
} as never)
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.click(screen.getByRole('button', { name: 'Query' }))
const pill = (await screen.findAllByText('Blocked')).find((e) => e.closest('td') != null)!
const cls = rowClass({ ...ENTRY, rcode: 'NxDomain', responseType: 'Blocked' })
expect(pill.closest('tr')).toHaveClass(cls)
const legend = LEGEND_ROWS.find((e) => e.name === 'Blocked')!
const legendPill = screen.getAllByText('Blocked').find((e) => e.closest('td') == null)!
expect(legendPill).toHaveClass(legend.cls)
expect(legendPill.className.replace(legend.cls, '').trim()).toBe(pill.className)
})
it('a response with no data leaves the counter at \"0 logs\"', async () => {
const user = userEvent.setup()
withApps([APP])
vi.spyOn(api, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: page({ entries: [], totalEntries: 0 }) },
} as never)
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.click(screen.getByRole('button', { name: 'Query' }))
expect(await screen.findAllByText('0 logs')).toHaveLength(2)
})
it('the root is written with a dot and the null fields are left blank', async () => {
const user = userEvent.setup()
withApps([APP])
vi.spyOn(api, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: {
response: page({
entries: [
{
...ENTRY,
rowNumber: 1,
qname: '',
qtype: null,
qclass: null,
answer: null,
responseRtt: undefined,
},
],
}),
},
} as never)
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.click(screen.getByRole('button', { name: 'Query' }))
const row = within(await screen.findByRole('row', { name: /127\.0\.0\.1/ }))
expect(row.getByText('.')).toBeInTheDocument()
// Without `responseRtt` the bracket with the milliseconds is not drawn.
expect(screen.queryByText(/ ms\)/)).not.toBeInTheDocument()
})
it('\"Last\" is asked for with pageNumber -1, which is how upstream reaches the last', async () => {
const user = userEvent.setup()
withApps([APP])
const spy = vi.spyOn(api, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: page({ pageNumber: 1, totalPages: 5, totalEntries: 100 }) },
} as never)
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.click(screen.getByRole('button', { name: 'Query' }))
await screen.findByText('github.com')
await user.click(screen.getByRole('button', { name: 'Last' }))
expect(spy.mock.calls.at(-1)![1].pageNumber).toBe('-1')
})
it('a server error comes out as an alert and does not wipe the previous table', async () => {
const user = userEvent.setup()
withApps([APP])
vi.spyOn(api, 'queryLogs').mockResolvedValue({
kind: 'error',
message: "Requested value 'ZZZ' was not found.",
} as never)
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
await user.click(screen.getByRole('button', { name: 'Query' }))
expect(await screen.findByText("Requested value 'ZZZ' was not found.")).toBeInTheDocument()
})
})
describe('Query Logs: pure pieces', () => {
it('the counter replicates the upstream format', () => {
expect(
statusText({
pageNumber: 2,
totalPages: 5,
totalEntries: 48312,
entries: [
{ ...ENTRY, rowNumber: 26 },
{ ...ENTRY, rowNumber: 50 },
],
}),
).toBe('26-50 (2) of 48312 logs (page 2 of 5)')
expect(statusText({ pageNumber: 1, totalPages: 0, totalEntries: 0, entries: [] })).toBe(
'0 logs',
)
})
it('the page window is ten wide, centred on the current one', () => {
expect(pageRange(1, 3)).toEqual([1, 2, 3])
expect(pageRange(1, 100)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
expect(pageRange(20, 100)).toEqual([15, 16, 17, 18, 19, 20, 21, 22, 23, 24])
// At the end, the window slides backwards so it keeps being ten wide.
expect(pageRange(100, 100)).toEqual([91, 92, 93, 94, 95, 96, 97, 98, 99, 100])
})
it('the row colour is decided by the RCODE and, within it, the response type', () => {
const c = (rcode: string, responseType: string) =>
rowClass({ ...ENTRY, rcode, responseType })
expect(c('ServerFailure', 'Recursive')).not.toBe('')
expect(c('NxDomain', 'Blocked')).toBe(c('NoError', 'UpstreamBlockedCached'))
expect(c('NxDomain', 'Authoritative')).not.toBe(c('NxDomain', 'Blocked'))
expect(c('NoError', 'Authoritative')).not.toBe(c('NoError', 'Recursive'))
expect(c('NoError', 'Whatever')).toBe('')
})
})
/*
The legend and the colour code: two lists that have to say the same thing.
This screen got as far as seven row colours and NO legend, against the design
rule that every server label carries its entry. The guard is not that the legend
exists: it is that **it cannot fall short**. If somebody adds a case to
`rowClass` and not to `LEGEND_ROWS`, this falls over.
*/
describe('the row colour code', () => {
it('the legend covers every class `rowClass` can return', () => {
const cases: QueryLogEntry[] = [
{ rowNumber: 1, rcode: 'ServerFailure', responseType: 'Recursive' },
{ rowNumber: 2, rcode: 'NxDomain', responseType: 'Blocked' },
{ rowNumber: 3, rcode: 'NxDomain', responseType: 'Recursive' },
{ rowNumber: 4, rcode: 'Refused', responseType: 'Authoritative' },
{ rowNumber: 5, rcode: 'NoError', responseType: 'Authoritative' },
{ rowNumber: 6, rcode: 'NoError', responseType: 'Recursive' },
{ rowNumber: 7, rcode: 'NoError', responseType: 'Cached' },
] as QueryLogEntry[]
const painted = new Set(cases.map((c) => rowClass(c)).filter(Boolean))
const explained = new Set(LEGEND_ROWS.map((e) => e.cls))
expect(painted.size).toBe(7)
for (const c of painted) expect(explained).toContain(c)
})
/* And the other way round: a legend entry that no longer paints anything is a promise. */
it('and no legend entry is left over', () => {
expect(LEGEND_ROWS).toHaveLength(7)
expect(new Set(LEGEND_ROWS.map((e) => e.name)).size).toBe(7)
})
/* The condition is written the way `rowClass` EVALUATES it. `Blocked` and
`NX Domain` share an RCODE and are told apart by the response type; a legend
that said only "NXDOMAIN" for both would not explain why they are two colours. */
it('the condition tells `Blocked` from `NX Domain`', () => {
const b = LEGEND_ROWS.find((e) => e.name === 'Blocked')!.when
const n = LEGEND_ROWS.find((e) => e.name === 'NX Domain')!.when
expect(b).not.toBe(n)
expect(b).toMatch(/blocked/i)
expect(n).toMatch(/without blocking/i)
})
})
/*
logs.js:101-114 and 144-154: a node change re-reads View Logs' list from the new
node and opens the same file there, or closes the viewer if that node has no file
by that name; in Query Logs it runs the query again when there are results.
*/
describe('Logs: changing the cluster node', () => {
const NODES = [
{ name: 'a.example', type: 'Primary' },
{ name: 'b.example', type: 'Secondary' },
]
async function pickB(user: ReturnType<typeof userEvent.setup>) {
await choose(user, screen.getByLabelText('Cluster Node'), 'b.example (secondary)')
}
it('View Logs opens the same file on the new node', async () => {
const user = userEvent.setup()
vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
const text = vi.spyOn(api, 'downloadLogText').mockResolvedValue('[2026-08-26] Logging started.')
render(<Logs token="t" sub="View Logs" nodes={NODES} clusterInitialised />)
await user.click(await screen.findByText('2026-08-26'))
await screen.findByText('[2026-08-26] Logging started.')
await pickB(user)
await vi.waitFor(() => expect(text).toHaveBeenLastCalledWith('t', '2026-08-26', 'b.example'))
})
it('View Logs closes the viewer when the new node has no such file', async () => {
const user = userEvent.setup()
const list = vi.spyOn(api, 'listLogFiles').mockResolvedValue({ kind: 'ok', data: FILES })
vi.spyOn(api, 'downloadLogText').mockResolvedValue('[2026-08-26] Logging started.')
render(<Logs token="t" sub="View Logs" nodes={NODES} clusterInitialised />)
await user.click(await screen.findByText('2026-08-26'))
await screen.findByText('[2026-08-26] Logging started.')
list.mockResolvedValue({ kind: 'ok', data: [{ fileName: '2026-08-20', size: '1 KB' }] })
await pickB(user)
await screen.findByText('2026-08-20')
expect(screen.queryByText('[2026-08-26] Logging started.')).not.toBeInTheDocument()
})
it('Query Logs runs the query again on the new node when results are showing', async () => {
const user = userEvent.setup()
withApps([APP])
const spy = vi
.spyOn(api, 'queryLogs')
.mockResolvedValue({ kind: 'ok', data: { response: page() } } as never)
render(<Logs token="t" sub="Query Logs" nodes={NODES} clusterInitialised />)
await screen.findByLabelText('App Name')
await user.click(screen.getByRole('button', { name: 'Query' }))
await screen.findByText('github.com')
await pickB(user)
await vi.waitFor(() => expect(spy).toHaveBeenCalledTimes(2))
expect(spy.mock.calls[1][1]).toMatchObject({ node: 'b.example' })
})
it('Query Logs does not query by itself when there are no results yet', async () => {
const user = userEvent.setup()
withApps([APP])
const spy = vi.spyOn(api, 'queryLogs')
render(<Logs token="t" sub="Query Logs" nodes={NODES} clusterInitialised />)
await screen.findByLabelText('App Name')
await pickB(user)
await new Promise((r) => setTimeout(r, 50))
expect(spy).not.toHaveBeenCalled()
})
})
+100
View File
@@ -0,0 +1,100 @@
import { useState } from 'react'
import { SubTabs } from '../../ui/SubTabs'
import { ClusterNodeSelect } from '../../ui/ClusterNodeSelect'
import { QueryLogs } from './QueryLogs'
import { ViewLogs } from './ViewLogs'
import type { QueryLogsRequest } from '../../app/handoff'
/*
Logs. Two sub-tabs, with upstream's literal labels (index.html:3243-3244): "View
Logs" and "Query Logs".
As in DHCP, the sub-navigation lives in the Shell's side panel and arrives
through the `sub` prop. The two sub-tabs are independent screens: in upstream
they are `refreshLogFilesList` and `refreshQueryLogsTab`, with no shared state.
The section's permissions are NOT a single one. The whole tab shows with
`Logs.canView`, but inside there are three delete actions and one of them belongs
to another section: "delete all stats" deletes the Dashboard's statistics and
asks for `Dashboard.canDelete` (`WebServiceLogsApi.cs:135`).
*/
export const SUB_TABS = ['View Logs', 'Query Logs'] as const
export type SubTab = (typeof SUB_TABS)[number]
export interface LogsProps {
/** The cluster nodes, for the node selector. */
nodes?: { name: string; type: string }[]
clusterInitialised?: boolean
token: string | null
/** The active sub-tab, the one the Shell's side panel marks. */
sub?: string | null
/** It exists for symmetry with Settings; this screen never forces a change. */
onSubChange?: (sub: SubTab) => void
/** `Logs.canDelete`: deleting a log file and deleting them all. */
canDeleteLogs?: boolean
/** `Dashboard.canDelete`: deleting all the statistics. */
canDeleteStats?: boolean
/** The cluster node. Empty means "this server". */
node?: string
/** `showQueryLogs` from another screen: Query Logs filtered and run on arrival. */
request?: QueryLogsRequest
}
export function Logs({
onSubChange,
nodes = [],
clusterInitialised = false,
token,
sub,
canDeleteLogs = true,
canDeleteStats = true,
request,
}: LogsProps) {
/* Which node this screen reads. Upstream mounts a selector here
and the parameter was already travelling empty. `showQueryLogs` picks one
only when it was given one (logs.js:634-635). */
const [node, setNode] = useState<string>(() => request?.node ?? '')
const requested = (sub ?? 'View Logs') as SubTab
const active: SubTab = SUB_TABS.includes(requested) ? requested : 'View Logs'
/*
The bar is built once here and handed to whichever sub-screen is drawn: the same
object on both, so they cannot drift apart.
*/
const tabs = (
<SubTabs
label="Logs sections"
section="logs"
tabs={SUB_TABS}
active={active}
onChoose={(t) => onSubChange?.(t as SubTab)}
/>
)
return (
<>
{/* Upstream mounts one here (`optDhcpClusterNode` / `optLogsClusterNode`).
It draws nothing without a cluster, so it costs a single-server install
nothing. */}
<ClusterNodeSelect
nodes={nodes}
initialised={clusterInitialised}
value={node}
onChange={setNode}
label="Cluster Node"
/>
{active === 'Query Logs' ? (
<QueryLogs tabs={tabs} token={token} node={node} request={request} />
) : (
<ViewLogs tabs={tabs}
token={token}
node={node}
canDeleteLogs={canDeleteLogs}
canDeleteStats={canDeleteStats}
/>
)}
</>
)
}
+213
View File
@@ -0,0 +1,213 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Logs } from './Logs'
import * as logs from '../../api/logs'
import * as apps from '../../api/apps'
import type { InstalledApp } from '../../api/apps'
import { activeFilters, defaultFilters, FOLD_QUERY, LEGEND_ROWS } from './QueryLogs'
import { choose } from '../../test/dropdown'
/*
The filter panel and the legend below 960 px (FOLD_QUERY in QueryLogs.tsx): the
panel folds to its title and lists what is set, and nothing it sends changes.
*/
const APP = {
name: 'Query Logs (Sqlite)',
dnsApps: [{ classPath: 'QueryLogsSqlite.App', isQueryLogs: true }],
} as unknown as InstalledApp
const NO_QUERY_LOGS = {
name: 'NO DATA',
dnsApps: [{ classPath: 'NoData.App', isQueryLogs: false }],
} as unknown as InstalledApp
function setUp(list: InstalledApp[] = [APP]) {
vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: { apps: list } } } as never)
return vi.spyOn(logs, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: { pageNumber: 1, totalPages: 1, totalEntries: 0, entries: [] } },
} as never)
}
/** A window this wide or narrower matches `FOLD_QUERY`; jsdom has no matchMedia. */
function windowWidth(narrow: boolean) {
const matchMedia = (media: string) => ({
media,
matches: narrow && media === FOLD_QUERY,
addEventListener: () => {},
removeEventListener: () => {},
})
Object.defineProperty(window, 'matchMedia', { value: matchMedia, configurable: true, writable: true })
}
afterEach(() => {
vi.restoreAllMocks()
delete (window as { matchMedia?: unknown }).matchMedia
})
const panel = () => document.getElementById('ql-filters') as HTMLElement
describe('Query Logs: what the folded panel lists', () => {
const reset = defaultFilters('Query Logs (Sqlite)', 'QueryLogsSqlite.App')
it('nothing, when the form is what Reset gives', () => {
expect(activeFilters(reset, reset)).toEqual([])
})
it('every field that differs, in the form order, with the value the field shows', () => {
const f = {
...reset,
start: '2026-10-16T09:00',
descendingOrder: 'false',
qname: '*.com',
protocol: 'Udp',
responseType: 'Blocked',
rcode: 'NxDomain',
}
expect(activeFilters(f, reset)).toEqual([
{ label: 'From', value: '2026-10-16 09:00' },
{ label: 'Order', value: 'Ascending' },
{ label: 'Domain', value: '*.com' },
{ label: 'Protocol', value: logs.PROTOCOLS.find((p) => p.value === 'Udp')?.label },
{ label: 'Response Type', value: logs.RESPONSE_TYPES.find((p) => p.value === 'Blocked')?.label },
{ label: 'RCODE', value: logs.RCODES.find((p) => p.value === 'NxDomain')?.label },
])
})
it('names each filter by the label its field carries in the form', async () => {
setUp()
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
const changed = Object.fromEntries(Object.keys(reset).map((k) => [k, 'x'])) as unknown as typeof reset
const labels = activeFilters(changed, reset).map((a) => a.label)
expect(labels).toHaveLength(14)
for (const label of labels) expect(screen.getByLabelText(label)).toBeInTheDocument()
})
})
describe('Query Logs: the filter panel above 960', () => {
it('is always open, under a heading and with no toggle', async () => {
windowWidth(false)
setUp()
render(<Logs token="t" sub="Query Logs" />)
await screen.findByLabelText('App Name')
expect(screen.getByRole('heading', { name: 'Filters' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'Filters' })).toBeNull()
expect(panel()).not.toHaveAttribute('hidden')
})
})
describe('Query Logs: the filter panel below 960', () => {
it('starts folded to its title, which opens and closes it', async () => {
windowWidth(true)
const user = userEvent.setup()
setUp()
render(<Logs token="t" sub="Query Logs" />)
const toggle = await screen.findByRole('button', { name: 'Filters' })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(panel()).toHaveAttribute('hidden')
await user.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
expect(panel()).not.toHaveAttribute('hidden')
})
it('Query sends the same filters and folds the panel, which then says what is set', async () => {
windowWidth(true)
const user = userEvent.setup()
const query = setUp()
render(<Logs token="t" sub="Query Logs" />)
await user.click(await screen.findByRole('button', { name: 'Filters' }))
await user.type(screen.getByLabelText('Domain'), 'home.test')
await choose(user, screen.getByLabelText('Protocol'), 'UDP')
await user.click(screen.getByRole('button', { name: 'Query' }))
expect(query.mock.calls[0][1]).toMatchObject({ qname: 'home.test', protocol: 'Udp', pageNumber: '1' })
const toggle = screen.getByRole('button', { name: 'Filters' })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
expect(panel()).toHaveAttribute('hidden')
const set = document.getElementById('ql-filters-set') as HTMLElement
expect(toggle).toHaveAttribute('aria-describedby', 'ql-filters-set')
expect(set).toHaveTextContent('Domain: home.test')
expect(set).toHaveTextContent('Protocol: UDP')
// The fields keep their values while folded.
expect(screen.getByLabelText('Domain')).toHaveValue('home.test')
})
it('with nothing set, the folded panel lists nothing', async () => {
windowWidth(true)
setUp()
render(<Logs token="t" sub="Query Logs" />)
const toggle = await screen.findByRole('button', { name: 'Filters' })
expect(document.getElementById('ql-filters-set')).toBeNull()
expect(toggle).not.toHaveAttribute('aria-describedby')
})
it('a validation that fails opens the panel and focuses its field, as upstream does', async () => {
windowWidth(true)
const user = userEvent.setup()
const query = setUp([NO_QUERY_LOGS])
render(<Logs token="t" sub="Query Logs" />)
await screen.findByRole('button', { name: 'Filters' })
await user.click(screen.getByRole('button', { name: 'Query' }))
expect(query).not.toHaveBeenCalled()
expect(
screen.getByText(
"Please install the 'Query Logs (Sqlite)' DNS App or any other DNS app that supports query logging feature from the Apps section.",
),
).toBeInTheDocument()
expect(panel()).not.toHaveAttribute('hidden')
expect(screen.getByLabelText('App Name')).toHaveFocus()
// Export validates the same field, and opens the panel the same way.
await user.click(screen.getByRole('button', { name: 'Filters' }))
expect(panel()).toHaveAttribute('hidden')
await user.click(screen.getByRole('button', { name: 'Export' }))
expect(panel()).not.toHaveAttribute('hidden')
expect(screen.getByLabelText('App Name')).toHaveFocus()
})
it('a jump from the Dashboard arrives folded, and the panel says which filters it brought', async () => {
windowWidth(true)
setUp()
const filters = { start: '2026-10-15T21:04:00', responseType: 'Cached' }
render(<Logs token="t" sub="Query Logs" request={{ domain: 'example.com', clientIp: null, node: '', filters }} />)
const set = await vi.waitFor(() => {
const el = document.getElementById('ql-filters-set')
if (el == null) throw new Error('not yet')
return el
})
expect(set).toHaveTextContent('From: 2026-10-15 21:04:00')
expect(set).toHaveTextContent('Domain: example.com')
expect(set).toHaveTextContent(`Response Type: ${logs.RESPONSE_TYPES.find((r) => r.value === 'Cached')?.label}`)
})
})
describe('Query Logs: the legend below 960', () => {
it('keeps the seven pills, and its title opens the conditions', async () => {
windowWidth(true)
const user = userEvent.setup()
setUp()
render(<Logs token="t" sub="Query Logs" />)
await user.click(await screen.findByRole('button', { name: 'Query' }))
const toggle = await screen.findByRole('button', { name: 'Row colours' })
const legend = toggle.parentElement as HTMLElement
for (const row of LEGEND_ROWS) {
expect(within(legend).getByText(row.name)).toBeInTheDocument()
expect(within(legend).getByText(row.when)).toBeInTheDocument()
}
expect(toggle).toHaveAttribute('aria-expanded', 'false')
await user.click(toggle)
expect(toggle).toHaveAttribute('aria-expanded', 'true')
})
})
+75
View File
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Logs } from './Logs'
import * as logs from '../../api/logs'
import * as apps from '../../api/apps'
import type { InstalledApp } from '../../api/apps'
import { toIso } from './dates'
/*
The Dashboard charts' jump (deviation 7): the same arrival as a row menu's
`showQueryLogs`, with a time window or a value in this form's own fields.
*/
afterEach(() => vi.restoreAllMocks())
const APP = {
name: 'Query Logs (Sqlite)',
dnsApps: [{ classPath: 'QueryLogsSqlite.App', isQueryLogs: true }],
} as unknown as InstalledApp
function setUp(list: InstalledApp[] = [APP]) {
vi.spyOn(apps, 'listApps').mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: { apps: list } } } as never)
return vi.spyOn(logs, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: { pageNumber: 1, totalPages: 1, totalEntries: 0, entries: [] } },
} as never)
}
describe('Query Logs: arriving from a Dashboard chart', () => {
it('fills From, To and the value, and queries once with them', async () => {
const query = setUp()
const filters = { start: '2026-10-15T21:04:00', end: '2026-10-15T21:04:59', responseType: 'Cached' }
render(<Logs token="t" sub="Query Logs" request={{ domain: null, clientIp: null, node: 'ns2', filters }} />)
await vi.waitFor(() => expect(query).toHaveBeenCalledTimes(1))
expect(query.mock.calls[0][1]).toMatchObject({
start: toIso(filters.start),
end: toIso(filters.end),
responseType: 'Cached',
protocol: '',
qtype: '',
qname: '',
node: 'ns2',
})
/* The field normalises how it writes the value (no ":00", or ".000"): what
matters is that it is the same instant. */
expect(toIso((screen.getByLabelText('From') as HTMLInputElement).value)).toBe(toIso(filters.start))
expect(toIso((screen.getByLabelText('To') as HTMLInputElement).value)).toBe(toIso(filters.end))
expect(screen.getByLabelText('Response Type')).toHaveTextContent('Cached')
})
it('a protocol and a type land in their own fields', async () => {
const query = setUp()
render(<Logs token="t" sub="Query Logs" request={{ domain: null, clientIp: null, node: '', filters: { protocol: 'Tcp', qtype: 'AAAA' } }} />)
await vi.waitFor(() => expect(query).toHaveBeenCalledTimes(1))
expect(query.mock.calls[0][1]).toMatchObject({ protocol: 'Tcp', qtype: 'AAAA', start: '', end: '' })
})
it("a count card's response code lands in RCODE, with no time window (upstream's v15.6.2 jump)", async () => {
const query = setUp()
render(<Logs token="t" sub="Query Logs" request={{ domain: null, clientIp: null, node: '', filters: { rcode: 'NxDomain' } }} />)
await vi.waitFor(() => expect(query).toHaveBeenCalledTimes(1))
expect(query.mock.calls[0][1]).toMatchObject({ rcode: 'NxDomain', responseType: '', start: '', end: '' })
expect(screen.getByLabelText('RCODE')).toHaveTextContent('NX Domain')
})
it('without a logging app it says what a row menu jump says, and queries nothing', async () => {
const query = setUp([])
render(<Logs token="t" sub="Query Logs" request={{ domain: null, clientIp: null, node: '', filters: { qtype: 'A' } }} />)
expect(await screen.findByText(
"Please install the 'Query Logs (Sqlite)' DNS App or any other DNS app that supports query logging feature from the Apps section.",
)).toBeInTheDocument()
expect(query).not.toHaveBeenCalled()
})
})
+270
View File
@@ -0,0 +1,270 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactNode } from 'react'
import { Logs } from './Logs'
import * as logs from '../../api/logs'
import * as apps from '../../api/apps'
import * as client from '../../api/client'
import type { InstalledApp } from '../../api/apps'
import type { QueryLogEntry } from '../../api/logs'
import { HandoffContext, type Handoff } from '../../app/handoff'
/*
Each Query Logs row's menu (logs.js:539-552) and the arrival from the Dashboard's
`showQueryLogs` (logs.js:624-655).
*/
afterEach(() => vi.restoreAllMocks())
const APP: InstalledApp = {
name: 'Query Logs (Sqlite)',
description: 'Logs all incoming DNS requests.',
version: '8.0',
dnsApps: [
{
classPath: 'QueryLogsSqlite.App',
description: 'Logs queries.',
recordDataTemplate: null,
isAppRecordRequestHandler: false,
isRequestController: false,
isAuthoritativeRequestHandler: false,
isRequestBlockingHandler: false,
isQueryLogger: true,
isQueryLogs: true,
isPostProcessor: false,
},
],
}
function entry(rowNumber: number, responseType: string, extra: Partial<QueryLogEntry> = {}): QueryLogEntry {
return {
rowNumber,
timestamp: '2026-08-26T05:32:14Z',
clientIpAddress: '10.0.0.7',
protocol: 'Udp',
responseType,
rcode: 'NoError',
qname: `r${rowNumber}.example`,
qtype: 'AAAA',
qclass: 'IN',
answer: null,
...extra,
}
}
const ENTRIES = [
entry(1, 'Recursive'),
entry(2, 'Blocked'),
entry(3, 'UpstreamBlocked'),
entry(4, 'upstreamblockedcached'),
entry(5, 'Cached'),
entry(6, 'Authoritative', { qname: null, qtype: null }),
]
function withApps(list: InstalledApp[] = [APP]) {
return vi
.spyOn(apps, 'listApps')
.mockResolvedValue({ kind: 'ok', data: { status: 'ok', response: { apps: list } } } as never)
}
function withEntries(entries: QueryLogEntry[] = ENTRIES) {
return vi.spyOn(logs, 'queryLogs').mockResolvedValue({
kind: 'ok',
data: { response: { pageNumber: 1, totalPages: 1, totalEntries: entries.length, entries } },
} as never)
}
function nav() {
return {
queryDnsServer: vi.fn<Handoff['queryDnsServer']>(),
showQueryLogs: vi.fn<Handoff['showQueryLogs']>(),
}
}
function inShell(ui: ReactNode, handoff: Handoff = nav()) {
return render(<HandoffContext.Provider value={handoff}>{ui}</HandoffContext.Provider>)
}
async function queried() {
await userEvent.click(await screen.findByRole('button', { name: 'Query' }))
await screen.findByText('r1.example')
}
async function openRow(n: number) {
await userEvent.click(screen.getByRole('button', { name: `Actions for row ${n}` }))
return screen.getByRole('menu', { name: `Actions for row ${n}` })
}
describe('Query Logs: the row menu', () => {
it('every row offers Query DNS Server and then Allow or Block by its response type', async () => {
withApps()
withEntries()
inShell(<Logs token="t" sub="Query Logs" />)
await queried()
const expected: Record<number, string> = {
1: 'Block Domain',
2: 'Allow Domain',
3: 'Allow Domain',
4: 'Allow Domain',
5: 'Block Domain',
6: 'Block Domain',
}
for (const [n, verb] of Object.entries(expected)) {
const menu = await openRow(Number(n))
const items = within(menu).getAllByRole('menuitem').map((i) => i.textContent)
expect(items).toEqual(['Query DNS Server', verb])
await userEvent.keyboard('{Escape}')
}
})
it('Query DNS Server hands over the domain, the type and the node of the screen', async () => {
withApps()
withEntries()
const handoff = nav()
inShell(<Logs token="t" sub="Query Logs" />, handoff)
await queried()
await openRow(1)
await userEvent.click(screen.getByRole('menuitem', { name: 'Query DNS Server' }))
expect(handoff.queryDnsServer).toHaveBeenCalledWith('r1.example', 'AAAA', '')
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
})
it('a row without a name or a type hands over an empty domain and the text "null", as upstream writes it', async () => {
withApps()
withEntries()
const handoff = nav()
inShell(<Logs token="t" sub="Query Logs" />, handoff)
await queried()
await openRow(6)
await userEvent.click(screen.getByRole('menuitem', { name: 'Query DNS Server' }))
expect(handoff.queryDnsServer).toHaveBeenCalledWith('', 'null', '')
})
it('Allow Domain is blocked/delete then allowed/add, and the alert comes out on the page', async () => {
withApps()
withEntries()
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok' } } as never)
inShell(<Logs token="t" sub="Query Logs" />)
await queried()
await openRow(2)
await userEvent.click(screen.getByRole('menuitem', { name: 'Allow Domain' }))
expect(await screen.findByText("Domain 'r2.example' was added to Allowed Zone successfully.")).toBeInTheDocument()
expect(screen.getByText('Allowed!')).toBeInTheDocument()
const paths = spy.mock.calls.map((c) => c[0])
expect(paths).toEqual(['blocked/delete', 'allowed/add'])
expect(spy.mock.calls[0][1]?.body).toEqual({ domain: 'r2.example' })
expect(spy.mock.calls[1][1]?.body).toEqual({ domain: 'r2.example' })
})
it('Block Domain is allowed/delete then blocked/add, with its own sentence', async () => {
withApps()
withEntries()
const spy = vi.spyOn(client, 'apiRequest').mockResolvedValue({ kind: 'ok', data: { status: 'ok' } } as never)
inShell(<Logs token="t" sub="Query Logs" />)
await queried()
await openRow(1)
await userEvent.click(screen.getByRole('menuitem', { name: 'Block Domain' }))
expect(await screen.findByText("Domain 'r1.example' was added to Blocked Zone successfully.")).toBeInTheDocument()
expect(screen.getByText('Blocked!')).toBeInTheDocument()
expect(spy.mock.calls.map((c) => c[0])).toEqual(['allowed/delete', 'blocked/add'])
expect(spy.mock.calls[1][1]?.body).toEqual({ domain: 'r1.example' })
})
it('when the first call fails the second is never made and the server message is shown', async () => {
withApps()
withEntries()
const spy = vi
.spyOn(client, 'apiRequest')
.mockResolvedValue({ kind: 'error', message: 'Access was denied.' } as never)
inShell(<Logs token="t" sub="Query Logs" />)
await queried()
await openRow(1)
await userEvent.click(screen.getByRole('menuitem', { name: 'Block Domain' }))
expect(await screen.findByText('Access was denied.')).toBeInTheDocument()
expect(spy.mock.calls.map((c) => c[0])).toEqual(['allowed/delete'])
})
it('with no entries there is no row menu at all', async () => {
withApps()
withEntries([])
inShell(<Logs token="t" sub="Query Logs" />)
await userEvent.click(await screen.findByRole('button', { name: 'Query' }))
expect(await screen.findAllByText('0 logs')).not.toHaveLength(0)
expect(screen.queryByRole('button', { name: /^Actions for row/ })).not.toBeInTheDocument()
})
})
describe('Query Logs: arriving from showQueryLogs', () => {
it('the domain is filled in and the query runs on arrival, with the reset form', async () => {
withApps()
const query = withEntries()
inShell(<Logs token="t" sub="Query Logs" request={{ domain: 'github.com', clientIp: null, node: '' }} />)
await screen.findByText('r1.example')
expect(query).toHaveBeenCalledTimes(1)
expect(query.mock.calls[0][1]).toMatchObject({
name: 'Query Logs (Sqlite)',
classPath: 'QueryLogsSqlite.App',
pageNumber: '1',
entriesPerPage: '10',
descendingOrder: 'true',
qname: 'github.com',
clientIpAddress: '',
responseType: '',
node: '',
})
expect(screen.getByLabelText('Domain')).toHaveValue('github.com')
expect(screen.getByLabelText('Client IP Address')).toHaveValue('')
})
it('a client fills the client field and leaves the domain empty', async () => {
withApps()
const query = withEntries()
inShell(<Logs token="t" sub="Query Logs" request={{ domain: null, clientIp: '10.0.0.7', node: '' }} />)
await screen.findByText('r1.example')
expect(query.mock.calls[0][1]).toMatchObject({ qname: '', clientIpAddress: '10.0.0.7' })
expect(screen.getByLabelText('Client IP Address')).toHaveValue('10.0.0.7')
})
it('a node in the request is the node the query asks', async () => {
withApps()
const query = withEntries()
inShell(<Logs token="t" sub="Query Logs" request={{ domain: 'github.com', clientIp: null, node: 'node2.example' }} />)
await screen.findByText('r1.example')
expect(query.mock.calls[0][1]).toMatchObject({ qname: 'github.com', node: 'node2.example' })
})
it('without the logging app it says what upstream says when Query runs', async () => {
withApps([])
const query = withEntries()
inShell(<Logs token="t" sub="Query Logs" request={{ domain: 'github.com', clientIp: null, node: '' }} />)
expect(
await screen.findByText(
"Please install the 'Query Logs (Sqlite)' DNS App or any other DNS app that supports query logging feature from the Apps section.",
),
).toBeInTheDocument()
expect(query).not.toHaveBeenCalled()
})
it('without a request nothing is queried on arrival', async () => {
withApps()
const query = withEntries()
inShell(<Logs token="t" sub="Query Logs" />)
await screen.findByRole('button', { name: 'Query' })
await waitFor(() => expect(screen.getByLabelText('Domain')).toHaveValue(''))
expect(query).not.toHaveBeenCalled()
})
})
File diff suppressed because it is too large. Load diff
+367
View File
@@ -0,0 +1,367 @@
import type { ReactNode } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import {
deleteAllLogs,
deleteLog,
downloadLogText,
listLogFiles,
openLogDownload,
type LogFile,
} from '../../api/logs'
import { deleteAllStats } from '../../api/dashboard'
import { Confirm } from '../../ui/Confirm'
import { Raw } from '../../ui/Raw'
import { LogText, stampWidth } from './LogText'
import { PermissionButton } from '../../ui/PermissionButton'
import { Button } from '../../ui/Button'
import { SectionHeader } from '../../ui/SectionHeader'
import {Empty, Loading} from '../../ui/Empty'
import styles from './Logs.module.css'
import { Body, Panel } from '../../ui/Panel'
import { noticeFromFailure, type Notice } from '../../lib/notice'
import { Notifier } from '../../ui/Notifier'
/*
Logs › View Logs (logs.js:105-268).
Four things from upstream that govern this screen:
1. **The three delete actions are NOT of the same section.** "delete all logs"
and deleting a file ask for `Logs.Delete`; "delete all stats" deletes the
Dashboard's statistics and asks for `Dashboard.Delete`
(`WebServiceLogsApi.cs:107,123,135`). They sit together on the screen and
they are different permissions.
2. **"delete all stats" is ALWAYS offered; "delete all logs" only if there are
files** (logs.js:119-127). With no files it says "No Log File Was Found".
3. **The viewer asks for only 2 MB** and the "Download" button for the whole
file; they are two different calls to the same endpoint.
4. **A server error is drawn INSIDE the viewer**, formatted as JSON, instead of
as an alert (logs.js:170-172). It is not an oversight: the endpoint returns
text and the only way to show the error is in that same space.
How the file is read is design, and it is ours (upstream draws a plain `<pre>`
that grows with the page, index.html:3272):
- **The viewer takes the window's height.** It used to be a box capped at 520 px:
at 1440x980 it ended at y=751 and left 229 px of empty page below it, and at
1024x768 it pushed the page into a 99 px scroll. Now the screen marks itself
`data-fills-window`, the Shell makes its body a flex column for it, and the
height runs down that chain (screen, split, panel, body) to `Raw`'s `fill`: the
panel ends one page margin above the footer (box 229-864 at 1440x980, 229-652
at 1024x768) and the page itself does not scroll on a desktop, whatever the
file's size. Its floor is 240 px, about twelve lines.
Stacked (720 px and below) the file list sits above it, so the page scrolls
anyway; there the reader is 70 % of the window, tall enough to read in, short
enough to leave page around it to scroll by on a touch screen, where a box
that took the whole screen would hold every swipe.
- **Long lines wrap, under their own message.** See `ui/Raw` (`wrap`) for the
measurement and `LogText` for the timestamp and the break points. Nothing is
added to the text: copying the viewer gives the file back as it came.
*/
export interface ViewLogsProps {
/** The section sub-navigation, drawn under the header. */
tabs?: ReactNode
token: string | null
node?: string
/** `Logs.canDelete`: deleting a log and deleting them all. */
canDeleteLogs?: boolean
/** `Dashboard.canDelete`: deleting all the statistics. */
canDeleteStats?: boolean
}
type Confirmation = 'log' | 'allLogs' | 'allStats'
/*
A body that is really the server's error.
Upstream returns it as TEXT through the same endpoint as the file, so there is no
way to tell it apart by the transport: it is told apart by its shape. What arrives
is the API's error JSON (`{"status":"error", ...}`), and a log file never starts
with a brace.
It is checked at the START and not by looking for `"error"` inside, because a log
file is full of the word "error" and we would mark ordinary content as a failure.
*/
function isError(text: string | null): boolean {
return text != null && /^\s*\{\s*"status"\s*:\s*"error"/.test(text)
}
export function ViewLogs({
tabs,
token,
node = '',
canDeleteLogs = true,
canDeleteStats = true,
}: ViewLogsProps) {
const [files, setFiles] = useState<LogFile[] | null>(null)
const [open, setOpen] = useState<string | null>(null)
const [body, setBody] = useState<string | null>(null)
const [loadingBody, setLoadingBody] = useState(false)
const [notice, setNotice] = useState<Notice | null>(null)
const [busy, setBusy] = useState(false)
const [confirm, setConfirm] = useState<Confirmation | null>(null)
// A failure on load is not drawn as an empty list; see `dhcp/Leases`.
const load = useCallback(async (): Promise<LogFile[] | null> => {
const r = await listLogFiles(token, node)
if (r.kind === 'ok') {
setFiles(r.data)
return r.data
}
setFiles([])
setNotice(noticeFromFailure(r))
return null
}, [token, node])
/*
The list is read again whenever the node changes, and an open file follows it
(logs.js:101-106 and 144-154): the same name is opened on the new node, or the
viewer closes if that node has no file by that name. Read through a ref so the
effect does not run again on opening a file.
*/
const openRef = useRef(open)
useEffect(() => {
openRef.current = open
}, [open])
const viewRef = useRef<(fileName: string) => Promise<void>>(async () => {})
useEffect(() => {
let live = true
void (async () => {
const list = await load()
const was = openRef.current
if (!live || was == null || list == null) return
if (list.some((f) => f.fileName === was)) void viewRef.current(was)
else {
setOpen(null)
setBody(null)
}
})()
return () => {
live = false
}
}, [load])
async function view(fileName: string) {
setOpen(fileName)
setBody(null)
setLoadingBody(true)
const text = await downloadLogText(token, fileName, node)
setLoadingBody(false)
setBody(text)
}
useEffect(() => {
viewRef.current = view
})
async function download() {
if (open == null) return
setBusy(true)
await openLogDownload(token, open, node)
setBusy(false)
}
async function removeLogFile() {
if (open == null) return
setConfirm(null)
setBusy(true)
const outcome = await deleteLog(token, open, node)
setBusy(false)
if (outcome.kind !== 'ok') return
await load()
setOpen(null)
setBody(null)
setNotice({
type: 'success',
title: 'Log Deleted!',
text: 'Log file was deleted successfully.',
})
}
async function removeAllLogs() {
setConfirm(null)
setBusy(true)
const outcome = await deleteAllLogs(token, node)
setBusy(false)
if (outcome.kind !== 'ok') return
await load()
setOpen(null)
setBody(null)
setNotice({
type: 'success',
title: 'Logs Deleted!',
text: 'All log files were deleted successfully.',
})
}
async function removeAllStats() {
setConfirm(null)
setBusy(true)
const outcome = await deleteAllStats(token)
setBusy(false)
if (outcome.kind !== 'ok') return
setNotice({
type: 'success',
title: 'Stats Deleted!',
text: 'All stats files were deleted successfully.',
})
}
if (files == null) return <Loading />
const CONFIRM_TEXT: Record<Confirmation, { title: string; text: string; label: string }> = {
log: {
title: 'Delete Log',
text: `Are you sure you want to permanently delete the log file '${open ?? ''}'?`,
label: 'Delete',
},
allLogs: {
title: 'Delete All Logs',
text: 'Are you sure you want to permanently delete all log files?',
label: 'Delete All Logs',
},
allStats: {
title: 'Delete All Stats',
text: 'Are you sure you want to permanently delete all stats files?',
label: 'Delete All Stats',
},
}
return (
<div className={styles.viewLogs} data-fills-window="">
<SectionHeader
title="Logs"
tabs={tabs}
actions={<>
{/*
Two destructive verbs asking for permissions from DIFFERENT SECTIONS,
and one condition that is not a permission.
`Delete All Logs` only exists when there are files (`logs.js:121`);
that is its object not being there, not a missing permission, so there it
does disappear: "disabled, never hidden" is about who you are, not about
whether there is anything to delete. `Delete All Stats` is offered ALWAYS
(`logs.js:117`).
And the stats one asks for `Dashboard.canDelete`, from another screen: it
deletes the Dashboard's statistics, not the logs. It is one of the three
controls, among the nine compared with upstream, that "does not ask for
the permission of the screen whose name it carries".
*/}
{files.length > 0 && (
<PermissionButton
variant="danger"
disabled={busy}
permission={canDeleteLogs ? undefined : 'Logs.canDelete'}
onClick={() => setConfirm('allLogs')}
>
Delete All Logs
</PermissionButton>
)}
<PermissionButton
variant="danger"
disabled={busy}
permission={canDeleteStats ? undefined : 'Dashboard.canDelete'}
onClick={() => setConfirm('allStats')}
>
Delete All Stats
</PermissionButton></>}
/>
<Notifier notice={notice} onClose={() => setNotice(null)} />
<div className={styles.split}>
<Panel title="Log Files">
<Body className={styles.pbList}>
{files.length === 0 ? (
<Empty>
{notice?.type === 'danger'
? 'Unable to load the log files.'
: 'No Log File Was Found'}
</Empty>
) : (
<div className={styles.logfiles}>
{files.map((f) => (
<button
key={f.fileName}
type="button"
className={styles.logfile}
aria-current={open === f.fileName}
onClick={() => void view(f.fileName)}
>
<span className={styles.name}>{f.fileName}</span>
<span className={styles.sz}>[{f.size}]</span>
</button>
))}
</div>
)}
</Body>
</Panel>
{open != null && (
<Panel
className={styles.reader}
title={<span className={styles.mono}>{open}</span>}
actions={
<div className={styles.acts}>
{/* `Download` asks for no permission at all: it stays enabled. */}
<Button disabled={busy} onClick={() => void download()}>
Download
</Button>
<PermissionButton
variant="danger"
disabled={busy}
permission={canDeleteLogs ? undefined : 'Logs.canDelete'}
onClick={() => setConfirm('log')}
>
Delete
</PermissionButton>
</div>
}
>
<Body className={styles.readerBody}>
{loadingBody ? (
<Loading />
) : (
/*
The server's error is drawn HERE and not as a notice, because the
endpoint returns text and this is the only place it fits
(`logs.js:170-172`). The frame turns `--dan` so it is framed and
not disguised as the file's content.
*/
<Raw
text={`${open} log file`}
fill
height={240}
wrap
hang={stampWidth(body ?? '') || 2}
tone={isError(body) ? 'error' : 'normal'}
>
<LogText text={body ?? ''} />
</Raw>
)}
</Body>
</Panel>
)}
</div>
<Confirm
open={confirm !== null}
title={confirm ? CONFIRM_TEXT[confirm].title : ''}
text={confirm ? CONFIRM_TEXT[confirm].text : ''}
label={confirm ? CONFIRM_TEXT[confirm].label : ''}
busy={busy}
onClose={() => setConfirm(null)}
onConfirm={() => {
if (confirm === 'log') void removeLogFile()
else if (confirm === 'allLogs') void removeAllLogs()
else if (confirm === 'allStats') void removeAllStats()
}}
/>
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
/*
`moment(x).local().format("YYYY-MM-DD HH:mm:ss")`, which is how upstream writes
the timestamp of a query log entry (logs.js:512). Without moment.
And the inverse conversion: the `<input type="datetime-local">` of the "From" and
"To" filters hands over a string with NO zone (`2026-08-25T00:00`), which
upstream passes through `moment(...).toISOString()`. Both moment and the
browser's `Date` read that form in LOCAL time, so the result is the same.
*/
/* `dateTime` was unified into `src/lib/dates.ts` when integrating phases 4, 8 and 9. */
export { dateTime } from '../../lib/dates'
/** `moment(value).toISOString()` (logs.js:411). An empty string if there is no
* value, which is what upstream sends when the field is blank. */
export function toIso(value: string): string {
if (value === '') return ''
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '' : d.toISOString()
}