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

graphsssss

Changed files
+421 -3
web
+3 -1
web/package.json
···
},
"dependencies": {
"@atcute/atproto": "^3.1.9",
-
"@atcute/client": "^4.0.5"
+
"@atcute/client": "^4.0.5",
+
"chart.js": "^4.5.1",
+
"chartjs-plugin-annotation": "^3.1.0"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.2",
+418 -2
web/src/lib/uptime-display.svelte
···
<script lang="ts">
import type { UptimeCheckRecord } from './types.ts';
+
import { onMount } from 'svelte';
+
import { Chart, LineController, LineElement, PointElement, LinearScale, TimeScale, Title, Tooltip, CategoryScale } from 'chart.js';
+
import annotationPlugin from 'chartjs-plugin-annotation';
+
+
Chart.register(LineController, LineElement, PointElement, LinearScale, TimeScale, Title, Tooltip, CategoryScale, annotationPlugin);
interface Props {
checks: UptimeCheckRecord[];
}
const { checks }: Props = $props();
+
+
// Track which service charts are expanded
+
let expandedCharts = $state(new Set<string>());
+
// Track which tab is active for each service (derivative or uptime)
+
let activeTab = $state(new Map<string, 'derivative' | 'uptime'>());
// group checks by group name, then by region, then by service
const groupedData = $derived(() => {
···
timeStyle: 'short',
}).format(date);
}
+
+
// Calculate derivative (change in response time between checks)
+
function calculateDerivative(checks: UptimeCheckRecord[]): { labels: string[], values: number[], colors: string[], pointRadii: number[] } {
+
if (checks.length < 2) {
+
return { labels: [], values: [], colors: [], pointRadii: [] };
+
}
+
+
// Reverse to get oldest first for proper time ordering
+
const sorted = [...checks].reverse();
+
const labels: string[] = [];
+
const values: number[] = [];
+
const colors: string[] = [];
+
+
for (let i = 1; i < sorted.length; i++) {
+
const prev = sorted[i - 1];
+
const curr = sorted[i];
+
+
// Skip if either check is down
+
if (prev.value.status !== 'up' || curr.value.status !== 'up') {
+
continue;
+
}
+
+
const change = curr.value.responseTime - prev.value.responseTime;
+
labels.push(formatTimestamp(curr.indexedAt));
+
values.push(change);
+
+
// Color code based on brightness: brighter = better (faster), darker = worse (slower)
+
const absChange = Math.abs(change);
+
const maxChange = 500; // Normalize around 500ms as reference
+
const intensity = Math.min(absChange / maxChange, 1);
+
+
if (change < 0) {
+
// Faster (good) - brighter pink
+
const brightness = 0.65 + (intensity * 0.25); // 0.65 to 0.9
+
colors.push(`oklch(${brightness} 0.15 345)`);
+
} else {
+
// Slower (bad) - darker pink
+
const brightness = 0.65 - (intensity * 0.35); // 0.65 to 0.3
+
colors.push(`oklch(${brightness} 0.15 345)`);
+
}
+
}
+
+
// Calculate standard deviation
+
const mean = values.reduce((sum, v) => sum + Math.abs(v), 0) / values.length;
+
const variance = values.reduce((sum, v) => sum + Math.pow(Math.abs(v) - mean, 2), 0) / values.length;
+
const stdDev = Math.sqrt(variance);
+
+
// Determine which points to show based on std deviation
+
const pointRadii = values.map((value, index) => {
+
const absValue = Math.abs(value);
+
// Show point if it's a significant spike (> 1 std dev) OR every other point
+
if (absValue > mean + stdDev) {
+
return 5; // Larger dot for significant spikes
+
} else if (index % 2 === 0) {
+
return 3; // Show every other point at normal size
+
} else {
+
return 0; // Hide this point
+
}
+
});
+
+
return { labels, values, colors, pointRadii };
+
}
+
+
function toggleChart(serviceKey: string) {
+
const newSet = new Set(expandedCharts);
+
if (newSet.has(serviceKey)) {
+
newSet.delete(serviceKey);
+
} else {
+
newSet.add(serviceKey);
+
// Default to uptime tab when opening
+
if (!activeTab.has(serviceKey)) {
+
const newTabMap = new Map(activeTab);
+
newTabMap.set(serviceKey, 'uptime');
+
activeTab = newTabMap;
+
}
+
}
+
expandedCharts = newSet;
+
}
+
+
function setActiveTab(serviceKey: string, tab: 'derivative' | 'uptime') {
+
const newTabMap = new Map(activeTab);
+
newTabMap.set(serviceKey, tab);
+
activeTab = newTabMap;
+
}
+
+
// Calculate uptime percentage in hourly rolling windows
+
function calculateHourlyUptime(checks: UptimeCheckRecord[]): { labels: string[], values: number[], colors: string[] } {
+
if (checks.length === 0) {
+
return { labels: [], values: [], colors: [] };
+
}
+
+
const sorted = [...checks].reverse(); // Oldest first
+
const hourlyBuckets = new Map<number, { total: number, up: number }>();
+
+
// Group checks into hourly buckets
+
for (const check of sorted) {
+
const hourTimestamp = Math.floor(check.indexedAt.getTime() / (1000 * 60 * 60)); // Round to hour
+
+
if (!hourlyBuckets.has(hourTimestamp)) {
+
hourlyBuckets.set(hourTimestamp, { total: 0, up: 0 });
+
}
+
+
const bucket = hourlyBuckets.get(hourTimestamp)!;
+
bucket.total++;
+
if (check.value.status === 'up') {
+
bucket.up++;
+
}
+
}
+
+
// Convert to arrays sorted by time
+
const sortedBuckets = Array.from(hourlyBuckets.entries()).sort((a, b) => a[0] - b[0]);
+
+
const labels: string[] = [];
+
const values: number[] = [];
+
const colors: string[] = [];
+
+
for (const [hourTimestamp, bucket] of sortedBuckets) {
+
const date = new Date(hourTimestamp * 1000 * 60 * 60);
+
const percentage = (bucket.up / bucket.total) * 100;
+
+
labels.push(new Intl.DateTimeFormat('en-US', {
+
month: 'short',
+
day: 'numeric',
+
hour: 'numeric'
+
}).format(date));
+
+
values.push(percentage);
+
+
// Gradient: 100% = pink (good), <99% = purple-ish, <95% = dark blue (bad)
+
if (percentage >= 99.5) {
+
colors.push('oklch(0.78 0.15 345)'); // Pink - good!
+
} else if (percentage >= 95) {
+
colors.push('oklch(0.65 0.12 285)'); // Purple - concerning
+
} else {
+
colors.push('oklch(0.32 0.04 285)'); // Dark blue - bad
+
}
+
}
+
+
return { labels, values, colors };
+
}
+
+
function createDerivativeChart(canvas: HTMLCanvasElement, checks: UptimeCheckRecord[]) {
+
const derivative = calculateDerivative(checks);
+
+
if (derivative.values.length === 0) {
+
return;
+
}
+
+
// Get CSS variables for theme-aware colors
+
const rootStyle = getComputedStyle(document.documentElement);
+
const foregroundColor = rootStyle.getPropertyValue('--foreground').trim() || 'oklch(0.18 0.01 30)';
+
const mutedForegroundColor = rootStyle.getPropertyValue('--muted-foreground').trim() || 'oklch(0.42 0.015 30)';
+
const borderColor = rootStyle.getPropertyValue('--border').trim() || 'oklch(0.75 0.015 30)';
+
+
// Calculate background annotations for problem areas
+
const annotations: any = {};
+
const threshold = 100; // 100ms change is "significant"
+
+
derivative.values.forEach((value, index) => {
+
if (value > threshold) {
+
// Significant slowdown - add red background
+
annotations[`problem-${index}`] = {
+
type: 'box',
+
xMin: index - 0.5,
+
xMax: index + 0.5,
+
backgroundColor: 'rgba(239, 68, 68, 0.15)', // Red with transparency
+
borderWidth: 0
+
};
+
}
+
});
+
+
const chart = new Chart(canvas, {
+
type: 'line',
+
data: {
+
labels: derivative.labels,
+
datasets: [{
+
label: 'Response Time Change (ms)',
+
data: derivative.values,
+
borderColor: 'oklch(0.78 0.15 345)', // Pink line
+
backgroundColor: 'transparent',
+
tension: 0.4,
+
pointBackgroundColor: 'oklch(0.78 0.15 345)',
+
pointBorderColor: 'oklch(0.78 0.15 345)',
+
pointRadius: derivative.pointRadii,
+
pointHoverRadius: 6,
+
fill: false,
+
}]
+
},
+
options: {
+
responsive: true,
+
maintainAspectRatio: false,
+
plugins: {
+
tooltip: {
+
backgroundColor: 'oklch(0.20 0.02 285)',
+
titleColor: 'oklch(0.98 0.00 285)',
+
bodyColor: 'oklch(0.98 0.00 285)',
+
borderColor: 'oklch(0.56 0.08 220)',
+
borderWidth: 1,
+
callbacks: {
+
label: (context) => {
+
const value = context.parsed.y;
+
const direction = value > 0 ? 'slower' : 'faster';
+
return `${Math.abs(value).toFixed(0)}ms ${direction}`;
+
}
+
}
+
},
+
title: {
+
display: true,
+
text: 'latency spikes (change in response time) • lower is better',
+
color: foregroundColor,
+
font: {
+
size: 14,
+
weight: 'normal'
+
}
+
},
+
annotation: {
+
annotations
+
}
+
},
+
scales: {
+
y: {
+
ticks: {
+
color: mutedForegroundColor,
+
callback: (value) => `${value}ms`
+
},
+
grid: {
+
color: borderColor
+
}
+
},
+
x: {
+
ticks: {
+
color: mutedForegroundColor,
+
maxRotation: 45,
+
minRotation: 45,
+
autoSkip: true,
+
maxTicksLimit: 6,
+
callback: function(value, index, ticks) {
+
// Always show the last (latest) timestamp
+
if (index === ticks.length - 1) {
+
return this.getLabelForValue(value);
+
}
+
// Show evenly distributed labels
+
const skipRate = Math.ceil(ticks.length / 6);
+
return index % skipRate === 0 ? this.getLabelForValue(value) : '';
+
}
+
},
+
grid: {
+
color: borderColor
+
}
+
}
+
}
+
}
+
});
+
+
return {
+
destroy() {
+
chart.destroy();
+
}
+
};
+
}
+
+
function createUptimeChart(canvas: HTMLCanvasElement, checks: UptimeCheckRecord[]) {
+
const uptimeData = calculateHourlyUptime(checks);
+
+
if (uptimeData.values.length === 0) {
+
return;
+
}
+
+
// Get CSS variables for theme-aware colors
+
const rootStyle = getComputedStyle(document.documentElement);
+
const foregroundColor = rootStyle.getPropertyValue('--foreground').trim() || 'oklch(0.18 0.01 30)';
+
const mutedForegroundColor = rootStyle.getPropertyValue('--muted-foreground').trim() || 'oklch(0.42 0.015 30)';
+
const borderColor = rootStyle.getPropertyValue('--border').trim() || 'oklch(0.75 0.015 30)';
+
+
const chart = new Chart(canvas, {
+
type: 'line',
+
data: {
+
labels: uptimeData.labels,
+
datasets: [{
+
label: 'Uptime %',
+
data: uptimeData.values,
+
borderColor: 'oklch(0.78 0.15 345)', // Pink
+
backgroundColor: (context) => {
+
const ctx = context.chart.ctx;
+
const gradient = ctx.createLinearGradient(0, 0, 0, 250);
+
gradient.addColorStop(0, 'oklch(0.78 0.15 345 / 0.4)'); // Pink at top (100%)
+
gradient.addColorStop(0.5, 'oklch(0.65 0.12 285 / 0.2)'); // Purple middle (~97%)
+
gradient.addColorStop(1, 'oklch(0.32 0.04 285 / 0.1)'); // Dark blue at bottom (<95%)
+
return gradient;
+
},
+
tension: 0.4,
+
pointBackgroundColor: uptimeData.colors,
+
pointBorderColor: uptimeData.colors,
+
pointRadius: 3,
+
pointHoverRadius: 6,
+
fill: true,
+
}]
+
},
+
options: {
+
responsive: true,
+
maintainAspectRatio: false,
+
plugins: {
+
tooltip: {
+
backgroundColor: 'oklch(0.20 0.02 285)',
+
titleColor: 'oklch(0.98 0.00 285)',
+
bodyColor: 'oklch(0.98 0.00 285)',
+
borderColor: 'oklch(0.78 0.15 345)',
+
borderWidth: 1,
+
callbacks: {
+
label: (context) => {
+
const value = context.parsed.y;
+
return `${value.toFixed(2)}% uptime`;
+
}
+
}
+
},
+
title: {
+
display: true,
+
text: 'uptime percentage (hourly)',
+
color: foregroundColor,
+
font: {
+
size: 14,
+
weight: 'normal'
+
}
+
}
+
},
+
scales: {
+
y: {
+
min: 0,
+
max: 100,
+
ticks: {
+
color: mutedForegroundColor,
+
callback: (value) => `${value}%`
+
},
+
grid: {
+
color: borderColor
+
}
+
},
+
x: {
+
ticks: {
+
color: mutedForegroundColor,
+
maxRotation: 45,
+
minRotation: 45,
+
autoSkip: true,
+
maxTicksLimit: 6,
+
callback: function(value, index, ticks) {
+
// Always show the last (latest) timestamp
+
if (index === ticks.length - 1) {
+
return this.getLabelForValue(value);
+
}
+
// Show evenly distributed labels
+
const skipRate = Math.ceil(ticks.length / 6);
+
return index % skipRate === 0 ? this.getLabelForValue(value) : '';
+
}
+
},
+
grid: {
+
color: borderColor
+
}
+
}
+
}
+
}
+
});
+
+
return {
+
destroy() {
+
chart.destroy();
+
}
+
};
+
}
</script>
<div class="mt-8">
···
<h3 class="text-xl font-semibold text-foreground mb-4 pl-2 border-l-4 border-accent">{region}</h3>
{#each [...serviceMap] as [serviceName, serviceChecks]}
+
{@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>
-
<div class="bg-accent text-accent-foreground px-3 py-1 rounded-full text-sm font-medium">
-
{calculateUptime(serviceChecks)}% uptime
+
<div class="flex gap-2 items-center">
+
<button
+
onclick={() => toggleChart(serviceKey)}
+
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'}"
+
>
+
{expandedCharts.has(serviceKey) ? 'hide graph' : 'show graph'}
+
</button>
+
<div class="bg-accent text-accent-foreground px-3 py-1 rounded-full text-sm font-medium">
+
{calculateUptime(serviceChecks)}% uptime
+
</div>
</div>
</div>
···
></div>
{/each}
</div>
+
+
{#if expandedCharts.has(serviceKey)}
+
<div class="mb-4 bg-background rounded-lg p-4 border border-border">
+
<!-- Tabs -->
+
<div class="flex gap-2 mb-4 border-b border-border">
+
<button
+
onclick={() => setActiveTab(serviceKey, 'uptime')}
+
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'}"
+
>
+
uptime %
+
</button>
+
<button
+
onclick={() => setActiveTab(serviceKey, 'derivative')}
+
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'}"
+
>
+
latency spikes
+
</button>
+
</div>
+
+
<!-- Chart -->
+
<div style="height: 250px;">
+
{#if activeTab.get(serviceKey) === 'uptime'}
+
<canvas use:createUptimeChart={serviceChecks}></canvas>
+
{:else}
+
<canvas use:createDerivativeChart={serviceChecks}></canvas>
+
{/if}
+
</div>
+
</div>
+
{/if}
<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'}">