Files
DenozordecandClaude Opus 5.5 ba3cdd08d2
CI / check (push) Successful in 2m39s
Hub / check (push) Successful in 50s
CI / image (push) Successful in 1m30s
Hub / image (push) Successful in 1m37s
UI polish across sections: one server selector, skeletons, inline errors
- Every section puts the hub's server selector in the chrome slot, the
  same place and gap as the Dashboard.
- Data regions load with a Skeleton and fail inline with Retry; KPIs show
  the no-data dash instead of 0 when a server did not answer. Settings
  keeps its header and selector while loading or failing.
- Cache, Apps and Logs headers group secondary or hub-refused actions in
  menus; app descriptions clamp to two lines; Unknown cells say why.
- DNS Client: Protocol label, Resolve and Import together. DHCP and Query
  Logs empty states with actions. View Logs list capped with scroll.
- Blocking: Lists KPI tiles like the Overview, app list table fits its
  panel with one-line URLs. Status re-reads on tab return at most once
  a minute.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 21:42:28 +07:00

359 lines
16 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
mergeQueryLogs,
mergeSettled,
mergeStats,
mergeTop,
mergeTopResponses,
type ChartData,
type DashboardStats,
type QueryLogEntry,
type QueryLogPage,
type Settled,
} from './merge.ts'
/* Fixtures in the shape of `dashboard/stats/get` (see dev/mock-api.mjs). */
function stats(over: Partial<Record<string, number>> = {}): Record<string, number> {
return {
totalQueries: 100, totalNoError: 90, totalServerFailure: 1, totalNxDomain: 5, totalRefused: 4,
totalAuthoritative: 10, totalRecursive: 30, totalCached: 50, totalBlocked: 8, totalDropped: 2,
totalClients: 12, zones: 3, cachedEntries: 400, allowedZones: 1, blockedZones: 2, allowListZones: 1,
blockListZones: 1000, ...over,
} as Record<string, number>
}
function response(over: Partial<DashboardStats> = {}): DashboardStats {
return {
stats: stats(),
mainChartData: {
labelFormat: 'HH:mm',
labels: ['2026-01-01T10:00:00Z', '2026-01-01T11:00:00Z'],
datasets: [{ label: 'Total', data: [40, 60] }, { label: 'Blocked', data: [3, 5] }],
},
queryResponseChartData: {
labels: ['Authoritative', 'Recursive', 'Cached'],
datasets: [{ label: 'Total', data: [10, 30, 50], backgroundColor: ['#111', '#222', '#333'] }],
},
queryTypeChartData: { labels: ['A', 'AAAA'], datasets: [{ label: 'Total', data: [70, 30], backgroundColor: [] }] },
protocolTypeChartData: { labels: ['UDP', 'TCP'], datasets: [{ label: 'Total', data: [90, 10], backgroundColor: [] }] },
topClients: [{ name: '10.0.0.1', domain: 'a.lan', hits: 50 }],
topDomains: [{ name: 'example.com', hits: 30 }],
topBlockedDomains: [{ name: 'ads.example.com', hits: 8 }],
...over,
}
}
describe('mergeStats counters', () => {
it('sums every numeric field of stats', () => {
const merged = mergeStats([response(), response({ stats: stats({ totalQueries: 250, zones: 7 }) })])
expect(merged.stats.totalQueries).toBe(350)
expect(merged.stats.totalNoError).toBe(180)
expect(merged.stats.zones).toBe(10)
expect(merged.stats.blockListZones).toBe(2000)
expect(Object.keys(merged.stats)).toEqual(Object.keys(stats()))
})
it('sums what each server holds: zones, cache, allowed, blocked and the two lists', () => {
const a = response({ stats: stats({ zones: 3, cachedEntries: 400, allowedZones: 1, blockedZones: 2, allowListZones: 1, blockListZones: 1000 }) })
const b = response({ stats: stats({ zones: 5, cachedEntries: 50, allowedZones: 4, blockedZones: 6, allowListZones: 2, blockListZones: 10 }) })
const merged = mergeStats([a, b])
expect(merged.stats).toMatchObject({ zones: 8, cachedEntries: 450, allowedZones: 5, blockedZones: 8, allowListZones: 3, blockListZones: 1010 })
})
it('counts a numeric string, and a server that left a field out as none', () => {
const a = response({ stats: { ...stats(), zones: '4' as unknown as number } })
const b = response({ stats: { totalQueries: 1 } })
const merged = mergeStats([a, b])
expect(merged.stats.zones).toBe(4)
expect(merged.stats.allowedZones).toBe(1)
expect(merged.stats.totalQueries).toBe(101)
expect(mergeStats([b]).stats.blockListZones).toBe(0)
})
it('returns a single response with the same numbers', () => {
const merged = mergeStats([response()])
expect(merged.stats).toEqual(stats())
expect(merged.mainChartData?.datasets[0].data).toEqual([40, 60])
})
it('throws on nothing to merge', () => {
expect(() => mergeStats([])).toThrow()
})
it('does not mutate its inputs', () => {
const a = response()
const b = response()
const before = JSON.stringify([a, b])
mergeStats([a, b])
expect(JSON.stringify([a, b])).toBe(before)
})
})
describe('mergeStats main chart', () => {
it('sums the values of equal labels per dataset', () => {
const merged = mergeStats([response(), response()])
expect(merged.mainChartData?.labels).toEqual(['2026-01-01T10:00:00Z', '2026-01-01T11:00:00Z'])
expect(merged.mainChartData?.datasets).toEqual([
{ label: 'Total', data: [80, 120] },
{ label: 'Blocked', data: [6, 10] },
])
expect(merged.mainChartData?.labelFormat).toBe('HH:mm')
})
it('aligns differing label sets by label, missing points count as 0, order stays chronological', () => {
const a: ChartData = { labels: ['t1', 't2', 't4'], datasets: [{ label: 'Total', data: [1, 2, 4] }] }
const b: ChartData = { labels: ['t2', 't3', 't4', 't5'], datasets: [{ label: 'Total', data: [20, 30, 40, 50] }] }
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
expect(merged.mainChartData?.labels).toEqual(['t1', 't2', 't3', 't4', 't5'])
expect(merged.mainChartData?.datasets[0].data).toEqual([1, 22, 30, 44, 50])
})
it('slips earlier labels of a later server before the first shared label', () => {
const a: ChartData = { labels: ['t3', 't4'], datasets: [{ label: 'Total', data: [3, 4] }] }
const b: ChartData = { labels: ['t1', 't2', 't3'], datasets: [{ label: 'Total', data: [10, 20, 30] }] }
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
expect(merged.mainChartData?.labels).toEqual(['t1', 't2', 't3', 't4'])
expect(merged.mainChartData?.datasets[0].data).toEqual([10, 20, 33, 4])
})
it('matches datasets by label, not by position', () => {
const a: ChartData = {
labels: ['t1'],
datasets: [{ label: 'Total', data: [5] }, { label: 'Blocked', data: [1] }],
}
const b: ChartData = {
labels: ['t1'],
datasets: [{ label: 'Blocked', data: [10] }, { label: 'Cached', data: [7] }, { label: 'Total', data: [50] }],
}
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
expect(merged.mainChartData?.datasets).toEqual([
{ label: 'Total', data: [55] },
{ label: 'Blocked', data: [11] },
{ label: 'Cached', data: [7] },
])
})
it('keeps a dataset that only one server has, zero where the other labels are', () => {
const a: ChartData = { labels: ['t1'], datasets: [{ label: 'Total', data: [5] }] }
const b: ChartData = { labels: ['t2'], datasets: [{ label: 'Cached', data: [7] }] }
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
expect(merged.mainChartData?.labels).toEqual(['t1', 't2'])
expect(merged.mainChartData?.datasets).toEqual([
{ label: 'Total', data: [5, 0] },
{ label: 'Cached', data: [0, 7] },
])
})
it('keeps the other properties of the first dataset that has them', () => {
const a: ChartData = { labels: ['t1'], datasets: [{ label: 'Total', data: [1], backgroundColor: '#aaa' }] }
const b: ChartData = { labels: ['t1'], datasets: [{ label: 'Total', data: [2], backgroundColor: '#bbb' }] }
const merged = mergeStats([response({ mainChartData: a }), response({ mainChartData: b })])
expect(merged.mainChartData?.datasets[0]).toEqual({ label: 'Total', data: [3], backgroundColor: '#aaa' })
})
it('survives a server without a main chart', () => {
const merged = mergeStats([response({ mainChartData: undefined }), response()])
expect(merged.mainChartData?.datasets[0].data).toEqual([40, 60])
})
})
describe('mergeStats breakdown charts', () => {
it('sums values per label and keeps the colours of the first server', () => {
const merged = mergeStats([response(), response()])
expect(merged.queryResponseChartData).toEqual({
labels: ['Authoritative', 'Recursive', 'Cached'],
datasets: [{ label: 'Total', data: [20, 60, 100], backgroundColor: ['#111', '#222', '#333'] }],
})
expect(merged.queryTypeChartData?.datasets[0].data).toEqual([140, 60])
expect(merged.protocolTypeChartData?.datasets[0].data).toEqual([180, 20])
})
it('keeps the label order of the first server and appends new labels', () => {
const a: ChartData = { labels: ['A', 'AAAA'], datasets: [{ label: 'Total', data: [1, 2] }] }
const b: ChartData = { labels: ['TXT', 'AAAA', 'A', 'MX'], datasets: [{ label: 'Total', data: [10, 20, 30, 40] }] }
const merged = mergeStats([response({ queryTypeChartData: a }), response({ queryTypeChartData: b })])
expect(merged.queryTypeChartData?.labels).toEqual(['A', 'AAAA', 'TXT', 'MX'])
expect(merged.queryTypeChartData?.datasets[0].data).toEqual([31, 22, 10, 40])
})
})
describe('mergeStats top lists', () => {
it('merges the three top lists inside the stats', () => {
const a = response({
topClients: [{ name: '10.0.0.1', domain: '', hits: 50 }, { name: '10.0.0.2', hits: 10, rateLimited: true }],
topDomains: [{ name: 'a.com', hits: 5 }],
})
const b = response({
topClients: [{ name: '10.0.0.1', domain: 'host.lan', hits: 25 }],
topDomains: [{ name: 'b.com', hits: 9 }],
topBlockedDomains: [{ name: 'ads.example.com', hits: 2 }],
})
const merged = mergeStats([a, b])
expect(merged.topClients).toEqual([
{ name: '10.0.0.1', domain: 'host.lan', hits: 75 },
{ name: '10.0.0.2', hits: 10, rateLimited: true },
])
// The default limit is the longest input list (1 here).
expect(merged.topDomains).toEqual([{ name: 'b.com', hits: 9 }])
expect(merged.topBlockedDomains).toEqual([{ name: 'ads.example.com', hits: 10 }])
})
})
describe('mergeTop', () => {
it('sums hits by name, sorts by hits and cuts to the longest input by default', () => {
const a = [{ name: 'a', hits: 10 }, { name: 'b', hits: 5 }, { name: 'c', hits: 1 }]
const b = [{ name: 'c', hits: 20 }, { name: 'd', hits: 7 }]
expect(mergeTop([a, b])).toEqual([
{ name: 'c', hits: 21 },
{ name: 'a', hits: 10 },
{ name: 'd', hits: 7 },
])
})
it('cuts to an explicit limit', () => {
const a = [{ name: 'a', hits: 10 }, { name: 'b', hits: 9 }]
const b = [{ name: 'c', hits: 8 }, { name: 'd', hits: 7 }]
expect(mergeTop([a, b], 3).map((e) => e.name)).toEqual(['a', 'b', 'c'])
expect(mergeTop([a, b], 0)).toEqual([])
})
it('breaks ties by name ascending', () => {
const a = [{ name: 'zeta', hits: 5 }, { name: 'alpha', hits: 5 }]
const b = [{ name: 'mid', hits: 5 }]
expect(mergeTop([a, b], 10).map((e) => e.name)).toEqual(['alpha', 'mid', 'zeta'])
})
it('keeps the first non-empty domain and ORs rateLimited', () => {
const a = [{ name: 'c1', domain: '', hits: 1 }]
const b = [{ name: 'c1', domain: 'first.lan', hits: 1, rateLimited: true }]
const c = [{ name: 'c1', domain: 'second.lan', hits: 1 }]
expect(mergeTop([a, b, c])).toEqual([{ name: 'c1', domain: 'first.lan', hits: 3, rateLimited: true }])
})
it('ignores missing lists and does not mutate the inputs', () => {
const a = [{ name: 'a', hits: 1 }]
expect(mergeTop([a, undefined, a])).toEqual([{ name: 'a', hits: 2 }])
expect(a).toEqual([{ name: 'a', hits: 1 }])
expect(mergeTop([])).toEqual([])
})
})
describe('mergeTopResponses', () => {
it('merges the getTop shape under the key the servers used', () => {
const merged = mergeTopResponses([
{ topClients: [{ name: 'x', hits: 3 }] },
{ topClients: [{ name: 'x', hits: 4 }, { name: 'y', hits: 1 }] },
], 1)
expect(merged).toEqual({ topClients: [{ name: 'x', hits: 7 }] })
})
it('works for domains and blocked domains', () => {
expect(mergeTopResponses([{ topDomains: [{ name: 'a', hits: 1 }] }, { topDomains: [{ name: 'a', hits: 1 }] }]))
.toEqual({ topDomains: [{ name: 'a', hits: 2 }] })
expect(mergeTopResponses([{ topBlockedDomains: [{ name: 'b', hits: 1 }] }]))
.toEqual({ topBlockedDomains: [{ name: 'b', hits: 1 }] })
})
})
/* Fixtures in the shape of `logs/query` (see dev/mock-api.mjs). */
function entry(timestamp: string, qname: string): QueryLogEntry {
return {
rowNumber: 1, timestamp, clientIpAddress: '192.168.10.14', protocol: 'Udp', responseType: 'Cached',
responseRtt: 1.5, rcode: 'NoError', qname, qtype: 'A', qclass: 'IN', answer: '1.1.1.1',
}
}
function page(entries: QueryLogEntry[], totalEntries = entries.length): QueryLogPage {
return { pageNumber: 1, totalPages: 1, totalEntries, entries }
}
describe('mergeQueryLogs', () => {
const dns = page([entry('2026-01-01T10:00:03Z', 'a.com'), entry('2026-01-01T10:00:01Z', 'c.com')])
const ndns = page([entry('2026-01-01T10:00:04Z', 'b.com'), entry('2026-01-01T10:00:02Z', 'd.com')])
const pages = [{ node: 'dns', page: dns }, { node: 'ndns', page: ndns }]
it('sorts newest first by default and tags each entry with its server', () => {
const merged = mergeQueryLogs(pages, { pageNumber: 1, entriesPerPage: 25 })
expect(merged.entries.map((e) => e.qname)).toEqual(['b.com', 'a.com', 'd.com', 'c.com'])
expect(merged.entries.map((e) => e.node)).toEqual(['ndns', 'dns', 'ndns', 'dns'])
expect(merged.entries[0]).toMatchObject({ clientIpAddress: '192.168.10.14', answer: '1.1.1.1', responseRtt: 1.5 })
})
it('sorts oldest first when descending is false', () => {
const merged = mergeQueryLogs(pages, { pageNumber: 1, entriesPerPage: 25, descending: false })
expect(merged.entries.map((e) => e.qname)).toEqual(['c.com', 'd.com', 'a.com', 'b.com'])
})
it('renumbers rows from 1 and sums the totals', () => {
const merged = mergeQueryLogs(
[{ node: 'dns', page: page(dns.entries, 120) }, { node: 'ndns', page: page(ndns.entries, 31) }],
{ pageNumber: 1, entriesPerPage: 25 },
)
expect(merged.entries.map((e) => e.rowNumber)).toEqual([1, 2, 3, 4])
expect(merged.totalEntries).toBe(151)
expect(merged.totalPages).toBe(7)
expect(merged.pageNumber).toBe(1)
})
it('cuts page N from the merged head of every server and numbers it from its offset', () => {
// Each server sent its first 2 x 2 entries; page 2 is the 3rd and 4th of the merged list.
const merged = mergeQueryLogs(pages, { pageNumber: 2, entriesPerPage: 2 })
expect(merged.pageNumber).toBe(2)
expect(merged.entries.map((e) => e.qname)).toEqual(['d.com', 'c.com'])
expect(merged.entries.map((e) => e.rowNumber)).toEqual([3, 4])
})
it('a page past the end is empty', () => {
expect(mergeQueryLogs(pages, { pageNumber: 3, entriesPerPage: 2 }).entries).toEqual([])
})
it('cuts to entriesPerPage', () => {
const merged = mergeQueryLogs(pages, { pageNumber: 1, entriesPerPage: 3 })
expect(merged.entries.map((e) => e.qname)).toEqual(['b.com', 'a.com', 'd.com'])
expect(merged.totalEntries).toBe(4)
expect(merged.totalPages).toBe(2)
})
it('keeps server order for equal timestamps', () => {
const same = [
{ node: 'dns', page: page([entry('2026-01-01T10:00:00Z', 'x.com')]) },
{ node: 'ndns', page: page([entry('2026-01-01T10:00:00Z', 'y.com')]) },
]
expect(mergeQueryLogs(same, { pageNumber: 1, entriesPerPage: 10 }).entries.map((e) => e.node)).toEqual(['dns', 'ndns'])
})
it('handles empty pages', () => {
const merged = mergeQueryLogs([{ node: 'dns', page: page([]) }], { pageNumber: 1, entriesPerPage: 25 })
expect(merged).toEqual({ pageNumber: 1, totalPages: 0, totalEntries: 0, entries: [] })
})
it('does not mutate the input entries', () => {
mergeQueryLogs(pages, { pageNumber: 2, entriesPerPage: 2 })
expect(dns.entries[0].node).toBeUndefined()
expect(dns.entries[0].rowNumber).toBe(1)
})
})
describe('mergeSettled', () => {
it('returns the data of the servers that answered', () => {
const results: Settled<number>[] = [{ node: 'dns', ok: true, data: 1 }, { node: 'ndns', ok: true, data: 2 }]
expect(mergeSettled(results)).toEqual({ data: [1, 2], warnings: [] })
})
it('warns about an unreachable server and keeps the other one', () => {
const results: Settled<number>[] = [
{ node: 'dns', ok: true, data: 1 },
{ node: 'ndns', ok: false, error: 'timeout' },
]
expect(mergeSettled(results)).toEqual({ data: [1], warnings: ['ndns: timeout'] })
})
it('throws naming every server when all failed', () => {
const results: Settled<number>[] = [
{ node: 'dns', ok: false, error: 'refused' },
{ node: 'ndns', ok: false, error: 'timeout' },
]
expect(() => mergeSettled(results)).toThrow(/dns: refused.*ndns: timeout/)
})
})