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
··· 2 2 "pds": "https://bsky.social", 3 3 "did": "did:plc:your-did-here", 4 4 "title": "cuteuptime", 5 - "subtitle": "cute uptime monitoring using your PDS to store events" 5 + "subtitle": "cute uptime monitoring using your PDS to store events", 6 + "showInactivityWarning": true, 7 + "inactivityThresholdMinutes": 5 6 8 } 7 9
+59 -13
web/src/app.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 - import { fetchUptimeChecks } from './lib/atproto.ts'; 3 + import { fetchUptimeChecks, fetchInitialUptimeChecks } from './lib/atproto.ts'; 4 4 import UptimeDisplay from './lib/uptime-display.svelte'; 5 5 import type { UptimeCheckRecord } from './lib/types.ts'; 6 6 import { config } from './lib/config.ts'; 7 7 8 8 let checks = $state<UptimeCheckRecord[]>([]); 9 - let loading = $state(true); 9 + let loading = $state(false); 10 10 let error = $state(''); 11 11 let lastUpdate = $state<Date | null>(null); 12 + let hasRecentEvents = $state(true); 13 + let initialLoadComplete = $state(false); 14 + let isLoadingMore = $state(false); 15 + 16 + /** 17 + * checks if there are any events in the configured threshold period 18 + */ 19 + function checkForRecentEvents(records: UptimeCheckRecord[]): boolean { 20 + const thresholdMinutes = config.inactivityThresholdMinutes || 5; 21 + const thresholdTimeAgo = new Date(Date.now() - thresholdMinutes * 60 * 1000); 22 + return records.some(record => record.indexedAt > thresholdTimeAgo); 23 + } 12 24 13 25 async function loadChecks() { 14 - loading = true; 26 + if (!initialLoadComplete) { 27 + isLoadingMore = true; 28 + } else { 29 + loading = true; 30 + } 15 31 error = ''; 16 32 17 33 try { 18 - checks = await fetchUptimeChecks(config.pds, config.did); 19 - lastUpdate = new Date(); 34 + if (!initialLoadComplete) { 35 + // Initial load: fetch records progressively and show in real-time 36 + await fetchInitialUptimeChecks(config.pds, config.did, (progressRecords) => { 37 + checks = progressRecords; 38 + lastUpdate = new Date(); 39 + hasRecentEvents = checkForRecentEvents(progressRecords); 40 + }); 41 + initialLoadComplete = true; 42 + } else { 43 + // Refresh: fetch only new records and append them 44 + const result = await fetchUptimeChecks(config.pds, config.did); 45 + if (result.records.length > 0) { 46 + // Check if we actually have new records by comparing with existing ones 47 + const existingUris = new Set(checks.map(c => c.uri)); 48 + const newRecords = result.records.filter(r => !existingUris.has(r.uri)); 49 + 50 + if (newRecords.length > 0) { 51 + checks = newRecords.concat(checks); // prepend new records 52 + } 53 + } 54 + lastUpdate = new Date(); 55 + hasRecentEvents = checkForRecentEvents(checks); 56 + } 20 57 } catch (err) { 21 58 error = (err as Error).message || 'failed to fetch uptime checks'; 22 59 checks = []; 23 60 } finally { 24 61 loading = false; 62 + isLoadingMore = false; 25 63 } 26 64 } 27 65 ··· 48 86 last updated: {lastUpdate.toLocaleTimeString()} 49 87 </span> 50 88 {/if} 89 + {#if isLoadingMore} 90 + <span class="text-sm text-primary animate-pulse"> 91 + fetching more data... 92 + </span> 93 + {/if} 51 94 </div> 52 95 <button 53 96 class="px-4 py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50 transition-opacity" 54 97 onclick={loadChecks} 55 - disabled={loading} 98 + disabled={loading || isLoadingMore} 56 99 > 57 - {loading ? 'refreshing...' : 'refresh'} 100 + {loading || isLoadingMore ? 'loading...' : 'refresh'} 58 101 </button> 59 102 </div> 60 103 ··· 64 107 </div> 65 108 {/if} 66 109 110 + {#if config.showInactivityWarning && !hasRecentEvents && checks.length > 0} 111 + <div class="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded-lg p-4 mb-4"> 112 + <strong>⚠️ Warning:</strong> No uptime pings received in the last {config.inactivityThresholdMinutes || 5} minutes. 113 + The uptime monitor may be offline or experiencing issues. 114 + </div> 115 + {/if} 116 + 67 117 {#if loading && checks.length === 0} 68 118 <div class="text-center py-12 bg-card rounded-lg shadow-sm text-muted-foreground"> 69 - loading uptime data... 119 + fetching uptime data... 70 120 </div> 71 - {:else if checks.length > 0} 121 + {:else} 72 122 <UptimeDisplay {checks} /> 73 - {:else if !loading} 74 - <div class="text-center py-12 bg-card rounded-lg shadow-sm text-muted-foreground"> 75 - no uptime data available 76 - </div> 77 123 {/if} 78 124 79 125 <footer class="mt-12 pt-8 border-t border-border text-center text-sm text-muted-foreground">
+48 -5
web/src/lib/atproto.ts
··· 4 4 import type { UptimeCheck, UptimeCheckRecord } from './types.ts'; 5 5 6 6 /** 7 - * fetches uptime check records from a PDS for a given DID 7 + * fetches uptime check records from a PDS for a given DID with cursor support 8 8 * 9 9 * @param pds the PDS URL 10 10 * @param did the DID or handle to fetch records for 11 - * @returns array of uptime check records 11 + * @param cursor optional cursor for pagination 12 + * @returns object with records array and optional next cursor 12 13 */ 13 14 export async function fetchUptimeChecks( 14 15 pds: string, 15 16 did: ActorIdentifier, 16 - ): Promise<UptimeCheckRecord[]> { 17 + cursor?: string, 18 + ): Promise<{ records: UptimeCheckRecord[]; cursor?: string }> { 17 19 const handler = simpleFetchHandler({ service: pds }); 18 20 const rpc = new Client({ handler }); 19 21 ··· 30 32 resolvedDid = did as Did; 31 33 } 32 34 33 - // fetch uptime check records 35 + // fetch uptime check records with cursor 34 36 const response = await ok( 35 37 rpc.get('com.atproto.repo.listRecords', { 36 38 params: { 37 39 repo: resolvedDid, 38 40 collection: 'pet.nkp.uptime.check', 39 41 limit: 100, 42 + cursor, 40 43 }, 41 44 }), 42 45 ); 43 46 44 47 // transform records into a more usable format 45 - return response.records.map((record) => ({ 48 + const records = response.records.map((record) => ({ 46 49 uri: record.uri, 47 50 cid: record.cid, 48 51 value: record.value as unknown as UptimeCheck, 49 52 indexedAt: new Date((record.value as unknown as UptimeCheck).checkedAt), 50 53 })); 54 + 55 + return { 56 + records, 57 + cursor: response.cursor, 58 + }; 59 + } 60 + 61 + /** 62 + * fetches up to 2000 uptime check records using cursor pagination with progress callback 63 + * 64 + * @param pds the PDS URL 65 + * @param did the DID or handle to fetch records for 66 + * @param onProgress optional callback called with each batch of records 67 + * @returns array of uptime check records (max 2000) 68 + */ 69 + export async function fetchInitialUptimeChecks( 70 + pds: string, 71 + did: ActorIdentifier, 72 + onProgress?: (records: UptimeCheckRecord[]) => void, 73 + ): Promise<UptimeCheckRecord[]> { 74 + let allRecords: UptimeCheckRecord[] = []; 75 + let cursor: string | undefined; 76 + const maxRecords = 2000; 77 + const batchSize = 100; 78 + let totalFetched = 0; 79 + 80 + do { 81 + const result = await fetchUptimeChecks(pds, did, cursor); 82 + const newRecords = result.records; 83 + allRecords = allRecords.concat(newRecords); 84 + totalFetched += newRecords.length; 85 + cursor = result.cursor; 86 + 87 + // Call progress callback with the accumulated records 88 + if (onProgress) { 89 + onProgress(allRecords); 90 + } 91 + } while (cursor && totalFetched < maxRecords); 92 + 93 + return allRecords; 51 94 }
+8 -1
web/src/lib/config.ts
··· 9 9 did: ActorIdentifier; 10 10 title: string; 11 11 subtitle: string; 12 + /** whether to show the 5-minute inactivity warning */ 13 + showInactivityWarning?: boolean; 14 + /** minutes to check for recent activity (default: 5) */ 15 + inactivityThresholdMinutes?: number; 12 16 }; 13 17 14 - export const config = __CONFIG__; 18 + export const config = __CONFIG__ as typeof __CONFIG__ & { 19 + showInactivityWarning: boolean; 20 + inactivityThresholdMinutes: number; 21 + }; 15 22