Hub stage 1: pure merge functions for stats, top lists and query logs
Sums dashboard counters, aligns chart series by label, merges top lists by name and query logs by time (page N cut from the merged head of every server), and reports an unreachable server as a warning. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
37a74199e9
commit
9df2c2b908
4 files changed
+611
-1
No files matched your search
@@ -0,0 +1,341 @@
|
|||||||
|
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('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/)
|
||||||
|
})
|
||||||
|
})
|
||||||
+245
@@ -0,0 +1,245 @@
|
|||||||
|
/*
|
||||||
|
Pure merge functions of the hub: they take the `response` objects of several
|
||||||
|
Technitium servers and return the one a single server would have sent, so the
|
||||||
|
console can show "All servers" without knowing there are two.
|
||||||
|
|
||||||
|
No imports, no I/O: it runs under `node --experimental-strip-types` and is tested
|
||||||
|
on its own. The types are declared here and mirror `src/api/dashboard.ts` and
|
||||||
|
`src/api/logs.ts` on purpose, so the hub never pulls the console's sources in.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChartDataset {
|
||||||
|
label: string
|
||||||
|
data: number[]
|
||||||
|
backgroundColor?: string | string[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartData {
|
||||||
|
labelFormat?: string
|
||||||
|
labels: string[]
|
||||||
|
datasets: ChartDataset[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopEntry {
|
||||||
|
name: string
|
||||||
|
hits: number
|
||||||
|
domain?: string
|
||||||
|
rateLimited?: boolean
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
stats: Record<string, number>
|
||||||
|
mainChartData?: ChartData
|
||||||
|
queryResponseChartData?: ChartData
|
||||||
|
queryTypeChartData?: ChartData
|
||||||
|
protocolTypeChartData?: ChartData
|
||||||
|
topClients?: TopEntry[]
|
||||||
|
topDomains?: TopEntry[]
|
||||||
|
topBlockedDomains?: TopEntry[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryLogEntry {
|
||||||
|
rowNumber: number
|
||||||
|
timestamp: string
|
||||||
|
node?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryLogPage {
|
||||||
|
pageNumber: number
|
||||||
|
totalPages: number
|
||||||
|
totalEntries: number
|
||||||
|
entries: QueryLogEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Settled<T> = { node: string; ok: true; data: T } | { node: string; ok: false; error: string }
|
||||||
|
|
||||||
|
const TOP_KEYS = ['topClients', 'topDomains', 'topBlockedDomains'] as const
|
||||||
|
const BREAKDOWN_KEYS = ['queryResponseChartData', 'queryTypeChartData', 'protocolTypeChartData'] as const
|
||||||
|
|
||||||
|
/*
|
||||||
|
Charts. A chart is a list of labels and datasets whose `data` lines up with the
|
||||||
|
labels. Merging means: build the merged label list, then for each dataset (matched
|
||||||
|
by its `label`) add each server's value into the slot of its label. A label a
|
||||||
|
server lacks counts as 0.
|
||||||
|
|
||||||
|
`ordered` is for the time series: the merged labels keep chronological order, new
|
||||||
|
labels are slipped in before the next label both lists share. Without it (the
|
||||||
|
breakdowns) the labels of the first server keep their order and new ones are
|
||||||
|
appended.
|
||||||
|
*/
|
||||||
|
function mergeLabels(charts: ChartData[], ordered: boolean): string[] {
|
||||||
|
const labels: string[] = [...charts[0].labels]
|
||||||
|
for (const chart of charts.slice(1)) {
|
||||||
|
if (!ordered) {
|
||||||
|
for (const label of chart.labels) if (!labels.includes(label)) labels.push(label)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Labels before the first shared one go right before it; with none shared, all are appended.
|
||||||
|
const anchor = chart.labels.findIndex((label) => labels.includes(label))
|
||||||
|
if (anchor < 0) {
|
||||||
|
labels.push(...chart.labels)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let pos = labels.indexOf(chart.labels[anchor])
|
||||||
|
for (const label of chart.labels) {
|
||||||
|
const at = labels.indexOf(label)
|
||||||
|
if (at >= 0) {
|
||||||
|
pos = at + 1
|
||||||
|
} else {
|
||||||
|
labels.splice(pos, 0, label)
|
||||||
|
pos += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeChart(charts: (ChartData | undefined)[], ordered: boolean): ChartData | undefined {
|
||||||
|
const present = charts.filter((c): c is ChartData => c != null && Array.isArray(c.labels))
|
||||||
|
if (present.length === 0) return undefined
|
||||||
|
|
||||||
|
const labels = mergeLabels(present, ordered)
|
||||||
|
const slot = new Map(labels.map((label, i) => [label, i]))
|
||||||
|
const merged = new Map<string, ChartDataset>()
|
||||||
|
|
||||||
|
for (const chart of present) {
|
||||||
|
for (const dataset of chart.datasets ?? []) {
|
||||||
|
let target = merged.get(dataset.label)
|
||||||
|
if (target === undefined) {
|
||||||
|
// The other properties (colours...) come from the first server that has the dataset.
|
||||||
|
target = { ...dataset, data: labels.map(() => 0) }
|
||||||
|
merged.set(dataset.label, target)
|
||||||
|
}
|
||||||
|
const into = target
|
||||||
|
chart.labels.forEach((label, i) => {
|
||||||
|
const value = dataset.data?.[i]
|
||||||
|
if (typeof value === 'number') into.data[slot.get(label) as number] += value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...present[0], labels, datasets: [...merged.values()] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Top lists: hits are summed by name, ordered by hits (highest first) then by name,
|
||||||
|
and cut to `limit`, which defaults to the longest input list. For a client, the
|
||||||
|
first non-empty `domain` is kept and `rateLimited` is true if any server said so.
|
||||||
|
*/
|
||||||
|
export function mergeTop(lists: (TopEntry[] | undefined)[], limit?: number): TopEntry[] {
|
||||||
|
const present = lists.filter((l): l is TopEntry[] => Array.isArray(l))
|
||||||
|
const byName = new Map<string, TopEntry>()
|
||||||
|
for (const list of present) {
|
||||||
|
for (const entry of list) {
|
||||||
|
const seen = byName.get(entry.name)
|
||||||
|
if (seen === undefined) {
|
||||||
|
byName.set(entry.name, { ...entry })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen.hits += entry.hits
|
||||||
|
if (!seen.domain && entry.domain) seen.domain = entry.domain
|
||||||
|
if (entry.rateLimited) seen.rateLimited = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cut = limit ?? Math.max(0, ...present.map((l) => l.length))
|
||||||
|
return [...byName.values()]
|
||||||
|
.sort((a, b) => b.hits - a.hits || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
||||||
|
.slice(0, cut)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The `dashboard/stats/getTop` response is `{ topClients | topDomains | topBlockedDomains: [...] }`. */
|
||||||
|
export function mergeTopResponses(
|
||||||
|
responses: Record<string, TopEntry[] | undefined>[],
|
||||||
|
limit?: number,
|
||||||
|
): Record<string, TopEntry[]> {
|
||||||
|
for (const key of TOP_KEYS) {
|
||||||
|
if (responses.some((r) => Array.isArray(r[key]))) return { [key]: mergeTop(responses.map((r) => r[key]), limit) }
|
||||||
|
}
|
||||||
|
return { topDomains: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
`dashboard/stats/get`. Every numeric counter in `stats` is summed. That includes
|
||||||
|
`totalClients` (distinct clients), which over-counts a client seen by both servers:
|
||||||
|
the hub has no way to know the overlap from the totals alone.
|
||||||
|
*/
|
||||||
|
export function mergeStats(responses: DashboardStats[]): DashboardStats {
|
||||||
|
if (responses.length === 0) throw new Error('Nothing to merge.')
|
||||||
|
const first = responses[0]
|
||||||
|
|
||||||
|
const stats: Record<string, number> = {}
|
||||||
|
for (const r of responses) {
|
||||||
|
for (const [key, value] of Object.entries(r.stats ?? {})) {
|
||||||
|
if (typeof value === 'number') stats[key] = (stats[key] ?? 0) + value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const merged: DashboardStats = { ...first, stats }
|
||||||
|
const main = mergeChart(responses.map((r) => r.mainChartData), true)
|
||||||
|
if (main) merged.mainChartData = main
|
||||||
|
for (const key of BREAKDOWN_KEYS) {
|
||||||
|
const chart = mergeChart(responses.map((r) => r[key]), false)
|
||||||
|
if (chart) merged[key] = chart
|
||||||
|
}
|
||||||
|
for (const key of TOP_KEYS) {
|
||||||
|
if (responses.some((r) => Array.isArray(r[key]))) merged[key] = mergeTop(responses.map((r) => r[key]))
|
||||||
|
}
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
`logs/query`. The pages are what each server answered; the entries are sorted by
|
||||||
|
timestamp together, each tagged with the server it came from. To build page N the
|
||||||
|
caller asks every server for ITS first N x `entriesPerPage` entries (page 1 of that
|
||||||
|
size): page N of the merged list can only be cut from the merged head of all of them.
|
||||||
|
The hub keeps the slice of page N. `rowNumber` continues from the page's offset, as a
|
||||||
|
single server numbers it.
|
||||||
|
*/
|
||||||
|
export function mergeQueryLogs(
|
||||||
|
pages: { node: string; page: QueryLogPage }[],
|
||||||
|
opts: { pageNumber: number; entriesPerPage: number; descending?: boolean },
|
||||||
|
): QueryLogPage {
|
||||||
|
const descending = opts.descending !== false
|
||||||
|
const entries: QueryLogEntry[] = []
|
||||||
|
let totalEntries = 0
|
||||||
|
for (const { node, page } of pages) {
|
||||||
|
totalEntries += page.totalEntries
|
||||||
|
for (const entry of page.entries) entries.push({ ...entry, node })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array.prototype.sort is stable: equal timestamps keep the order of the servers.
|
||||||
|
const time = (e: QueryLogEntry) => {
|
||||||
|
const t = Date.parse(e.timestamp)
|
||||||
|
return Number.isNaN(t) ? 0 : t
|
||||||
|
}
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
const diff = time(a) - time(b)
|
||||||
|
return descending ? -diff : diff
|
||||||
|
})
|
||||||
|
|
||||||
|
const offset = (opts.pageNumber - 1) * opts.entriesPerPage
|
||||||
|
return {
|
||||||
|
pageNumber: opts.pageNumber,
|
||||||
|
totalPages: Math.ceil(totalEntries / opts.entriesPerPage),
|
||||||
|
totalEntries,
|
||||||
|
entries: entries
|
||||||
|
.slice(offset, offset + opts.entriesPerPage)
|
||||||
|
.map((entry, i) => ({ ...entry, rowNumber: offset + i + 1 })),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Settled results of the parallel calls: the data that arrived plus a warning per unreachable server. */
|
||||||
|
export function mergeSettled<T>(results: Settled<T>[]): { data: T[]; warnings: string[] } {
|
||||||
|
const data: T[] = []
|
||||||
|
const warnings: string[] = []
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.ok) data.push(result.data)
|
||||||
|
else warnings.push(`${result.node}: ${result.error}`)
|
||||||
|
}
|
||||||
|
if (data.length === 0) throw new Error(`No server answered. ${warnings.join('; ')}`.trim())
|
||||||
|
return { data, warnings }
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "../node_modules/.tmp/tsconfig.hub.tsbuildinfo",
|
||||||
|
"target": "esnext",
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* The hub runs under `node --experimental-strip-types`. */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["*.ts"]
|
||||||
|
}
|
||||||
+2
-1
@@ -4,6 +4,7 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" },
|
{ "path": "./tsconfig.node.json" },
|
||||||
{ "path": "./update-check/tsconfig.json" }
|
{ "path": "./update-check/tsconfig.json" },
|
||||||
|
{ "path": "./hub/tsconfig.json" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user