public uptime monitoring + (soon) observability with events saved to PDS
1<script lang="ts">
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);
8
9 interface Props {
10 checks: UptimeCheckRecord[];
11 }
12
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'>());
19
20 // group checks by group name, then by region, then by service
21 const groupedData = $derived(() => {
22 const groups = new Map<string, Map<string, Map<string, UptimeCheckRecord[]>>>();
23
24 for (const check of checks) {
25 const groupName = check.value.groupName || 'ungrouped';
26 const region = check.value.region || 'unknown';
27 const serviceName = check.value.serviceName;
28
29 if (!groups.has(groupName)) {
30 groups.set(groupName, new Map<string, Map<string, UptimeCheckRecord[]>>());
31 }
32
33 const regionMap = groups.get(groupName)!;
34 if (!regionMap.has(region)) {
35 regionMap.set(region, new Map<string, UptimeCheckRecord[]>());
36 }
37
38 const serviceMap = regionMap.get(region)!;
39 if (!serviceMap.has(serviceName)) {
40 serviceMap.set(serviceName, []);
41 }
42 serviceMap.get(serviceName)!.push(check);
43 }
44
45 // sort checks within each service by time (newest first)
46 for (const [, regionMap] of groups) {
47 for (const [, serviceMap] of regionMap) {
48 for (const [, serviceChecks] of serviceMap) {
49 serviceChecks.sort((a, b) => b.indexedAt.getTime() - a.indexedAt.getTime());
50 }
51 }
52 }
53
54 return groups;
55 });
56
57 function calculateUptime(checks: UptimeCheckRecord[]): string {
58 if (checks.length === 0) {
59 return '0';
60 }
61 const upChecks = checks.filter((c) => c.value.status === 'up').length;
62 return ((upChecks / checks.length) * 100).toFixed(2);
63 }
64
65 function formatResponseTime(ms: number): string {
66 if (ms < 0) {
67 return 'N/A';
68 }
69 if (ms < 1000) {
70 return `${ms}ms`;
71 }
72 return `${(ms / 1000).toFixed(2)}s`;
73 }
74
75 function formatTimestamp(date: Date): string {
76 return new Intl.DateTimeFormat('en-US', {
77 dateStyle: 'short',
78 timeStyle: 'short',
79 }).format(date);
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 }
449</script>
450
451<div class="mt-8">
452 <h2 class="text-2xl font-semibold mb-6">uptime statistics</h2>
453
454 {#each [...groupedData()] as [groupName, regionMap]}
455 <div class="mb-8">
456 {#if groupName !== 'ungrouped'}
457 <h2 class="text-3xl font-bold text-accent mb-4 pb-2 border-b-2 border-accent">{groupName}</h2>
458 {/if}
459
460 {#each [...regionMap] as [region, serviceMap]}
461 <div class="mb-8">
462 <h3 class="text-xl font-semibold text-foreground mb-4 pl-2 border-l-4 border-accent">{region}</h3>
463
464 {#each [...serviceMap] as [serviceName, serviceChecks]}
465 {@const serviceKey = `${groupName}-${region}-${serviceName}`}
466 <div class="bg-card rounded-lg shadow-sm p-6 mb-6">
467 <div class="flex justify-between items-center mb-2">
468 <h4 class="text-lg font-medium">{serviceName}</h4>
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>
479 </div>
480 </div>
481
482 <div class="text-sm text-muted-foreground mb-4 break-all">
483 {serviceChecks[0].value.serviceUrl}
484 </div>
485
486 <div class="flex gap-1 flex-wrap mb-4">
487 {#each serviceChecks.slice(0, 20) as check}
488 <div
489 class="w-3 h-3 rounded-sm cursor-pointer transition-transform hover:scale-150 {check.value.status === 'up' ? 'bg-chart-4' : 'bg-destructive'}"
490 title={`${check.value.status} - ${formatResponseTime(check.value.responseTime)} - ${formatTimestamp(check.indexedAt)}`}
491 ></div>
492 {/each}
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}
523
524 <div class="flex flex-wrap gap-4 items-center pt-4 border-t border-border text-sm">
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'}">
526 {serviceChecks[0].value.status}
527 </span>
528 {#if serviceChecks[0].value.status === 'up'}
529 <span>response time: {formatResponseTime(serviceChecks[0].value.responseTime)}</span>
530 {#if serviceChecks[0].value.httpStatus}
531 <span>HTTP {serviceChecks[0].value.httpStatus}</span>
532 {/if}
533 {:else if serviceChecks[0].value.errorMessage}
534 <span class="text-destructive">{serviceChecks[0].value.errorMessage}</span>
535 {/if}
536 <span class="text-muted-foreground ml-auto">checked {formatTimestamp(serviceChecks[0].indexedAt)}</span>
537 </div>
538 </div>
539 {/each}
540 </div>
541 {/each}
542 </div>
543 {/each}
544</div>
545