Skip to content

Commit 8335a8d

Browse files
committed
release: refresh v0.4.8 realtime chart and update fixes
1 parent bdacca6 commit 8335a8d

7 files changed

Lines changed: 106 additions & 26 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
1616
- `DNS Resolver Matrix` is now streamlined into a high-signal default view (4 core cards) with optional expand/collapse for secondary metrics.
1717
- `Query Type Counters` redesigned into a compact footprint to reduce visual noise while preserving quick type distribution visibility.
1818
- Dashboard control toolbar (refresh/auto/ws chips) is hidden by default and can be toggled from the title area.
19+
- `Traffic Stability & QPS Over Time` now renders on a 1-second UI heartbeat while keeping backend polling lightweight, reducing chart freeze during high-variance traffic spikes.
1920

2021
### Performance
2122
- Default top tracker retention increased to `2000` for clients/domains (`web.top_clients_limit`, `web.top_domains_limit`) to match high-cardinality operational monitoring needs.
23+
- Time-series aggregation interval moved from 10s to 1s for smoother and more responsive dashboard trend lines.
24+
25+
### Fixed
26+
- Web update endpoint now handles read-only filesystem installs gracefully and returns a clear operator hint instead of a generic temp-file failure.
2227

2328
## [0.4.7] - 2026-04-04
2429

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@
1515
- **Single binary** — DNS resolver + web dashboard + auth, everything in one 6.8 MB executable
1616
- **Web dashboard** — Real-time DNS monitoring, cache management, live query stream, dark/light theme
1717
- **Operator-first dashboard** — Compact high-signal matrix, expandable secondary metrics, and inline cache query modal from top domains
18+
- **Smooth live charts** — Dashboard trend charts redraw every second to stay responsive during bursty traffic changes
1819
- **Zero-config start** — Interactive setup wizard on first run, sane defaults for everything
1920
- **Recursive only** — Navigates root → TLD → authoritative, caches results
2021
- **RFC compliant** — RFC 1035, 2308, 3596, 4033-4035, 6891, 7858, 8484, 8767, 9114, 9156
2122
- **DNSSEC validation** — Full signature verification (RSA, ECDSA, ED25519), trust chain from root KSK
2223
- **DNS blocklist** — Pi-hole style domain blocking with hosts/domain/AdBlock Plus list formats
2324
- **Secure** — JWT auth, bcrypt passwords, bailiwick enforcement, rate limiting, ACL
2425
- **Observable** — Prometheus metrics, Zabbix agent, structured logging, WebSocket query stream
25-
- **Self-updating** — Automatic version check + one-click update from web dashboard
26+
- **Self-updating** — Automatic version check + one-click update from web dashboard (read-only installs require host-level update/redeploy)
2627
- **Fast** — Sharded cache, >22M cache reads/sec, <50µs cache hit latency, request coalescing
2728

2829
## Quick Install
@@ -85,7 +86,7 @@ On first run (no config file), the dashboard shows an interactive setup wizard:
8586

8687
| Page | Description |
8788
|------|-------------|
88-
| **Dashboard** | Real-time DNS stats with compact matrix view, expandable secondary metrics, paged Top Clients/Top Domains (up to 2000), and inline domain cache-query modal |
89+
| **Dashboard** | Real-time DNS stats with compact matrix view, expandable secondary metrics, 1-second chart redraw cadence, paged Top Clients/Top Domains (up to 2000), and inline domain cache-query modal |
8990
| **Queries** | Live DNS query stream via WebSocket — filterable, pausable, DNSSEC badges, blocked indicators |
9091
| **Cache** | Cache stats, lookup tool, flush, delete individual entries, negative cache view |
9192
| **Blocklist** | List management, quick block/unblock, domain check, source stats |

web/api_stats.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,8 @@ func (s *AdminServer) handleTimeSeries(w http.ResponseWriter, r *http.Request) {
7979
}
8080

8181
jsonResponse(w, http.StatusOK, map[string]interface{}{
82-
"window": windowStr,
83-
"buckets": buckets,
82+
"window": windowStr,
83+
"bucket_seconds": int(bucketInterval.Seconds()),
84+
"buckets": buckets,
8485
})
8586
}

web/timeseries.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import (
66
)
77

88
const (
9-
bucketInterval = 10 * time.Second
10-
maxBuckets = 360 // 1 hour at 10s intervals
9+
bucketInterval = 1 * time.Second
10+
maxBuckets = 3600 // 1 hour at 1s intervals
1111
)
1212

