- 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 <noreply@anthropic.com>
261 lines
9.3 KiB
TypeScript
261 lines
9.3 KiB
TypeScript
/*
|
|
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: [] }
|
|
}
|
|
|
|
/* A counter as a number: a number, or a numeric string some servers send. Anything else is not a counter. */
|
|
function counter(value: unknown): number | null {
|
|
if (typeof value === 'number') return Number.isFinite(value) ? value : null
|
|
if (typeof value === 'string' && value.trim() !== '' && Number.isFinite(Number(value))) return Number(value)
|
|
return null
|
|
}
|
|
|
|
/* What a server holds, as the "Server" panel of the Dashboard counts it. */
|
|
const HELD = ['zones', 'cachedEntries', 'allowedZones', 'blockedZones', 'allowListZones', 'blockListZones']
|
|
|
|
/*
|
|
`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 ?? {})) {
|
|
const n = counter(value)
|
|
if (n !== null) stats[key] = (stats[key] ?? 0) + n
|
|
}
|
|
}
|
|
/* The Dashboard's "Server" panel counts what each server holds (zones, cache, allow and block
|
|
lists). Those are sums too, and a server that left one out counts as none, so the field is
|
|
never missing from the total. */
|
|
for (const key of HELD) stats[key] ??= 0
|
|
|
|
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 }
|
|
}
|