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

Configure Feed

Select the types of activity you want to include in your feed.

feat: add 24h latency time series + rename activity chart

- timing now stores hourly buckets for last 24 hours
- dashboard API returns history array per endpoint
- frontend renders mini bar charts under each endpoint
- renamed "activity" to "documents indexed" for clarity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

zzstoatzz 19b24a29 00f81604

+173 -4
+18
backend/src/dashboard.zig
··· 252 252 253 253 fn formatTimingJson(alloc: Allocator) ![]const u8 { 254 254 const all_timing = timing.getAllStats(); 255 + const all_series = timing.getAllTimeSeries(); 255 256 256 257 var output: std.Io.Writer.Allocating = .init(alloc); 257 258 errdefer output.deinit(); ··· 260 261 try jw.beginObject(); 261 262 inline for (@typeInfo(timing.Endpoint).@"enum".fields, 0..) |field, i| { 262 263 const t = all_timing[i]; 264 + const series = all_series[i]; 263 265 try jw.objectField(field.name); 264 266 try jw.beginObject(); 265 267 try jw.objectField("count"); ··· 274 276 try jw.write(t.p99_ms); 275 277 try jw.objectField("max_ms"); 276 278 try jw.write(t.max_ms); 279 + // add 24h time series 280 + try jw.objectField("history"); 281 + try jw.beginArray(); 282 + for (series) |point| { 283 + try jw.beginObject(); 284 + try jw.objectField("hour"); 285 + try jw.write(point.hour); 286 + try jw.objectField("count"); 287 + try jw.write(point.count); 288 + try jw.objectField("avg_ms"); 289 + try jw.write(point.avg_ms); 290 + try jw.objectField("max_ms"); 291 + try jw.write(point.max_ms); 292 + try jw.endObject(); 293 + } 294 + try jw.endArray(); 277 295 try jw.endObject(); 278 296 } 279 297 try jw.endObject();
+118 -1
backend/src/timing.zig
··· 15 15 const SAMPLE_COUNT = 1000; 16 16 const ENDPOINT_COUNT = @typeInfo(Endpoint).@"enum".fields.len; 17 17 const PERSIST_PATH = "/data/timing.bin"; 18 + const PERSIST_PATH_HOURLY = "/data/timing_hourly.bin"; 18 19 const PERSIST_INTERVAL = 100; // save every N records 20 + const HOURS_TO_KEEP = 24; 19 21 20 22 /// per-endpoint latency buffer 21 23 const LatencyBuffer = struct { ··· 32 34 } 33 35 }; 34 36 37 + /// hourly bucket for time series 38 + const HourlyBucket = struct { 39 + hour: i64 = 0, // unix timestamp of hour start 40 + count: u32 = 0, 41 + sum_us: u64 = 0, 42 + max_us: u32 = 0, 43 + 44 + fn record(self: *HourlyBucket, hour: i64, latency_us: u32) void { 45 + if (self.hour != hour) { 46 + // new hour, reset 47 + self.hour = hour; 48 + self.count = 0; 49 + self.sum_us = 0; 50 + self.max_us = 0; 51 + } 52 + self.count += 1; 53 + self.sum_us += latency_us; 54 + if (latency_us > self.max_us) self.max_us = latency_us; 55 + } 56 + }; 57 + 58 + /// time series data point for API response 59 + pub const TimeSeriesPoint = struct { 60 + hour: i64, 61 + count: u32, 62 + avg_ms: f64, 63 + max_ms: f64, 64 + }; 65 + 35 66 /// computed stats for an endpoint 36 67 pub const EndpointStats = struct { 37 68 count: u64 = 0, ··· 43 74 }; 44 75 45 76 var buffers: [ENDPOINT_COUNT]LatencyBuffer = [_]LatencyBuffer{.{}} ** ENDPOINT_COUNT; 77 + var hourly: [ENDPOINT_COUNT][HOURS_TO_KEEP]HourlyBucket = [_][HOURS_TO_KEEP]HourlyBucket{[_]HourlyBucket{.{}} ** HOURS_TO_KEEP} ** ENDPOINT_COUNT; 46 78 var mutex: std.Thread.Mutex = .{}; 47 79 var records_since_persist: u32 = 0; 48 80 var initialized: bool = false; 49 81 82 + fn getCurrentHour() i64 { 83 + const now_s = @divFloor(std.time.timestamp(), 3600) * 3600; 84 + return now_s; 85 + } 86 + 87 + fn getHourIndex(hour: i64) usize { 88 + // use hour as index into ring buffer 89 + return @intCast(@mod(@divFloor(hour, 3600), HOURS_TO_KEEP)); 90 + } 91 + 50 92 /// record a request latency (call after request completes) 51 93 pub fn record(endpoint: Endpoint, start_time: i64) void { 52 94 const now = std.time.microTimestamp(); 53 95 const elapsed_us: u32 = @intCast(@max(0, now - start_time)); 96 + const current_hour = getCurrentHour(); 97 + const hour_idx = getHourIndex(current_hour); 54 98 55 99 mutex.lock(); 56 100 defer mutex.unlock(); ··· 58 102 if (!initialized) { 59 103 initialized = true; 60 104 loadLocked(); 105 + loadHourlyLocked(); 61 106 } 62 107 63 - buffers[@intFromEnum(endpoint)].record(elapsed_us); 108 + const ep_idx = @intFromEnum(endpoint); 109 + buffers[ep_idx].record(elapsed_us); 110 + hourly[ep_idx][hour_idx].record(current_hour, elapsed_us); 64 111 65 112 // persist periodically 66 113 records_since_persist += 1; 67 114 if (records_since_persist >= PERSIST_INTERVAL) { 68 115 records_since_persist = 0; 69 116 persistLocked(); 117 + persistHourlyLocked(); 70 118 } 71 119 } 72 120 ··· 109 157 } 110 158 } 111 159 160 + fn loadHourlyLocked() void { 161 + const file = std.fs.openFileAbsolute(PERSIST_PATH_HOURLY, .{}) catch return; 162 + defer file.close(); 163 + 164 + const bucket_size = @sizeOf(HourlyBucket); 165 + const total_size = ENDPOINT_COUNT * HOURS_TO_KEEP * bucket_size; 166 + var file_buf: [total_size]u8 = undefined; 167 + const bytes_read = file.readAll(&file_buf) catch return; 168 + if (bytes_read != total_size) return; 169 + 170 + var offset: usize = 0; 171 + for (&hourly) |*ep_buckets| { 172 + for (ep_buckets) |*bucket| { 173 + bucket.* = std.mem.bytesToValue(HourlyBucket, file_buf[offset..][0..bucket_size]); 174 + offset += bucket_size; 175 + } 176 + } 177 + } 178 + 179 + fn persistHourlyLocked() void { 180 + const file = std.fs.createFileAbsolute(PERSIST_PATH_HOURLY, .{}) catch return; 181 + defer file.close(); 182 + 183 + for (hourly) |ep_buckets| { 184 + for (ep_buckets) |bucket| { 185 + file.writeAll(std.mem.asBytes(&bucket)) catch return; 186 + } 187 + } 188 + } 189 + 112 190 /// get stats for a specific endpoint 113 191 pub fn getStats(endpoint: Endpoint) EndpointStats { 114 192 mutex.lock(); ··· 144 222 } 145 223 return result; 146 224 } 225 + 226 + /// get time series for an endpoint (last 24 hours) 227 + pub fn getTimeSeries(endpoint: Endpoint) [HOURS_TO_KEEP]TimeSeriesPoint { 228 + mutex.lock(); 229 + defer mutex.unlock(); 230 + 231 + const current_hour = getCurrentHour(); 232 + const ep_buckets = hourly[@intFromEnum(endpoint)]; 233 + var result: [HOURS_TO_KEEP]TimeSeriesPoint = undefined; 234 + 235 + // return hours in chronological order, oldest first 236 + for (0..HOURS_TO_KEEP) |i| { 237 + const hours_ago = HOURS_TO_KEEP - 1 - i; 238 + const hour = current_hour - @as(i64, @intCast(hours_ago)) * 3600; 239 + const idx = getHourIndex(hour); 240 + const bucket = ep_buckets[idx]; 241 + 242 + if (bucket.hour == hour and bucket.count > 0) { 243 + result[i] = .{ 244 + .hour = hour, 245 + .count = bucket.count, 246 + .avg_ms = @as(f64, @floatFromInt(bucket.sum_us)) / @as(f64, @floatFromInt(bucket.count)) / 1000.0, 247 + .max_ms = @as(f64, @floatFromInt(bucket.max_us)) / 1000.0, 248 + }; 249 + } else { 250 + result[i] = .{ .hour = hour, .count = 0, .avg_ms = 0, .max_ms = 0 }; 251 + } 252 + } 253 + return result; 254 + } 255 + 256 + /// get time series for all endpoints 257 + pub fn getAllTimeSeries() [ENDPOINT_COUNT][HOURS_TO_KEEP]TimeSeriesPoint { 258 + var result: [ENDPOINT_COUNT][HOURS_TO_KEEP]TimeSeriesPoint = undefined; 259 + for (0..ENDPOINT_COUNT) |i| { 260 + result[i] = getTimeSeries(@enumFromInt(i)); 261 + } 262 + return result; 263 + }
+19 -2
site/dashboard.css
··· 109 109 justify-content: space-between; 110 110 font-size: 12px; 111 111 padding: 0.25rem 0; 112 - border-bottom: 1px solid #1a1a1a; 113 112 } 114 - .timing-row:last-child { border-bottom: none; } 115 113 .timing-name { color: #888; } 116 114 .timing-value { color: #ccc; } 117 115 .timing-value .dim { color: #555; } 116 + 117 + .timing-chart { 118 + display: flex; 119 + align-items: flex-end; 120 + gap: 1px; 121 + height: 24px; 122 + margin-bottom: 0.75rem; 123 + padding-bottom: 0.5rem; 124 + border-bottom: 1px solid #1a1a1a; 125 + } 126 + .timing-chart:last-child { border-bottom: none; margin-bottom: 0; } 127 + 128 + .timing-bar { 129 + flex: 1; 130 + background: #1B7340; 131 + min-width: 2px; 132 + opacity: 0.6; 133 + } 134 + .timing-bar:hover { opacity: 1; } 118 135 119 136 .tags { 120 137 display: flex;
+1 -1
site/dashboard.html
··· 44 44 </section> 45 45 46 46 <section> 47 - <div class="section-title">activity (last 30 days)</div> 47 + <div class="section-title">documents indexed (last 30 days)</div> 48 48 <div class="chart-box"> 49 49 <div class="timeline" id="timeline"></div> 50 50 </div>
+17
site/dashboard.js
··· 104 104 formatMs(t.p95_ms) + ' <span class="dim">p95</span></span>'; 105 105 } 106 106 el.appendChild(row); 107 + 108 + // add 24h mini chart if history available 109 + if (t.history && t.history.length > 0) { 110 + const chart = document.createElement('div'); 111 + chart.className = 'timing-chart'; 112 + const maxCount = Math.max(...t.history.map(h => h.count), 1); 113 + t.history.forEach(h => { 114 + const bar = document.createElement('div'); 115 + bar.className = 'timing-bar'; 116 + const height = h.count > 0 ? Math.max((h.count / maxCount) * 100, 5) : 0; 117 + bar.style.height = height + '%'; 118 + const hourStr = new Date(h.hour * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}); 119 + bar.title = hourStr + ': ' + h.count + ' req, ' + formatMs(h.avg_ms) + ' avg'; 120 + chart.appendChild(bar); 121 + }); 122 + el.appendChild(chart); 123 + } 107 124 }); 108 125 } 109 126