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 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-10-30 18:51:37 +07:00
1 parent 37a74199e9
commit 9df2c2b908
4 files changed
+611 -1

No files matched your search

+341
View File
@@ -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
View File
@@ -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 }
}
+23
View File
@@ -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
View File
@@ -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" }
]
}