diff --git a/hub/merge.test.ts b/hub/merge.test.ts new file mode 100644 index 0000000..051cad9 --- /dev/null +++ b/hub/merge.test.ts @@ -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 { + 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 +} + +function response(over: Partial = {}): 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[] = [{ 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[] = [ + { 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[] = [ + { node: 'dns', ok: false, error: 'refused' }, + { node: 'ndns', ok: false, error: 'timeout' }, + ] + expect(() => mergeSettled(results)).toThrow(/dns: refused.*ndns: timeout/) + }) +}) diff --git a/hub/merge.ts b/hub/merge.ts new file mode 100644 index 0000000..4e0ae1f --- /dev/null +++ b/hub/merge.ts @@ -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 + 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 = { 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() + + 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() + 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[], + limit?: number, +): Record { + 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 = {} + 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(results: Settled[]): { 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 } +} diff --git a/hub/tsconfig.json b/hub/tsconfig.json new file mode 100644 index 0000000..f382243 --- /dev/null +++ b/hub/tsconfig.json @@ -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"] +} diff --git a/tsconfig.json b/tsconfig.json index 0ac4bad..5b80ba8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,7 @@ "references": [ { "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }, - { "path": "./update-check/tsconfig.json" } + { "path": "./update-check/tsconfig.json" }, + { "path": "./hub/tsconfig.json" } ] }