+3
-1
web/package.json
+3
-1
web/package.json
+418
-2
web/src/lib/uptime-display.svelte
+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'}">