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

pull down 2000 worth of records on initial pull, add warning system if there hasnt been events since the last x minutes

Changed files
+118 -20
web
+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
}
+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">
+48 -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
+
*
+
* @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);
+
}
+
} 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;
+
};