UI polish across sections: one server selector, skeletons, inline errors
CI / check (push) Successful in 2m39s
Hub / check (push) Successful in 50s
CI / image (push) Successful in 1m30s
Hub / image (push) Successful in 1m37s

- 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:
DenozordecandClaude Opus 5.5 committed 2026-11-02 21:42:28 +07:00
1 parent 172e06f1ce
commit ba3cdd08d2
43 files changed
+671 -261

No files matched your search

+17
View File
@@ -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
View File
@@ -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)