public uptime monitoring + (soon) observability with events saved to PDS

graphsssss

Changed files
+421 -3
web
+3 -1
web/package.json
··· 9 9 }, 10 10 "dependencies": { 11 11 "@atcute/atproto": "^3.1.9", 12 - "@atcute/client": "^4.0.5" 12 + "@atcute/client": "^4.0.5", 13 + "chart.js": "^4.5.1", 14 + "chartjs-plugin-annotation": "^3.1.0" 13 15 }, 14 16 "devDependencies": { 15 17 "@sveltejs/vite-plugin-svelte": "^5.0.2",
+418 -2
web/src/lib/uptime-display.svelte
··· 1 1 <script lang="ts"> 2 2 import type { UptimeCheckRecord } from './types.ts'; 3 + import { onMount } from 'svelte'; 4 + import { Chart, LineController, LineElement, PointElement, LinearScale, TimeScale, Title, Tooltip, CategoryScale } from 'chart.js'; 5 + import annotationPlugin from 'chartjs-plugin-annotation'; 6 + 7 + Chart.register(LineController, LineElement, PointElement, LinearScale, TimeScale, Title, Tooltip, CategoryScale, annotationPlugin); 3 8 4 9 interface Props { 5 10 checks: UptimeCheckRecord[]; 6 11 } 7 12 8 13 const { checks }: Props = $props(); 14 + 15 + // Track which service charts are expanded 16 + let expandedCharts = $state(new Set<string>()); 17 + // Track which tab is active for each service (derivative or uptime) 18 + let activeTab = $state(new Map<string, 'derivative' | 'uptime'>()); 9 19 10 20 // group checks by group name, then by region, then by service 11 21 const groupedData = $derived(() => { ··· 68 78 timeStyle: 'short', 69 79 }).format(date); 70 80 } 81 + 82 + // Calculate derivative (change in response time between checks) 83 + function calculateDerivative(checks: UptimeCheckRecord[]): { labels: string[], values: number[], colors: string[], pointRadii: number[] } { 84 + if (checks.length < 2) { 85 + return { labels: [], values: [], colors: [], pointRadii: [] }; 86 + } 87 + 88 + // Reverse to get oldest first for proper time ordering 89 + const sorted = [...checks].reverse(); 90 + const labels: string[] = []; 91 + const values: number[] = []; 92 + const colors: string[] = []; 93 + 94 + for (let i = 1; i < sorted.length; i++) { 95 + const prev = sorted[i - 1]; 96 + const curr = sorted[i]; 97 + 98 + // Skip if either check is down 99 + if (prev.value.status !== 'up' || curr.value.status !== 'up') { 100 + continue; 101 + } 102 + 103 + const change = curr.value.responseTime - prev.value.responseTime; 104 + labels.push(formatTimestamp(curr.indexedAt)); 105 + values.push(change); 106 + 107 + // Color code based on brightness: brighter = better (faster), darker = worse (slower) 108 + const absChange = Math.abs(change); 109 + const maxChange = 500; // Normalize around 500ms as reference 110 + const intensity = Math.min(absChange / maxChange, 1); 111 + 112 + if (change < 0) { 113 + // Faster (good) - brighter pink 114 + const brightness = 0.65 + (intensity * 0.25); // 0.65 to 0.9 115 + colors.push(`oklch(${brightness} 0.15 345)`); 116 + } else { 117 + // Slower (bad) - darker pink 118 + const brightness = 0.65 - (intensity * 0.35); // 0.65 to 0.3 119 + colors.push(`oklch(${brightness} 0.15 345)`); 120 + } 121 + } 122 + 123 + // Calculate standard deviation 124 + const mean = values.reduce((sum, v) => sum + Math.abs(v), 0) / values.length; 125 + const variance = values.reduce((sum, v) => sum + Math.pow(Math.abs(v) - mean, 2), 0) / values.length; 126 + const stdDev = Math.sqrt(variance); 127 + 128 + // Determine which points to show based on std deviation 129 + const pointRadii = values.map((value, index) => { 130 + const absValue = Math.abs(value); 131 + // Show point if it's a significant spike (> 1 std dev) OR every other point 132 + if (absValue > mean + stdDev) { 133 + return 5; // Larger dot for significant spikes 134 + } else if (index % 2 === 0) { 135 + return 3; // Show every other point at normal size 136 + } else { 137 + return 0; // Hide this point 138 + } 139 + }); 140 + 141 + return { labels, values, colors, pointRadii }; 142 + } 143 + 144 + function toggleChart(serviceKey: string) { 145 + const newSet = new Set(expandedCharts); 146 + if (newSet.has(serviceKey)) { 147 + newSet.delete(serviceKey); 148 + } else { 149 + newSet.add(serviceKey); 150 + // Default to uptime tab when opening 151 + if (!activeTab.has(serviceKey)) { 152 + const newTabMap = new Map(activeTab); 153 + newTabMap.set(serviceKey, 'uptime'); 154 + activeTab = newTabMap; 155 + } 156 + } 157 + expandedCharts = newSet; 158 + } 159 + 160 + function setActiveTab(serviceKey: string, tab: 'derivative' | 'uptime') { 161 + const newTabMap = new Map(activeTab); 162 + newTabMap.set(serviceKey, tab); 163 + activeTab = newTabMap; 164 + } 165 + 166 + // Calculate uptime percentage in hourly rolling windows 167 + function calculateHourlyUptime(checks: UptimeCheckRecord[]): { labels: string[], values: number[], colors: string[] } { 168 + if (checks.length === 0) { 169 + return { labels: [], values: [], colors: [] }; 170 + } 171 + 172 + const sorted = [...checks].reverse(); // Oldest first 173 + const hourlyBuckets = new Map<number, { total: number, up: number }>(); 174 + 175 + // Group checks into hourly buckets 176 + for (const check of sorted) { 177 + const hourTimestamp = Math.floor(check.indexedAt.getTime() / (1000 * 60 * 60)); // Round to hour 178 + 179 + if (!hourlyBuckets.has(hourTimestamp)) { 180 + hourlyBuckets.set(hourTimestamp, { total: 0, up: 0 }); 181 + } 182 + 183 + const bucket = hourlyBuckets.get(hourTimestamp)!; 184 + bucket.total++; 185 + if (check.value.status === 'up') { 186 + bucket.up++; 187 + } 188 + } 189 + 190 + // Convert to arrays sorted by time 191 + const sortedBuckets = Array.from(hourlyBuckets.entries()).sort((a, b) => a[0] - b[0]); 192 + 193 + const labels: string[] = []; 194 + const values: number[] = []; 195 + const colors: string[] = []; 196 + 197 + for (const [hourTimestamp, bucket] of sortedBuckets) { 198 + const date = new Date(hourTimestamp * 1000 * 60 * 60); 199 + const percentage = (bucket.up / bucket.total) * 100; 200 + 201 + labels.push(new Intl.DateTimeFormat('en-US', { 202 + month: 'short', 203 + day: 'numeric', 204 + hour: 'numeric' 205 + }).format(date)); 206 + 207 + values.push(percentage); 208 + 209 + // Gradient: 100% = pink (good), <99% = purple-ish, <95% = dark blue (bad) 210 + if (percentage >= 99.5) { 211 + colors.push('oklch(0.78 0.15 345)'); // Pink - good! 212 + } else if (percentage >= 95) { 213 + colors.push('oklch(0.65 0.12 285)'); // Purple - concerning 214 + } else { 215 + colors.push('oklch(0.32 0.04 285)'); // Dark blue - bad 216 + } 217 + } 218 + 219 + return { labels, values, colors }; 220 + } 221 + 222 + function createDerivativeChart(canvas: HTMLCanvasElement, checks: UptimeCheckRecord[]) { 223 + const derivative = calculateDerivative(checks); 224 + 225 + if (derivative.values.length === 0) { 226 + return; 227 + } 228 + 229 + // Get CSS variables for theme-aware colors 230 + const rootStyle = getComputedStyle(document.documentElement); 231 + const foregroundColor = rootStyle.getPropertyValue('--foreground').trim() || 'oklch(0.18 0.01 30)'; 232 + const mutedForegroundColor = rootStyle.getPropertyValue('--muted-foreground').trim() || 'oklch(0.42 0.015 30)'; 233 + const borderColor = rootStyle.getPropertyValue('--border').trim() || 'oklch(0.75 0.015 30)'; 234 + 235 + // Calculate background annotations for problem areas 236 + const annotations: any = {}; 237 + const threshold = 100; // 100ms change is "significant" 238 + 239 + derivative.values.forEach((value, index) => { 240 + if (value > threshold) { 241 + // Significant slowdown - add red background 242 + annotations[`problem-${index}`] = { 243 + type: 'box', 244 + xMin: index - 0.5, 245 + xMax: index + 0.5, 246 + backgroundColor: 'rgba(239, 68, 68, 0.15)', // Red with transparency 247 + borderWidth: 0 248 + }; 249 + } 250 + }); 251 + 252 + const chart = new Chart(canvas, { 253 + type: 'line', 254 + data: { 255 + labels: derivative.labels, 256 + datasets: [{ 257 + label: 'Response Time Change (ms)', 258 + data: derivative.values, 259 + borderColor: 'oklch(0.78 0.15 345)', // Pink line 260 + backgroundColor: 'transparent', 261 + tension: 0.4, 262 + pointBackgroundColor: 'oklch(0.78 0.15 345)', 263 + pointBorderColor: 'oklch(0.78 0.15 345)', 264 + pointRadius: derivative.pointRadii, 265 + pointHoverRadius: 6, 266 + fill: false, 267 + }] 268 + }, 269 + options: { 270 + responsive: true, 271 + maintainAspectRatio: false, 272 + plugins: { 273 + tooltip: { 274 + backgroundColor: 'oklch(0.20 0.02 285)', 275 + titleColor: 'oklch(0.98 0.00 285)', 276 + bodyColor: 'oklch(0.98 0.00 285)', 277 + borderColor: 'oklch(0.56 0.08 220)', 278 + borderWidth: 1, 279 + callbacks: { 280 + label: (context) => { 281 + const value = context.parsed.y; 282 + const direction = value > 0 ? 'slower' : 'faster'; 283 + return `${Math.abs(value).toFixed(0)}ms ${direction}`; 284 + } 285 + } 286 + }, 287 + title: { 288 + display: true, 289 + text: 'latency spikes (change in response time) • lower is better', 290 + color: foregroundColor, 291 + font: { 292 + size: 14, 293 + weight: 'normal' 294 + } 295 + }, 296 + annotation: { 297 + annotations 298 + } 299 + }, 300 + scales: { 301 + y: { 302 + ticks: { 303 + color: mutedForegroundColor, 304 + callback: (value) => `${value}ms` 305 + }, 306 + grid: { 307 + color: borderColor 308 + } 309 + }, 310 + x: { 311 + ticks: { 312 + color: mutedForegroundColor, 313 + maxRotation: 45, 314 + minRotation: 45, 315 + autoSkip: true, 316 + maxTicksLimit: 6, 317 + callback: function(value, index, ticks) { 318 + // Always show the last (latest) timestamp 319 + if (index === ticks.length - 1) { 320 + return this.getLabelForValue(value); 321 + } 322 + // Show evenly distributed labels 323 + const skipRate = Math.ceil(ticks.length / 6); 324 + return index % skipRate === 0 ? this.getLabelForValue(value) : ''; 325 + } 326 + }, 327 + grid: { 328 + color: borderColor 329 + } 330 + } 331 + } 332 + } 333 + }); 334 + 335 + return { 336 + destroy() { 337 + chart.destroy(); 338 + } 339 + }; 340 + } 341 + 342 + function createUptimeChart(canvas: HTMLCanvasElement, checks: UptimeCheckRecord[]) { 343 + const uptimeData = calculateHourlyUptime(checks); 344 + 345 + if (uptimeData.values.length === 0) { 346 + return; 347 + } 348 + 349 + // Get CSS variables for theme-aware colors 350 + const rootStyle = getComputedStyle(document.documentElement); 351 + const foregroundColor = rootStyle.getPropertyValue('--foreground').trim() || 'oklch(0.18 0.01 30)'; 352 + const mutedForegroundColor = rootStyle.getPropertyValue('--muted-foreground').trim() || 'oklch(0.42 0.015 30)'; 353 + const borderColor = rootStyle.getPropertyValue('--border').trim() || 'oklch(0.75 0.015 30)'; 354 + 355 + const chart = new Chart(canvas, { 356 + type: 'line', 357 + data: { 358 + labels: uptimeData.labels, 359 + datasets: [{ 360 + label: 'Uptime %', 361 + data: uptimeData.values, 362 + borderColor: 'oklch(0.78 0.15 345)', // Pink 363 + backgroundColor: (context) => { 364 + const ctx = context.chart.ctx; 365 + const gradient = ctx.createLinearGradient(0, 0, 0, 250); 366 + gradient.addColorStop(0, 'oklch(0.78 0.15 345 / 0.4)'); // Pink at top (100%) 367 + gradient.addColorStop(0.5, 'oklch(0.65 0.12 285 / 0.2)'); // Purple middle (~97%) 368 + gradient.addColorStop(1, 'oklch(0.32 0.04 285 / 0.1)'); // Dark blue at bottom (<95%) 369 + return gradient; 370 + }, 371 + tension: 0.4, 372 + pointBackgroundColor: uptimeData.colors, 373 + pointBorderColor: uptimeData.colors, 374 + pointRadius: 3, 375 + pointHoverRadius: 6, 376 + fill: true, 377 + }] 378 + }, 379 + options: { 380 + responsive: true, 381 + maintainAspectRatio: false, 382 + plugins: { 383 + tooltip: { 384 + backgroundColor: 'oklch(0.20 0.02 285)', 385 + titleColor: 'oklch(0.98 0.00 285)', 386 + bodyColor: 'oklch(0.98 0.00 285)', 387 + borderColor: 'oklch(0.78 0.15 345)', 388 + borderWidth: 1, 389 + callbacks: { 390 + label: (context) => { 391 + const value = context.parsed.y; 392 + return `${value.toFixed(2)}% uptime`; 393 + } 394 + } 395 + }, 396 + title: { 397 + display: true, 398 + text: 'uptime percentage (hourly)', 399 + color: foregroundColor, 400 + font: { 401 + size: 14, 402 + weight: 'normal' 403 + } 404 + } 405 + }, 406 + scales: { 407 + y: { 408 + min: 0, 409 + max: 100, 410 + ticks: { 411 + color: mutedForegroundColor, 412 + callback: (value) => `${value}%` 413 + }, 414 + grid: { 415 + color: borderColor 416 + } 417 + }, 418 + x: { 419 + ticks: { 420 + color: mutedForegroundColor, 421 + maxRotation: 45, 422 + minRotation: 45, 423 + autoSkip: true, 424 + maxTicksLimit: 6, 425 + callback: function(value, index, ticks) { 426 + // Always show the last (latest) timestamp 427 + if (index === ticks.length - 1) { 428 + return this.getLabelForValue(value); 429 + } 430 + // Show evenly distributed labels 431 + const skipRate = Math.ceil(ticks.length / 6); 432 + return index % skipRate === 0 ? this.getLabelForValue(value) : ''; 433 + } 434 + }, 435 + grid: { 436 + color: borderColor 437 + } 438 + } 439 + } 440 + } 441 + }); 442 + 443 + return { 444 + destroy() { 445 + chart.destroy(); 446 + } 447 + }; 448 + } 71 449 </script> 72 450 73 451 <div class="mt-8"> ··· 84 462 <h3 class="text-xl font-semibold text-foreground mb-4 pl-2 border-l-4 border-accent">{region}</h3> 85 463 86 464 {#each [...serviceMap] as [serviceName, serviceChecks]} 465 + {@const serviceKey = `${groupName}-${region}-${serviceName}`} 87 466 <div class="bg-card rounded-lg shadow-sm p-6 mb-6"> 88 467 <div class="flex justify-between items-center mb-2"> 89 468 <h4 class="text-lg font-medium">{serviceName}</h4> 90 - <div class="bg-accent text-accent-foreground px-3 py-1 rounded-full text-sm font-medium"> 91 - {calculateUptime(serviceChecks)}% uptime 469 + <div class="flex gap-2 items-center"> 470 + <button 471 + onclick={() => toggleChart(serviceKey)} 472 + class="px-3 py-1 rounded-full text-sm font-medium transition-colors {expandedCharts.has(serviceKey) ? 'bg-chart-3 text-white' : 'bg-chart-3/20 text-chart-3 hover:bg-chart-3/30'}" 473 + > 474 + {expandedCharts.has(serviceKey) ? 'hide graph' : 'show graph'} 475 + </button> 476 + <div class="bg-accent text-accent-foreground px-3 py-1 rounded-full text-sm font-medium"> 477 + {calculateUptime(serviceChecks)}% uptime 478 + </div> 92 479 </div> 93 480 </div> 94 481 ··· 104 491 ></div> 105 492 {/each} 106 493 </div> 494 + 495 + {#if expandedCharts.has(serviceKey)} 496 + <div class="mb-4 bg-background rounded-lg p-4 border border-border"> 497 + <!-- Tabs --> 498 + <div class="flex gap-2 mb-4 border-b border-border"> 499 + <button 500 + onclick={() => setActiveTab(serviceKey, 'uptime')} 501 + class="px-4 py-2 text-sm font-medium transition-colors border-b-2 {activeTab.get(serviceKey) === 'uptime' ? 'border-accent text-accent' : 'border-transparent text-muted-foreground hover:text-foreground'}" 502 + > 503 + uptime % 504 + </button> 505 + <button 506 + onclick={() => setActiveTab(serviceKey, 'derivative')} 507 + class="px-4 py-2 text-sm font-medium transition-colors border-b-2 {activeTab.get(serviceKey) === 'derivative' ? 'border-accent text-accent' : 'border-transparent text-muted-foreground hover:text-foreground'}" 508 + > 509 + latency spikes 510 + </button> 511 + </div> 512 + 513 + <!-- Chart --> 514 + <div style="height: 250px;"> 515 + {#if activeTab.get(serviceKey) === 'uptime'} 516 + <canvas use:createUptimeChart={serviceChecks}></canvas> 517 + {:else} 518 + <canvas use:createDerivativeChart={serviceChecks}></canvas> 519 + {/if} 520 + </div> 521 + </div> 522 + {/if} 107 523 108 524 <div class="flex flex-wrap gap-4 items-center pt-4 border-t border-border text-sm"> 109 525 <span class="px-2 py-1 rounded {serviceChecks[0].value.status === 'up' ? 'bg-chart-4/20 text-chart-4 font-semibold' : 'bg-destructive/20 text-destructive font-semibold'}">