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 <noreply@anthropic.com>
This commit is contained in:
1 parent
172e06f1ce
commit
ba3cdd08d2
43 files changed
+671
-261
No files matched your search
@@ -53,6 +53,23 @@ describe('mergeStats counters', () => {
|
||||
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())
|
||||
|
||||
+16
-1
@@ -162,6 +162,16 @@ export function mergeTopResponses(
|
||||
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:
|
||||
@@ -174,9 +184,14 @@ export function mergeStats(responses: DashboardStats[]): DashboardStats {
|
||||
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 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)
|
||||
|
||||
Reference in new issue
Block a user