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

Compare changes

Choose any two refs to compare.

+3 -1
web/config.example.json
···
"pds": "https://bsky.social",
"did": "did:plc:your-did-here",
"title": "cuteuptime",
-
"subtitle": "cute uptime monitoring using your PDS to store events"
+
"subtitle": "cute uptime monitoring using your PDS to store events",
+
"showInactivityWarning": true,
+
"inactivityThresholdMinutes": 5
}
+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",
+59 -13
web/src/app.svelte
···
<script lang="ts">
import { onMount } from 'svelte';
-
import { fetchUptimeChecks } from './lib/atproto.ts';
+
import { fetchUptimeChecks, fetchInitialUptimeChecks } from './lib/atproto.ts';
import UptimeDisplay from './lib/uptime-display.svelte';
import type { UptimeCheckRecord } from './lib/types.ts';
import { config } from './lib/config.ts';
let checks = $state<UptimeCheckRecord[]>([]);
-
let loading = $state(true);
+
let loading = $state(false);
let error = $state('');
let lastUpdate = $state<Date | null>(null);
+
let hasRecentEvents = $state(true);
+
let initialLoadComplete = $state(false);
+
let isLoadingMore = $state(false);
+
+
/**
+
* checks if there are any events in the configured threshold period
+
*/
+
function checkForRecentEvents(records: UptimeCheckRecord[]): boolean {
+
const thresholdMinutes = config.inactivityThresholdMinutes || 5;
+
const thresholdTimeAgo = new Date(Date.now() - thresholdMinutes * 60 * 1000);
+
return records.some(record => record.indexedAt > thresholdTimeAgo);
+
}
async function loadChecks() {
-
loading = true;
+
if (!initialLoadComplete) {
+
isLoadingMore = true;
+
} else {
+
loading = true;
+
}
error = '';
try {
-
checks = await fetchUptimeChecks(config.pds, config.did);
-
lastUpdate = new Date();
+
if (!initialLoadComplete) {
+
// Initial load: fetch records progressively and show in real-time
+
await fetchInitialUptimeChecks(config.pds, config.did, (progressRecords) => {
+
checks = progressRecords;
+
lastUpdate = new Date();
+
hasRecentEvents = checkForRecentEvents(progressRecords);
+
});
+
initialLoadComplete = true;
+
} else {
+
// Refresh: fetch only new records and append them
+
const result = await fetchUptimeChecks(config.pds, config.did);
+
if (result.records.length > 0) {
+
// Check if we actually have new records by comparing with existing ones
+
const existingUris = new Set(checks.map(c => c.uri));
+
const newRecords = result.records.filter(r => !existingUris.has(r.uri));
+
+
if (newRecords.length > 0) {
+
checks = newRecords.concat(checks); // prepend new records
+
}
+
}
+
lastUpdate = new Date();
+
hasRecentEvents = checkForRecentEvents(checks);
+
}
} catch (err) {
error = (err as Error).message || 'failed to fetch uptime checks';
checks = [];
} finally {
loading = false;
+
isLoadingMore = false;
}
}
···
last updated: {lastUpdate.toLocaleTimeString()}
</span>
{/if}
+
{#if isLoadingMore}
+
<span class="text-sm text-primary animate-pulse">
+
fetching more data...
+
</span>
+
{/if}
</div>
<button
class="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50 transition-opacity"
onclick={loadChecks}
-
disabled={loading}
+
disabled={loading || isLoadingMore}
>
-
{loading ? 'refreshing...' : 'refresh'}
+
{loading || isLoadingMore ? 'loading...' : 'refresh'}
</button>
</div>
···
</div>
{/if}
+
{#if config.showInactivityWarning && !hasRecentEvents && checks.length > 0}
+
<div class="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg p-4 mb-4">
+
<strong>โš ๏ธ Warning:</strong> No uptime pings received in the last {config.inactivityThresholdMinutes || 5} minutes.
+
The uptime monitor may be offline or experiencing issues.
+
</div>
+
{/if}
+
{#if loading && checks.length === 0}
<div class="text-center py-12 bg-card rounded-lg shadow-sm text-muted-foreground">
-
loading uptime data...
+
fetching uptime data...
</div>
-
{:else if checks.length > 0}
+
{:else}
<UptimeDisplay {checks} />
-
{:else if !loading}
-
<div class="text-center py-12 bg-card rounded-lg shadow-sm text-muted-foreground">
-
no uptime data available
-
</div>
{/if}
<footer class="mt-12 pt-8 border-t border-border text-center text-sm text-muted-foreground">
+56 -5
web/src/lib/atproto.ts
···
import type { UptimeCheck, UptimeCheckRecord } from './types.ts';
/**
-
* fetches uptime check records from a PDS for a given DID
+
* fetches uptime check records from a PDS for a given DID with cursor support
*
* @param pds the PDS URL
* @param did the DID or handle to fetch records for
-
* @returns array of uptime check records
+
* @param cursor optional cursor for pagination
+
* @returns object with records array and optional next cursor
*/
export async function fetchUptimeChecks(
pds: string,
did: ActorIdentifier,
-
): Promise<UptimeCheckRecord[]> {
+
cursor?: string,
+
): Promise<{ records: UptimeCheckRecord[]; cursor?: string }> {
const handler = simpleFetchHandler({ service: pds });
const rpc = new Client({ handler });
···
resolvedDid = did as Did;
}
-
// fetch uptime check records
+
// fetch uptime check records with cursor
const response = await ok(
rpc.get('com.atproto.repo.listRecords', {
params: {
repo: resolvedDid,
collection: 'pet.nkp.uptime.check',
limit: 100,
+
cursor,
},
}),
);
// transform records into a more usable format
-
return response.records.map((record) => ({
+
const records = response.records.map((record) => ({
uri: record.uri,
cid: record.cid,
value: record.value as unknown as UptimeCheck,
indexedAt: new Date((record.value as unknown as UptimeCheck).checkedAt),
}));
+
+
return {
+
records,
+
cursor: response.cursor,
+
};
+
}
+
+
/**
+
* fetches up to 2000 uptime check records using cursor pagination with progress callback
+
* stops early if no more records are available
+
*
+
* @param pds the PDS URL
+
* @param did the DID or handle to fetch records for
+
* @param onProgress optional callback called with each batch of records
+
* @returns array of uptime check records (max 2000)
+
*/
+
export async function fetchInitialUptimeChecks(
+
pds: string,
+
did: ActorIdentifier,
+
onProgress?: (records: UptimeCheckRecord[]) => void,
+
): Promise<UptimeCheckRecord[]> {
+
let allRecords: UptimeCheckRecord[] = [];
+
let cursor: string | undefined;
+
const maxRecords = 2000;
+
const batchSize = 100;
+
let totalFetched = 0;
+
+
do {
+
const result = await fetchUptimeChecks(pds, did, cursor);
+
const newRecords = result.records;
+
allRecords = allRecords.concat(newRecords);
+
totalFetched += newRecords.length;
+
cursor = result.cursor;
+
+
// Call progress callback with the accumulated records
+
if (onProgress) {
+
onProgress(allRecords);
+
}
+
+
// Stop early if we got fewer records than the batch size (indicating we've exhausted all records)
+
// or if no cursor is returned (also indicating no more records)
+
if (newRecords.length < batchSize || !cursor) {
+
break;
+
}
+
+
} while (cursor && totalFetched < maxRecords);
+
+
return allRecords;
}
+8 -1
web/src/lib/config.ts
···
did: ActorIdentifier;
title: string;
subtitle: string;
+
/** whether to show the 5-minute inactivity warning */
+
showInactivityWarning?: boolean;
+
/** minutes to check for recent activity (default: 5) */
+
inactivityThresholdMinutes?: number;
};
-
export const config = __CONFIG__;
+
export const config = __CONFIG__ as typeof __CONFIG__ & {
+
showInactivityWarning: boolean;
+
inactivityThresholdMinutes: number;
+
};
+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'}">