1313
// Bucket represents an aggregated time-series data point.
@@ -30,7 +30,7 @@ type activeBucket struct {
3030
totalLatency float64
3131
}
3232

33-
// TimeSeriesAggregator collects rolling 1-hour bucketed counters at 10-second intervals.
33+
// TimeSeriesAggregator collects rolling 1-hour bucketed counters at 1-second intervals.
3434
type TimeSeriesAggregator struct {
3535
mu sync.Mutex
3636
buckets []Bucket

web/ui/src/api/client.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type {
22
TopListResponse,
33
NegativeCacheEntry,
44
QueryEntry,
5-
TimeSeriesBucket,
5+
TimeSeriesResponse,
66
UpdateInfo,
77
BlocklistStats,
88
BlocklistListEntry,
@@ -128,7 +128,7 @@ export const api = {
128128
stats: () => request<Record<string, unknown>>('/api/stats'),
129129

130130
timeseries: (window = '5m') =>
131-
request<{ buckets: TimeSeriesBucket[] }>(`/api/stats/timeseries?window=${window}`),
131+
request<TimeSeriesResponse>(`/api/stats/timeseries?window=${window}`),
132132

133133
recentQueries: (limit = 50) =>
134134
request<{ entries: QueryEntry[]; count: number }>(`/api/queries/recent?limit=${limit}`),

web/ui/src/api/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ export interface TimeSeriesBucket {
4444
avg_latency_ms: number
4545
}
4646

47+
export interface TimeSeriesResponse {
48+
window?: string
49+
bucket_seconds?: number
50+
buckets: TimeSeriesBucket[]
51+
}
52+
4753
export interface SystemProfileResponse {
4854
hostname: string
4955
network: {

web/ui/src/pages/DashboardPage.tsx

Lines changed: 84 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
CartesianGrid,
2626
} from 'recharts'
2727
import { api } from '@/api/client'
28-
import type { StatsResponse, TimeSeriesBucket, TopEntry, SystemProfileResponse, CacheEntry, TopListResponse } from '@/api/types'
28+
import type { StatsResponse, TimeSeriesBucket, TopEntry, SystemProfileResponse, CacheEntry, TopListResponse, TimeSeriesResponse } from '@/api/types'
2929
import { formatBytes, formatNumber, formatUptime, formatVersion } from '@/lib/utils'
3030
import { useQueryStream } from '@/hooks/useWebSocket'
3131

@@ -58,6 +58,8 @@ const CHART_SERIES_LABELS: Record<ChartSeriesKey, string> = {
5858
const CHART_SERIES_STORAGE_KEY = 'labyrinth.dashboard.chart_series_visibility'
5959
const TOP_PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const
6060
const TOP_WINDOW_LIMIT = 2000
61+
const CHART_HEARTBEAT_MS = 1000
62+
const TIMESERIES_POLL_MS = 5000
6163

6264
function movingAverage(values: number[], windowSize = 4): number[] {
6365
if (values.length === 0) return []
@@ -79,10 +81,6 @@ function ema(values: number[], alpha = 0.35): number[] {
7981
return out
8082
}
8183

82-
function toTenSecondBucket(tsMs: number): number {
83-
return Math.floor(tsMs / 10_000) * 10_000
84-
}
85-
8684
function defaultChartSeriesVisibility(): Record<ChartSeriesKey, boolean> {
8785
return {
8886
queries: true,
@@ -138,10 +136,12 @@ export default function DashboardPage() {
138136
const [stats, setStats] = useState<StatsResponse | null>(null)
139137
const [profile, setProfile] = useState<SystemProfileResponse | null>(null)
140138
const [timeseries, setTimeseries] = useState<TimeSeriesBucket[]>([])
139+
const [bucketSeconds, setBucketSeconds] = useState(10)
141140
const [timeWindow, setTimeWindow] = useState<TimeWindow>('15m')
142141
const [topClients, setTopClients] = useState<TopEntry[]>([])
143142
const [topDomains, setTopDomains] = useState<TopEntry[]>([])
144143
const [statsSnapshotAtMs, setStatsSnapshotAtMs] = useState(0)
144+
const [chartHeartbeatAtMs, setChartHeartbeatAtMs] = useState(() => Date.now())
145145
const [updatedAt, setUpdatedAt] = useState<Date | null>(null)
146146
const [autoRefresh, setAutoRefresh] = useState(true)
147147
const [refreshMs, setRefreshMs] = useState(15000)
@@ -276,13 +276,23 @@ export default function DashboardPage() {
276276
}
277277
}, [])
278278

279+
useEffect(() => {
280+
const tick = setInterval(() => {
281+
if (document.hidden) return
282+
setChartHeartbeatAtMs(Date.now())
283+
}, CHART_HEARTBEAT_MS)
284+
return () => clearInterval(tick)
285+
}, [])
286+
279287
useEffect(() => {
280288
let cancelled = false
281289
const fetchTimeseries = async () => {
282290
try {
283-
const tsData = await api.timeseries(timeWindow) as { buckets?: TimeSeriesBucket[] }
291+
const tsData = await api.timeseries(timeWindow) as TimeSeriesResponse
284292
if (cancelled) return
285293
setTimeseries(tsData?.buckets || [])
294+
const secs = Number(tsData?.bucket_seconds)
295+
setBucketSeconds(Number.isFinite(secs) && secs > 0 ? secs : 10)
286296
} catch {
287297
// keep last timeseries on transient errors
288298
}
@@ -292,7 +302,7 @@ export default function DashboardPage() {
292302
const interval = setInterval(() => {
293303
if (!autoRefresh || document.hidden) return
294304
void fetchTimeseries()
295-
}, 1000)
305+
}, TIMESERIES_POLL_MS)
296306
return () => {
297307
cancelled = true
298308
clearInterval(interval)
@@ -333,6 +343,27 @@ export default function DashboardPage() {
333343
}
334344
}, [streamQueries])
335345

346+
const liveSecondStats = useMemo(() => {
347+
const now = chartHeartbeatAtMs
348+
const cutoff = now - 1_000
349+
let queries = 0
350+
let errors = 0
351+
let latencyTotal = 0
352+
for (const q of streamQueries) {
353+
const ts = Date.parse(q.ts || '')
354+
if (!Number.isFinite(ts) || ts < cutoff || ts > now) continue
355+
queries++
356+
latencyTotal += Number(q.duration_ms || 0)
357+
if (q.blocked || (q.rcode && q.rcode !== 'NOERROR')) errors++
358+
}
359+
return {
360+
bucketMs: Math.floor(now / 1000) * 1000,
361+
queries,
362+
errors,
363+
avgLatencyMs: queries > 0 ? latencyTotal / queries : 0,
364+
}
365+
}, [streamQueries, chartHeartbeatAtMs])
366+
336367
const streamDelta = useMemo(() => {
337368
const queryTypeDelta: Record<string, number> = {}
338369
const rcodeDelta: Record<string, number> = {}
@@ -384,34 +415,70 @@ export default function DashboardPage() {
384415
: 0
385416

386417
const chartDataRaw = useMemo(() => {
387-
return (timeseries || [])
418+
const perBucketSeconds = Math.max(1, bucketSeconds)
419+
const rows = (timeseries || [])
388420
.map((b) => {
389421
const ts = b.timestamp || b.ts || ''
390422
const tsMs = Date.parse(ts)
391-
const bucketMs = Number.isFinite(tsMs) ? toTenSecondBucket(tsMs) : 0
423+
const bucketMs = Number.isFinite(tsMs) ? tsMs : 0
392424
const queryCount = b.queries || 0
425+
const qpsRaw = queryCount / perBucketSeconds
393426
return {
394427
ts,
395428
bucketMs,
396-
time: ts ? new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '',
429+
time: ts ? new Date(ts).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '',
397430
queries: queryCount,
398431
cacheHits: b.cache_hits || 0,
399432
cacheMisses: b.cache_misses || 0,
400433
errors: b.errors || 0,
401434
avgLatencyMs: b.avg_latency_ms || 0,
402-
qps: Number((queryCount / 10).toFixed(2)),
435+
qpsRaw,
403436
}
404437
})
405438
.filter((row) => row.ts)
406439
.sort((a, b) => a.bucketMs - b.bucketMs)
407-
}, [timeseries])
440+
if (streamConnected) {
441+
const liveRow = {
442+
ts: new Date(liveSecondStats.bucketMs).toISOString(),
443+
bucketMs: liveSecondStats.bucketMs,
444+
time: new Date(liveSecondStats.bucketMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }),
445+
queries: liveSecondStats.queries,
446+
cacheHits: 0,
447+
cacheMisses: 0,
448+
errors: liveSecondStats.errors,
449+
avgLatencyMs: liveSecondStats.avgLatencyMs,
450+
qpsRaw: liveSecondStats.queries,
451+
}
452+
if (rows.length === 0) {
453+
rows.push(liveRow)
454+
} else {
455+
const lastIdx = rows.length - 1
456+
const last = rows[lastIdx]
457+
if (liveRow.bucketMs <= last.bucketMs+perBucketSeconds*1000) {
458+
rows[lastIdx] = { ...last, ...liveRow }
459+
} else {
460+
rows.push(liveRow)
461+
}
462+
}
463+
}
464+
const maxPoints = timeWindow === '5m' ? 300 : timeWindow === '15m' ? 900 : 1200
465+
if (rows.length <= maxPoints) return rows
466+
const stride = Math.ceil(rows.length / maxPoints)
467+
const sampled = rows.filter((_, idx) => idx % stride === 0)
468+
const last = rows[rows.length - 1]
469+
if (sampled[sampled.length - 1]?.bucketMs !== last.bucketMs) sampled.push(last)
470+
return sampled
471+
}, [timeseries, bucketSeconds, liveSecondStats, streamConnected, timeWindow])
408472
const trendWindow = timeWindow === '5m' ? 4 : timeWindow === '15m' ? 6 : 8
409473
const queryTrend = movingAverage(chartDataRaw.map((x) => x.queries), trendWindow)
410474
const queryEMA = ema(chartDataRaw.map((x) => x.queries), 0.35)
475+
const qpsWindowPoints = Math.max(1, Math.round(10 / Math.max(1, bucketSeconds)))
476+
const qpsTrend = movingAverage(chartDataRaw.map((x) => x.qpsRaw), qpsWindowPoints)
411477
const chartData = chartDataRaw.map((x, i) => ({
412478
...x,
413479
queriesTrend: Number((queryTrend[i] || 0).toFixed(2)),
414480
queriesEMA: Number((queryEMA[i] || 0).toFixed(2)),
481+
qps: Number((qpsTrend[i] || 0).toFixed(2)),
415482
}))
416483

417484
const windowQueries = chartData.reduce((sum, row) => sum + row.queries, 0)
@@ -811,19 +878,19 @@ export default function DashboardPage() {
811878
}}
812879
/>
813880
{chartSeriesVisibility.queries && (
814-
<Area yAxisId="q" type="monotone" dataKey="queries" fill="url(#queriesAreaGrad)" stroke="#22d3ee" strokeWidth={1.8} name="Queries" />
881+
<Area yAxisId="q" type="monotone" dataKey="queries" fill="url(#queriesAreaGrad)" stroke="#22d3ee" strokeWidth={1.8} name="Queries" isAnimationActive={false} />
815882
)}
816883
{chartSeriesVisibility.moving_avg && (
817-
<Line yAxisId="q" type="linear" dataKey="queriesTrend" stroke="#f59e0b" strokeWidth={2.3} dot={false} name="Moving Avg" />
884+
<Line yAxisId="q" type="linear" dataKey="queriesTrend" stroke="#f59e0b" strokeWidth={2.3} dot={false} name="Moving Avg" isAnimationActive={false} connectNulls />
818885
)}
819886
{chartSeriesVisibility.ema && (
820-
<Line yAxisId="q" type="linear" dataKey="queriesEMA" stroke="#60a5fa" strokeWidth={2} dot={false} strokeDasharray="4 4" name="EMA" />
887+
<Line yAxisId="q" type="linear" dataKey="queriesEMA" stroke="#60a5fa" strokeWidth={2} dot={false} strokeDasharray="4 4" name="EMA" isAnimationActive={false} connectNulls />
821888
)}
822889
{chartSeriesVisibility.errors && (
823-
<Line yAxisId="q" type="linear" dataKey="errors" stroke="#ef4444" strokeWidth={1.8} dot={false} name="Errors" />
890+
<Line yAxisId="q" type="linear" dataKey="errors" stroke="#ef4444" strokeWidth={1.8} dot={false} name="Errors" isAnimationActive={false} connectNulls />
824891
)}
825892
{chartSeriesVisibility.qps && (
826-
<Line yAxisId="qps" type="linear" dataKey="qps" stroke="#14b8a6" strokeWidth={2} dot={false} name="QPS" />
893+
<Line yAxisId="qps" type="linear" dataKey="qps" stroke="#14b8a6" strokeWidth={2} dot={false} name="QPS" isAnimationActive={false} connectNulls />
827894
)}
828895
</ComposedChart>
829896
</ResponsiveContainer>

0 commit comments

Comments
 (0)