search for standard sites pub-search.waow.tech
search zig blog atproto

feat: add traffic sparkline to dashboard with 24h/7d/30d range toggle

expand timing ring buffer from 24 to 720 hours (30 days), add aggregate
traffic series endpoint, render as SVG sparkline on dashboard between
metrics and documents-by-platform sections.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+225 -8
+42 -8
backend/src/metrics/timing.zig
··· 18 const ENDPOINT_COUNT = @typeInfo(Endpoint).@"enum".fields.len; 19 const PERSIST_PATH = "/data/timing.bin"; 20 const PERSIST_PATH_HOURLY = "/data/timing_hourly.bin"; 21 - const HOURS_TO_KEEP = 24; 22 23 /// per-endpoint latency buffer 24 const LatencyBuffer = struct { ··· 225 return result; 226 } 227 228 - /// get time series for an endpoint (last 24 hours) 229 - pub fn getTimeSeries(endpoint: Endpoint) [HOURS_TO_KEEP]TimeSeriesPoint { 230 mutex.lock(); 231 defer mutex.unlock(); 232 ··· 234 235 const current_hour = getCurrentHour(); 236 const ep_buckets = hourly[@intFromEnum(endpoint)]; 237 - var result: [HOURS_TO_KEEP]TimeSeriesPoint = undefined; 238 239 // return hours in chronological order, oldest first 240 - for (0..HOURS_TO_KEEP) |i| { 241 - const hours_ago = HOURS_TO_KEEP - 1 - i; 242 const hour = current_hour - @as(i64, @intCast(hours_ago)) * 3600; 243 const idx = getHourIndex(hour); 244 const bucket = ep_buckets[idx]; ··· 258 } 259 260 /// get time series for all endpoints 261 - pub fn getAllTimeSeries() [ENDPOINT_COUNT][HOURS_TO_KEEP]TimeSeriesPoint { 262 - var result: [ENDPOINT_COUNT][HOURS_TO_KEEP]TimeSeriesPoint = undefined; 263 for (0..ENDPOINT_COUNT) |i| { 264 result[i] = getTimeSeries(@enumFromInt(i)); 265 } 266 return result; 267 }
··· 18 const ENDPOINT_COUNT = @typeInfo(Endpoint).@"enum".fields.len; 19 const PERSIST_PATH = "/data/timing.bin"; 20 const PERSIST_PATH_HOURLY = "/data/timing_hourly.bin"; 21 + const HOURS_TO_KEEP = 720; // 30 days 22 + const LATENCY_HISTORY_HOURS = 24; // per-endpoint latency charts stay at 24h 23 24 /// per-endpoint latency buffer 25 const LatencyBuffer = struct { ··· 226 return result; 227 } 228 229 + /// get time series for an endpoint (last 24 hours, for latency charts) 230 + pub fn getTimeSeries(endpoint: Endpoint) [LATENCY_HISTORY_HOURS]TimeSeriesPoint { 231 mutex.lock(); 232 defer mutex.unlock(); 233 ··· 235 236 const current_hour = getCurrentHour(); 237 const ep_buckets = hourly[@intFromEnum(endpoint)]; 238 + var result: [LATENCY_HISTORY_HOURS]TimeSeriesPoint = undefined; 239 240 // return hours in chronological order, oldest first 241 + for (0..LATENCY_HISTORY_HOURS) |i| { 242 + const hours_ago = LATENCY_HISTORY_HOURS - 1 - i; 243 const hour = current_hour - @as(i64, @intCast(hours_ago)) * 3600; 244 const idx = getHourIndex(hour); 245 const bucket = ep_buckets[idx]; ··· 259 } 260 261 /// get time series for all endpoints 262 + pub fn getAllTimeSeries() [ENDPOINT_COUNT][LATENCY_HISTORY_HOURS]TimeSeriesPoint { 263 + var result: [ENDPOINT_COUNT][LATENCY_HISTORY_HOURS]TimeSeriesPoint = undefined; 264 for (0..ENDPOINT_COUNT) |i| { 265 result[i] = getTimeSeries(@enumFromInt(i)); 266 } 267 return result; 268 } 269 + 270 + /// traffic data point (aggregate across all endpoints) 271 + pub const TrafficPoint = struct { 272 + hour: i64, 273 + count: u32, 274 + }; 275 + 276 + /// get aggregate traffic series (all endpoints summed, last 720 hours) 277 + pub fn getTrafficSeries() [HOURS_TO_KEEP]TrafficPoint { 278 + mutex.lock(); 279 + defer mutex.unlock(); 280 + 281 + ensureInitialized(); 282 + 283 + const current_hour = getCurrentHour(); 284 + var result: [HOURS_TO_KEEP]TrafficPoint = undefined; 285 + 286 + for (0..HOURS_TO_KEEP) |i| { 287 + const hours_ago = HOURS_TO_KEEP - 1 - i; 288 + const hour = current_hour - @as(i64, @intCast(hours_ago)) * 3600; 289 + const idx = getHourIndex(hour); 290 + 291 + var total: u32 = 0; 292 + for (0..ENDPOINT_COUNT) |ep| { 293 + const bucket = hourly[ep][idx]; 294 + if (bucket.hour == hour) { 295 + total += bucket.count; 296 + } 297 + } 298 + result[i] = .{ .hour = hour, .count = total }; 299 + } 300 + return result; 301 + }
+29
backend/src/server/dashboard.zig
··· 24 top_pubs_json: []const u8, 25 platforms_json: []const u8, 26 timing_json: []const u8, 27 }; 28 29 fn getRelayUrl() []const u8 { ··· 121 .top_pubs_json = try formatPubsJson(alloc, batch.get(4)), 122 .platforms_json = try formatPlatformsJson(alloc, batch.get(1)), 123 .timing_json = try formatTimingJson(alloc), 124 }; 125 } 126 ··· 180 .top_pubs_json = top_pubs_json, 181 .platforms_json = platforms_json, 182 .timing_json = try formatTimingJson(alloc), 183 }; 184 } 185 ··· 320 return try output.toOwnedSlice(); 321 } 322 323 /// Generate dashboard data as JSON for API endpoint 324 pub fn toJson(alloc: Allocator, data: Data) ![]const u8 { 325 var output: std.Io.Writer.Allocating = .init(alloc); ··· 370 try jw.objectField("timing"); 371 try jw.beginWriteRaw(); 372 try jw.writer.writeAll(data.timing_json); 373 jw.endWriteRaw(); 374 375 try jw.endObject();
··· 24 top_pubs_json: []const u8, 25 platforms_json: []const u8, 26 timing_json: []const u8, 27 + traffic_json: []const u8, 28 }; 29 30 fn getRelayUrl() []const u8 { ··· 122 .top_pubs_json = try formatPubsJson(alloc, batch.get(4)), 123 .platforms_json = try formatPlatformsJson(alloc, batch.get(1)), 124 .timing_json = try formatTimingJson(alloc), 125 + .traffic_json = try formatTrafficJson(alloc), 126 }; 127 } 128 ··· 182 .top_pubs_json = top_pubs_json, 183 .platforms_json = platforms_json, 184 .timing_json = try formatTimingJson(alloc), 185 + .traffic_json = try formatTrafficJson(alloc), 186 }; 187 } 188 ··· 323 return try output.toOwnedSlice(); 324 } 325 326 + fn formatTrafficJson(alloc: Allocator) ![]const u8 { 327 + const series = timing.getTrafficSeries(); 328 + 329 + var output: std.Io.Writer.Allocating = .init(alloc); 330 + errdefer output.deinit(); 331 + var jw: json.Stringify = .{ .writer = &output.writer }; 332 + 333 + try jw.beginArray(); 334 + for (series) |point| { 335 + try jw.beginObject(); 336 + try jw.objectField("hour"); 337 + try jw.write(point.hour); 338 + try jw.objectField("count"); 339 + try jw.write(point.count); 340 + try jw.endObject(); 341 + } 342 + try jw.endArray(); 343 + 344 + return try output.toOwnedSlice(); 345 + } 346 + 347 /// Generate dashboard data as JSON for API endpoint 348 pub fn toJson(alloc: Allocator, data: Data) ![]const u8 { 349 var output: std.Io.Writer.Allocating = .init(alloc); ··· 394 try jw.objectField("timing"); 395 try jw.beginWriteRaw(); 396 try jw.writer.writeAll(data.timing_json); 397 + jw.endWriteRaw(); 398 + 399 + try jw.objectField("trafficHistory"); 400 + try jw.beginWriteRaw(); 401 + try jw.writer.writeAll(data.traffic_json); 402 jw.endWriteRaw(); 403 404 try jw.endObject();
+39
site/dashboard.css
··· 148 .timing-value { color: var(--text); } 149 .timing-value .dim { color: var(--text-dim); } 150 151 .latency-grid { 152 display: grid; 153 grid-template-columns: 1fr 1fr 1fr;
··· 148 .timing-value { color: var(--text); } 149 .timing-value .dim { color: var(--text-dim); } 150 151 + .traffic-sparkline { 152 + position: relative; 153 + height: 60px; 154 + } 155 + .traffic-tooltip { 156 + position: absolute; 157 + bottom: 2px; 158 + right: 4px; 159 + font-size: 9px; 160 + color: var(--text-dim); 161 + white-space: nowrap; 162 + pointer-events: none; 163 + opacity: 0; 164 + transition: opacity 0.1s; 165 + background: var(--tooltip-bg); 166 + padding: 1px 4px; 167 + } 168 + .traffic-range-btns { 169 + margin-left: 0.5rem; 170 + } 171 + .traffic-range-btns button { 172 + font-family: monospace; 173 + font-size: 10px; 174 + background: none; 175 + border: 1px solid var(--border); 176 + color: var(--text-dim); 177 + padding: 1px 6px; 178 + cursor: pointer; 179 + margin-left: 2px; 180 + } 181 + .traffic-range-btns button:hover { 182 + border-color: var(--border-focus); 183 + color: var(--text-secondary); 184 + } 185 + .traffic-range-btns button.active { 186 + border-color: #1B7340; 187 + color: #1B7340; 188 + } 189 + 190 .latency-grid { 191 display: grid; 192 grid-template-columns: 1fr 1fr 1fr;
+12
site/dashboard.html
··· 45 </section> 46 47 <section> 48 <div class="section-title">documents by platform</div> 49 <div class="chart-box"> 50 <div id="platforms"></div>
··· 45 </section> 46 47 <section> 48 + <div class="section-title"> 49 + traffic 50 + <span class="traffic-range-btns" id="traffic-range"> 51 + <button data-range="24h">24h</button> 52 + <button data-range="7d" class="active">7d</button> 53 + <button data-range="30d">30d</button> 54 + </span> 55 + </div> 56 + <div class="traffic-sparkline" id="traffic-sparkline"></div> 57 + </section> 58 + 59 + <section> 60 <div class="section-title">documents by platform</div> 61 <div class="chart-box"> 62 <div id="platforms"></div>
+103
site/dashboard.js
··· 245 }); 246 } 247 248 function formatTimestamp(hour) { 249 const d = new Date(hour * 1000); 250 const h = d.getHours(); ··· 289 290 renderPlatforms(data.platforms); 291 renderTiming(data.timing); 292 renderTimeline(data.timeline); 293 renderPubs(data.topPubs); 294 renderTags(data.tags);
··· 245 }); 246 } 247 248 + // traffic sparkline 249 + let trafficData = []; 250 + let currentRange = '7d'; 251 + const RANGE_HOURS = { '24h': 24, '7d': 168, '30d': 720 }; 252 + 253 + function renderTrafficSparkline(history) { 254 + if (!history) return; 255 + trafficData = history; 256 + drawTrafficSvg(); 257 + } 258 + 259 + function drawTrafficSvg() { 260 + const container = document.getElementById('traffic-sparkline'); 261 + if (!container) return; 262 + container.innerHTML = ''; 263 + 264 + const hours = RANGE_HOURS[currentRange] || 168; 265 + const data = trafficData.slice(-hours); 266 + if (data.length === 0) return; 267 + 268 + const max = Math.max(...data.map(d => d.count), 1); 269 + const w = container.clientWidth || 560; 270 + const h = 60; 271 + 272 + const ns = 'http://www.w3.org/2000/svg'; 273 + const svg = document.createElementNS(ns, 'svg'); 274 + svg.setAttribute('width', w); 275 + svg.setAttribute('height', h); 276 + svg.setAttribute('viewBox', '0 0 ' + w + ' ' + h); 277 + svg.style.display = 'block'; 278 + 279 + const pad = { top: 2, right: 2, bottom: 2, left: 2 }; 280 + const cw = w - pad.left - pad.right; 281 + const ch = h - pad.top - pad.bottom; 282 + 283 + const points = data.map((d, i) => { 284 + const x = pad.left + (i / Math.max(data.length - 1, 1)) * cw; 285 + const y = pad.top + ch - (d.count / max) * ch; 286 + return { x, y, d }; 287 + }); 288 + 289 + // filled area 290 + const polyPoints = [pad.left + ',' + (pad.top + ch)] 291 + .concat(points.map(p => p.x + ',' + p.y)) 292 + .concat([(pad.left + cw) + ',' + (pad.top + ch)]); 293 + const polygon = document.createElementNS(ns, 'polygon'); 294 + polygon.setAttribute('points', polyPoints.join(' ')); 295 + polygon.setAttribute('fill', '#1B7340'); 296 + polygon.setAttribute('opacity', '0.15'); 297 + svg.appendChild(polygon); 298 + 299 + // line 300 + const linePoints = points.map(p => p.x + ',' + p.y).join(' '); 301 + const polyline = document.createElementNS(ns, 'polyline'); 302 + polyline.setAttribute('points', linePoints); 303 + polyline.setAttribute('fill', 'none'); 304 + polyline.setAttribute('stroke', '#1B7340'); 305 + polyline.setAttribute('stroke-width', '1.5'); 306 + svg.appendChild(polyline); 307 + 308 + // hover overlay 309 + const overlay = document.createElementNS(ns, 'rect'); 310 + overlay.setAttribute('width', w); 311 + overlay.setAttribute('height', h); 312 + overlay.setAttribute('fill', 'transparent'); 313 + svg.appendChild(overlay); 314 + 315 + container.appendChild(svg); 316 + 317 + // tooltip 318 + const tooltip = document.createElement('div'); 319 + tooltip.className = 'traffic-tooltip'; 320 + container.appendChild(tooltip); 321 + 322 + svg.addEventListener('mousemove', function(e) { 323 + const rect = svg.getBoundingClientRect(); 324 + const mouseX = e.clientX - rect.left; 325 + const idx = Math.round((mouseX - pad.left) / cw * (data.length - 1)); 326 + const ci = Math.max(0, Math.min(data.length - 1, idx)); 327 + const pt = data[ci]; 328 + if (pt) { 329 + const d = new Date(pt.hour * 1000); 330 + const label = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' + formatTimestamp(pt.hour); 331 + tooltip.textContent = label + ' · ' + pt.count + ' req'; 332 + tooltip.style.opacity = '1'; 333 + } 334 + }); 335 + svg.addEventListener('mouseleave', function() { 336 + tooltip.style.opacity = '0'; 337 + }); 338 + } 339 + 340 + // range button handler 341 + document.getElementById('traffic-range')?.addEventListener('click', function(e) { 342 + const btn = e.target.closest('button[data-range]'); 343 + if (!btn) return; 344 + currentRange = btn.dataset.range; 345 + this.querySelectorAll('button').forEach(b => b.classList.remove('active')); 346 + btn.classList.add('active'); 347 + drawTrafficSvg(); 348 + }); 349 + 350 function formatTimestamp(hour) { 351 const d = new Date(hour * 1000); 352 const h = d.getHours(); ··· 391 392 renderPlatforms(data.platforms); 393 renderTiming(data.timing); 394 + renderTrafficSparkline(data.trafficHistory); 395 renderTimeline(data.timeline); 396 renderPubs(data.topPubs); 397 renderTags(data.tags);