···
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';
7
+
Chart.register(LineController, LineElement, PointElement, LinearScale, TimeScale, Title, Tooltip, CategoryScale, annotationPlugin);
checks: UptimeCheckRecord[];
const { checks }: Props = $props();
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'>());
// group checks by group name, then by region, then by service
const groupedData = $derived(() => {
···
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: [] };
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[] = [];
94
+
for (let i = 1; i < sorted.length; i++) {
95
+
const prev = sorted[i - 1];
96
+
const curr = sorted[i];
98
+
// Skip if either check is down
99
+
if (prev.value.status !== 'up' || curr.value.status !== 'up') {
103
+
const change = curr.value.responseTime - prev.value.responseTime;
104
+
labels.push(formatTimestamp(curr.indexedAt));
105
+
values.push(change);
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);
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)`);
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)`);
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);
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
137
+
return 0; // Hide this point
141
+
return { labels, values, colors, pointRadii };
144
+
function toggleChart(serviceKey: string) {
145
+
const newSet = new Set(expandedCharts);
146
+
if (newSet.has(serviceKey)) {
147
+
newSet.delete(serviceKey);
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;
157
+
expandedCharts = newSet;
160
+
function setActiveTab(serviceKey: string, tab: 'derivative' | 'uptime') {
161
+
const newTabMap = new Map(activeTab);
162
+
newTabMap.set(serviceKey, tab);
163
+
activeTab = newTabMap;
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: [] };
172
+
const sorted = [...checks].reverse(); // Oldest first
173
+
const hourlyBuckets = new Map<number, { total: number, up: number }>();
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
179
+
if (!hourlyBuckets.has(hourTimestamp)) {
180
+
hourlyBuckets.set(hourTimestamp, { total: 0, up: 0 });
183
+
const bucket = hourlyBuckets.get(hourTimestamp)!;
185
+
if (check.value.status === 'up') {
190
+
// Convert to arrays sorted by time
191
+
const sortedBuckets = Array.from(hourlyBuckets.entries()).sort((a, b) => a[0] - b[0]);
193
+
const labels: string[] = [];
194
+
const values: number[] = [];
195
+
const colors: string[] = [];
197
+
for (const [hourTimestamp, bucket] of sortedBuckets) {
198
+
const date = new Date(hourTimestamp * 1000 * 60 * 60);
199
+
const percentage = (bucket.up / bucket.total) * 100;
201
+
labels.push(new Intl.DateTimeFormat('en-US', {
207
+
values.push(percentage);
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
215
+
colors.push('oklch(0.32 0.04 285)'); // Dark blue - bad
219
+
return { labels, values, colors };
222
+
function createDerivativeChart(canvas: HTMLCanvasElement, checks: UptimeCheckRecord[]) {
223
+
const derivative = calculateDerivative(checks);
225
+
if (derivative.values.length === 0) {
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)';
235
+
// Calculate background annotations for problem areas
236
+
const annotations: any = {};
237
+
const threshold = 100; // 100ms change is "significant"
239
+
derivative.values.forEach((value, index) => {
240
+
if (value > threshold) {
241
+
// Significant slowdown - add red background
242
+
annotations[`problem-${index}`] = {
246
+
backgroundColor: 'rgba(239, 68, 68, 0.15)', // Red with transparency
252
+
const chart = new Chart(canvas, {
255
+
labels: derivative.labels,
257
+
label: 'Response Time Change (ms)',
258
+
data: derivative.values,
259
+
borderColor: 'oklch(0.78 0.15 345)', // Pink line
260
+
backgroundColor: 'transparent',
262
+
pointBackgroundColor: 'oklch(0.78 0.15 345)',
263
+
pointBorderColor: 'oklch(0.78 0.15 345)',
264
+
pointRadius: derivative.pointRadii,
265
+
pointHoverRadius: 6,
271
+
maintainAspectRatio: false,
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)',
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}`;
289
+
text: 'latency spikes (change in response time) โข lower is better',
290
+
color: foregroundColor,
303
+
color: mutedForegroundColor,
304
+
callback: (value) => `${value}ms`
312
+
color: mutedForegroundColor,
317
+
callback: function(value, index, ticks) {
318
+
// Always show the last (latest) timestamp
319
+
if (index === ticks.length - 1) {
320
+
return this.getLabelForValue(value);
322
+
// Show evenly distributed labels
323
+
const skipRate = Math.ceil(ticks.length / 6);
324
+
return index % skipRate === 0 ? this.getLabelForValue(value) : '';
342
+
function createUptimeChart(canvas: HTMLCanvasElement, checks: UptimeCheckRecord[]) {
343
+
const uptimeData = calculateHourlyUptime(checks);
345
+
if (uptimeData.values.length === 0) {
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)';
355
+
const chart = new Chart(canvas, {
358
+
labels: uptimeData.labels,
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%)
372
+
pointBackgroundColor: uptimeData.colors,
373
+
pointBorderColor: uptimeData.colors,
375
+
pointHoverRadius: 6,
381
+
maintainAspectRatio: false,
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)',
390
+
label: (context) => {
391
+
const value = context.parsed.y;
392
+
return `${value.toFixed(2)}% uptime`;
398
+
text: 'uptime percentage (hourly)',
399
+
color: foregroundColor,
411
+
color: mutedForegroundColor,
412
+
callback: (value) => `${value}%`
420
+
color: mutedForegroundColor,
425
+
callback: function(value, index, ticks) {
426
+
// Always show the last (latest) timestamp
427
+
if (index === ticks.length - 1) {
428
+
return this.getLabelForValue(value);
430
+
// Show evenly distributed labels
431
+
const skipRate = Math.ceil(ticks.length / 6);
432
+
return index % skipRate === 0 ? this.getLabelForValue(value) : '';
···
<h3 class="text-xl font-semibold text-foreground mb-4 pl-2 border-l-4 border-accent">{region}</h3>
{#each [...serviceMap] as [serviceName, serviceChecks]}
465
+
{@const serviceKey = `${groupName}-${region}-${serviceName}`}
<div class="bg-card rounded-lg shadow-sm p-6 mb-6">
<div class="flex justify-between items-center mb-2">
<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">
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'}"
474
+
{expandedCharts.has(serviceKey) ? 'hide graph' : 'show graph'}
476
+
<div class="bg-accent text-accent-foreground px-3 py-1 rounded-full text-sm font-medium">
477
+
{calculateUptime(serviceChecks)}% uptime
···
495
+
{#if expandedCharts.has(serviceKey)}
496
+
<div class="mb-4 bg-background rounded-lg p-4 border border-border">
498
+
<div class="flex gap-2 mb-4 border-b border-border">
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'}"
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'}"
514
+
<div style="height: 250px;">
515
+
{#if activeTab.get(serviceKey) === 'uptime'}
516
+
<canvas use:createUptimeChart={serviceChecks}></canvas>
518
+
<canvas use:createDerivativeChart={serviceChecks}></canvas>
<div class="flex flex-wrap gap-4 items-center pt-4 border-t border-border text-sm">
<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'}">