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:
commit
f3531c64f9
680 files changed
+139836
No files matched your search
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user