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

yay

+21
.gitignore
··· 1 + .DS_Store 2 + .research/ 3 + 4 + # Configuration files with secrets 5 + worker/config.json 6 + web/config.json 7 + 8 + # Dependencies 9 + node_modules/ 10 + worker/node_modules/ 11 + web/node_modules/ 12 + 13 + # Build output 14 + web/dist/ 15 + worker/dist/ 16 + 17 + # Locks 18 + package-lock.json 19 + bun.lock 20 + worker/bun.lock 21 + web/bun.lock
+63
lexicon/pet/nkp/uptime/check.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pet.nkp.uptime.check", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "a record representing a single uptime check for a monitored service", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["serviceName", "serviceUrl", "checkedAt", "status", "responseTime"], 12 + "properties": { 13 + "groupName": { 14 + "type": "string", 15 + "description": "optional group or project name that multiple services belong to (e.g., 'wisp.place')", 16 + "maxLength": 100 17 + }, 18 + "serviceName": { 19 + "type": "string", 20 + "description": "human-readable name of the service being monitored", 21 + "maxLength": 100 22 + }, 23 + "region": { 24 + "type": "string", 25 + "description": "optional region where the service is located (e.g., 'US East', 'Singapore')", 26 + "maxLength": 50 27 + }, 28 + "serviceUrl": { 29 + "type": "string", 30 + "format": "uri", 31 + "description": "URL of the service being checked" 32 + }, 33 + "checkedAt": { 34 + "type": "string", 35 + "format": "datetime", 36 + "description": "timestamp when the check was performed" 37 + }, 38 + "status": { 39 + "type": "string", 40 + "enum": ["up", "down"], 41 + "description": "status of the service at check time" 42 + }, 43 + "responseTime": { 44 + "type": "integer", 45 + "description": "response time in milliseconds, -1 if service is down", 46 + "minimum": -1 47 + }, 48 + "httpStatus": { 49 + "type": "integer", 50 + "description": "HTTP status code if applicable", 51 + "minimum": 100, 52 + "maximum": 599 53 + }, 54 + "errorMessage": { 55 + "type": "string", 56 + "description": "error message if the check failed", 57 + "maxLength": 500 58 + } 59 + } 60 + } 61 + } 62 + } 63 + }
+15
package.json
··· 1 + { 2 + "name": "cuteuptime", 3 + "version": "0.1.0", 4 + "private": true, 5 + "scripts": { 6 + "worker:dev": "cd worker && bun run dev", 7 + "worker:start": "cd worker && bun run start", 8 + "web:dev": "cd web && npm run dev", 9 + "web:build": "cd web && npm run build", 10 + "web:preview": "cd web && npm run preview" 11 + }, 12 + "dependencies": { 13 + "@atcute/atproto": "^3.1.9" 14 + } 15 + }
+148
readme.md
··· 1 + # cuteuptime 2 + 3 + Cute uptime monitoring using your PDS to store events. 4 + 5 + ## Project Structure 6 + 7 + - **`worker/`** - Background Bun worker that monitors services and publishes uptime checks 8 + - **`web/`** - Static web svelte dashboard that displays uptime statistics 9 + - **`lexicon/`** - AT Protocol lexicon definitions for the uptime check record type 10 + 11 + ## Quick Start 12 + 13 + ### 1. Configure the Worker 14 + 15 + ```bash 16 + cd worker 17 + cp config.example.json config.json 18 + ``` 19 + 20 + Edit `config.json`: 21 + 22 + ```json 23 + { 24 + "pds": "https://bsky.social", 25 + "identifier": "your.handle.bsky.social", 26 + "password": "your-app-password", 27 + "checkInterval": 300, 28 + "services": [ 29 + { 30 + "groupName": "Production", 31 + "name": "API Server", 32 + "url": "https://api.example.com/health", 33 + "method": "GET", 34 + "timeout": 10000, 35 + "expectedStatus": 200 36 + } 37 + ] 38 + } 39 + ``` 40 + 41 + **Important:** Use an app password, not your main account password. Generate one at: https://bsky.app/settings/app-passwords 42 + 43 + ### 2. Run the Worker 44 + 45 + ```bash 46 + cd worker 47 + bun install 48 + bun run dev 49 + ``` 50 + 51 + The worker will: 52 + - Check each service at the configured interval 53 + - Publish results to your AT Protocol PDS 54 + - Continue running until you stop it 55 + 56 + ### 3. Configure the Web Dashboard 57 + 58 + ```bash 59 + cd web 60 + cp config.example.json config.json 61 + ``` 62 + 63 + Edit `config.json`: 64 + 65 + ```json 66 + { 67 + "pds": "https://bsky.social", 68 + "did": "did:plc:your-did-here" 69 + } 70 + ``` 71 + 72 + To find your DID, visit: https://bsky.app/profile/[your-handle] and look in the URL or use the AT Protocol explorer. 73 + 74 + ### 4. Build and Deploy the Web Dashboard 75 + 76 + ```bash 77 + cd web 78 + npm install 79 + npm run build 80 + ``` 81 + 82 + The built static site will be in `web/dist/`. Deploy it to any static hosting: 83 + 84 + - **Wisp Place**: Drag and drop the `dist` folder 85 + - **GitHub Pages**: Push to `gh-pages` branch 86 + - **Netlify**: Drag and drop the `dist` folder 87 + - **Vercel**: Connect your repo and set build directory to `web/dist` 88 + - **Cloudflare Pages**: Connect your repo 89 + 90 + ## Configuration 91 + 92 + ### Worker Configuration 93 + 94 + | Field | Description | 95 + |-------|-------------| 96 + | `pds` | Your PDS URL (usually `https://bsky.social`) | 97 + | `identifier` | Your AT Protocol handle | 98 + | `password` | Your app password | 99 + | `checkInterval` | Seconds between checks (e.g., 300 = 5 minutes) | 100 + | `services` | Array of services to monitor | 101 + 102 + ### Service Configuration 103 + 104 + | Field | Description | 105 + |-------|-------------| 106 + | `groupName` | Optional group name (e.g., "Production", "Staging") | 107 + | `name` | Service display name | 108 + | `url` | URL to check | 109 + | `method` | HTTP method (GET, POST, etc.) | 110 + | `timeout` | Request timeout in milliseconds | 111 + | `expectedStatus` | Expected HTTP status code (optional) | 112 + 113 + ### Web Configuration 114 + 115 + | Field | Description | 116 + |-------|-------------| 117 + | `pds` | PDS URL to fetch records from | 118 + | `did` | DID of the account publishing uptime checks | 119 + 120 + **Note:** The web config is injected at build time, so you need to rebuild after changing it. 121 + 122 + ## Features 123 + 124 + - ✅ Looks cute 125 + - ✅ No database required just use your pds 126 + - ✅ Service grouping support 127 + - ✅ Response time tracking 128 + - ✅ Auto-refresh every x configurable minutes 129 + 130 + ## Development 131 + 132 + ### Worker Development 133 + 134 + ```bash 135 + cd worker 136 + bun run dev 137 + ``` 138 + 139 + ### Web Development 140 + 141 + ```bash 142 + cd web 143 + npm run dev 144 + ``` 145 + 146 + Visit http://localhost:5173 to see the dashboard. 147 + 148 + MIT
+3
web/.gitignore
··· 1 + node_modules/ 2 + dist/ 3 + .DS_Store
+7
web/config.example.json
··· 1 + { 2 + "pds": "https://bsky.social", 3 + "did": "did:plc:your-did-here", 4 + "title": "cuteuptime", 5 + "subtitle": "cute uptime monitoring using your PDS to store events" 6 + } 7 +
+12
web/index.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 + <title>cuteuptime</title> 7 + </head> 8 + <body> 9 + <div id="app"></div> 10 + <script type="module" src="/src/main.ts"></script> 11 + </body> 12 + </html>
+23
web/package.json
··· 1 + { 2 + "name": "@cuteuptime/web", 3 + "version": "0.1.0", 4 + "type": "module", 5 + "scripts": { 6 + "dev": "vite", 7 + "build": "vite build", 8 + "preview": "vite preview" 9 + }, 10 + "dependencies": { 11 + "@atcute/atproto": "^3.1.9", 12 + "@atcute/client": "^4.0.5" 13 + }, 14 + "devDependencies": { 15 + "@sveltejs/vite-plugin-svelte": "^5.0.2", 16 + "@tailwindcss/postcss": "^4.1.17", 17 + "autoprefixer": "^10.4.22", 18 + "svelte": "^5.2.9", 19 + "tailwindcss": "^4.1.17", 20 + "typescript": "^5.7.2", 21 + "vite": "^6.0.1" 22 + } 23 + }
+6
web/postcss.config.js
··· 1 + export default { 2 + plugins: { 3 + '@tailwindcss/postcss': {}, 4 + }, 5 + }; 6 +
+137
web/src/app.css
··· 1 + @import "tailwindcss"; 2 + 3 + :root { 4 + color-scheme: light; 5 + /* Warm beige background inspired by Sunset design #E9DDD8 */ 6 + --background: oklch(0.90 0.012 35); 7 + /* Very dark brown text for strong contrast #2A2420 */ 8 + --foreground: oklch(0.18 0.01 30); 9 + /* Slightly lighter card background */ 10 + --card: oklch(0.93 0.01 35); 11 + --card-foreground: oklch(0.18 0.01 30); 12 + --popover: oklch(0.93 0.01 35); 13 + --popover-foreground: oklch(0.18 0.01 30); 14 + /* Dark brown primary inspired by #645343 */ 15 + --primary: oklch(0.35 0.02 35); 16 + --primary-foreground: oklch(0.95 0.01 35); 17 + /* Bright pink accent for links #FFAAD2 */ 18 + --accent: oklch(0.78 0.15 345); 19 + --accent-foreground: oklch(0.18 0.01 30); 20 + /* Medium taupe secondary inspired by #867D76 */ 21 + --secondary: oklch(0.52 0.015 30); 22 + --secondary-foreground: oklch(0.95 0.01 35); 23 + /* Light warm muted background */ 24 + --muted: oklch(0.88 0.01 35); 25 + --muted-foreground: oklch(0.42 0.015 30); 26 + --border: oklch(0.75 0.015 30); 27 + --input: oklch(0.92 0.01 35); 28 + --ring: oklch(0.72 0.08 15); 29 + --destructive: oklch(0.577 0.245 27.325); 30 + --destructive-foreground: oklch(0.985 0 0); 31 + --chart-1: oklch(0.78 0.15 345); 32 + --chart-2: oklch(0.32 0.04 285); 33 + --chart-3: oklch(0.56 0.08 220); 34 + --chart-4: oklch(0.50 0.10 145); 35 + --chart-5: oklch(0.93 0.03 85); 36 + --radius: 0.75rem; 37 + } 38 + 39 + @media (prefers-color-scheme: dark) { 40 + :root { 41 + color-scheme: dark; 42 + /* Slate violet background - #2C2C2C with violet tint */ 43 + --background: oklch(0.23 0.015 285); 44 + /* Light gray text - #E4E4E4 */ 45 + --foreground: oklch(0.90 0.005 285); 46 + /* Slightly lighter slate for cards */ 47 + --card: oklch(0.28 0.015 285); 48 + --card-foreground: oklch(0.90 0.005 285); 49 + --popover: oklch(0.28 0.015 285); 50 + --popover-foreground: oklch(0.90 0.005 285); 51 + /* Lavender buttons - #B39CD0 */ 52 + --primary: oklch(0.70 0.10 295); 53 + --primary-foreground: oklch(0.23 0.015 285); 54 + /* Soft pink accent - #FFC1CC */ 55 + --accent: oklch(0.85 0.08 5); 56 + --accent-foreground: oklch(0.23 0.015 285); 57 + /* Light cyan secondary - #A8DADC */ 58 + --secondary: oklch(0.82 0.05 200); 59 + --secondary-foreground: oklch(0.23 0.015 285); 60 + /* Muted slate areas */ 61 + --muted: oklch(0.33 0.015 285); 62 + --muted-foreground: oklch(0.72 0.01 285); 63 + /* Subtle borders */ 64 + --border: oklch(0.38 0.02 285); 65 + --input: oklch(0.30 0.015 285); 66 + --ring: oklch(0.70 0.10 295); 67 + /* Warm destructive color */ 68 + --destructive: oklch(0.60 0.22 27); 69 + --destructive-foreground: oklch(0.98 0.01 85); 70 + /* Chart colors using the accent palette */ 71 + --chart-1: oklch(0.85 0.08 5); 72 + --chart-2: oklch(0.82 0.05 200); 73 + --chart-3: oklch(0.70 0.10 295); 74 + --chart-4: oklch(0.75 0.08 340); 75 + --chart-5: oklch(0.65 0.08 180); 76 + } 77 + } 78 + 79 + @theme inline { 80 + --color-background: var(--background); 81 + --color-foreground: var(--foreground); 82 + --color-card: var(--card); 83 + --color-card-foreground: var(--card-foreground); 84 + --color-popover: var(--popover); 85 + --color-popover-foreground: var(--popover-foreground); 86 + --color-primary: var(--primary); 87 + --color-primary-foreground: var(--primary-foreground); 88 + --color-secondary: var(--secondary); 89 + --color-secondary-foreground: var(--secondary-foreground); 90 + --color-muted: var(--muted); 91 + --color-muted-foreground: var(--muted-foreground); 92 + --color-accent: var(--accent); 93 + --color-accent-foreground: var(--accent-foreground); 94 + --color-destructive: var(--destructive); 95 + --color-destructive-foreground: var(--destructive-foreground); 96 + --color-border: var(--border); 97 + --color-input: var(--input); 98 + --color-ring: var(--ring); 99 + --color-chart-1: var(--chart-1); 100 + --color-chart-2: var(--chart-2); 101 + --color-chart-3: var(--chart-3); 102 + --color-chart-4: var(--chart-4); 103 + --color-chart-5: var(--chart-5); 104 + --radius-sm: calc(var(--radius) - 4px); 105 + --radius-md: calc(var(--radius) - 2px); 106 + --radius-lg: var(--radius); 107 + --radius-xl: calc(var(--radius) + 4px); 108 + } 109 + 110 + @layer base { 111 + * { 112 + @apply border-border outline-ring/50; 113 + } 114 + body { 115 + @apply bg-background text-foreground; 116 + } 117 + } 118 + 119 + @keyframes arrow-bounce { 120 + 0%, 100% { 121 + transform: translateX(0); 122 + } 123 + 50% { 124 + transform: translateX(4px); 125 + } 126 + } 127 + 128 + .arrow-animate { 129 + animation: arrow-bounce 1.5s ease-in-out infinite; 130 + } 131 + 132 + @keyframes shimmer { 133 + 100% { 134 + transform: translateX(100%); 135 + } 136 + } 137 +
+86
web/src/app.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { fetchUptimeChecks } from './lib/atproto.ts'; 4 + import UptimeDisplay from './lib/uptime-display.svelte'; 5 + import type { UptimeCheckRecord } from './lib/types.ts'; 6 + import { config } from './lib/config.ts'; 7 + 8 + let checks = $state<UptimeCheckRecord[]>([]); 9 + let loading = $state(true); 10 + let error = $state(''); 11 + let lastUpdate = $state<Date | null>(null); 12 + 13 + async function loadChecks() { 14 + loading = true; 15 + error = ''; 16 + 17 + try { 18 + checks = await fetchUptimeChecks(config.pds, config.did); 19 + lastUpdate = new Date(); 20 + } catch (err) { 21 + error = (err as Error).message || 'failed to fetch uptime checks'; 22 + checks = []; 23 + } finally { 24 + loading = false; 25 + } 26 + } 27 + 28 + onMount(() => { 29 + // load checks immediately 30 + loadChecks(); 31 + 32 + // refresh every 10 seconds 33 + const interval = setInterval(loadChecks, 10 * 1000); 34 + return () => clearInterval(interval); 35 + }); 36 + </script> 37 + 38 + <main class="max-w-6xl mx-auto p-8"> 39 + <header class="text-center mb-8"> 40 + <h1 class="text-5xl font-bold text-accent mb-2">{config.title}</h1> 41 + <p class="text-muted-foreground">{config.subtitle}</p> 42 + </header> 43 + 44 + <div class="bg-card rounded-lg shadow-sm p-4 mb-8 flex justify-between items-center"> 45 + <div class="flex items-center gap-4"> 46 + {#if lastUpdate} 47 + <span class="text-sm text-muted-foreground"> 48 + last updated: {lastUpdate.toLocaleTimeString()} 49 + </span> 50 + {/if} 51 + </div> 52 + <button 53 + 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 + onclick={loadChecks} 55 + disabled={loading} 56 + > 57 + {loading ? 'refreshing...' : 'refresh'} 58 + </button> 59 + </div> 60 + 61 + {#if error} 62 + <div class="bg-destructive/10 text-destructive rounded-lg p-4 mb-4"> 63 + {error} 64 + </div> 65 + {/if} 66 + 67 + {#if loading && checks.length === 0} 68 + <div class="text-center py-12 bg-card rounded-lg shadow-sm text-muted-foreground"> 69 + loading uptime data... 70 + </div> 71 + {:else if checks.length > 0} 72 + <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 + {/if} 78 + 79 + <footer class="mt-12 pt-8 border-t border-border text-center text-sm text-muted-foreground"> 80 + <p> 81 + built by <a href="https://bsky.app/profile/nekomimi.pet" target="_blank" rel="noopener noreferrer" class="text-accent hover:underline">@nekomimi.pet</a> 82 + · <a href="https://tangled.org/@nekomimi.pet/cute-monitor" target="_blank" rel="noopener noreferrer" class="text-accent hover:underline">source</a> 83 + </p> 84 + </footer> 85 + </main> 86 +
+51
web/src/lib/atproto.ts
··· 1 + import { Client, ok, simpleFetchHandler } from '@atcute/client'; 2 + import type {} from '@atcute/atproto'; 3 + import type { ActorIdentifier, Did, Handle } from '@atcute/lexicons'; 4 + import type { UptimeCheck, UptimeCheckRecord } from './types.ts'; 5 + 6 + /** 7 + * fetches uptime check records from a PDS for a given DID 8 + * 9 + * @param pds the PDS URL 10 + * @param did the DID or handle to fetch records for 11 + * @returns array of uptime check records 12 + */ 13 + export async function fetchUptimeChecks( 14 + pds: string, 15 + did: ActorIdentifier, 16 + ): Promise<UptimeCheckRecord[]> { 17 + const handler = simpleFetchHandler({ service: pds }); 18 + const rpc = new Client({ handler }); 19 + 20 + // resolve handle to DID if needed 21 + let resolvedDid: Did; 22 + if (!did.startsWith('did:')) { 23 + const handleData = await ok( 24 + rpc.get('com.atproto.identity.resolveHandle', { 25 + params: { handle: did as Handle }, 26 + }), 27 + ); 28 + resolvedDid = handleData.did; 29 + } else { 30 + resolvedDid = did as Did; 31 + } 32 + 33 + // fetch uptime check records 34 + const response = await ok( 35 + rpc.get('com.atproto.repo.listRecords', { 36 + params: { 37 + repo: resolvedDid, 38 + collection: 'pet.nkp.uptime.check', 39 + limit: 100, 40 + }, 41 + }), 42 + ); 43 + 44 + // transform records into a more usable format 45 + return response.records.map((record) => ({ 46 + uri: record.uri, 47 + cid: record.cid, 48 + value: record.value as unknown as UptimeCheck, 49 + indexedAt: new Date((record.value as unknown as UptimeCheck).checkedAt), 50 + })); 51 + }
+15
web/src/lib/config.ts
··· 1 + /** 2 + * build-time configuration 3 + */ 4 + 5 + import type { ActorIdentifier } from '@atcute/lexicons'; 6 + 7 + declare const __CONFIG__: { 8 + pds: string; 9 + did: ActorIdentifier; 10 + title: string; 11 + subtitle: string; 12 + }; 13 + 14 + export const config = __CONFIG__; 15 +
+33
web/src/lib/types.ts
··· 1 + /** 2 + * uptime check record value 3 + */ 4 + export interface UptimeCheck { 5 + /** optional group or project name that multiple services belong to */ 6 + groupName?: string; 7 + /** human-readable name of the service */ 8 + serviceName: string; 9 + /** optional region where the service is located */ 10 + region?: string; 11 + /** URL that was checked */ 12 + serviceUrl: string; 13 + /** timestamp when the check was performed */ 14 + checkedAt: string; 15 + /** status of the service */ 16 + status: 'up' | 'down'; 17 + /** response time in milliseconds, -1 if down */ 18 + responseTime: number; 19 + /** HTTP status code if applicable */ 20 + httpStatus?: number; 21 + /** error message if the check failed */ 22 + errorMessage?: string; 23 + } 24 + 25 + /** 26 + * uptime check record from PDS 27 + */ 28 + export interface UptimeCheckRecord { 29 + uri: string; 30 + cid: string; 31 + value: UptimeCheck; 32 + indexedAt: Date; 33 + }
+129
web/src/lib/uptime-display.svelte
··· 1 + <script lang="ts"> 2 + import type { UptimeCheckRecord } from './types.ts'; 3 + 4 + interface Props { 5 + checks: UptimeCheckRecord[]; 6 + } 7 + 8 + const { checks }: Props = $props(); 9 + 10 + // group checks by group name, then by region, then by service 11 + const groupedData = $derived(() => { 12 + const groups = new Map<string, Map<string, Map<string, UptimeCheckRecord[]>>>(); 13 + 14 + for (const check of checks) { 15 + const groupName = check.value.groupName || 'ungrouped'; 16 + const region = check.value.region || 'unknown'; 17 + const serviceName = check.value.serviceName; 18 + 19 + if (!groups.has(groupName)) { 20 + groups.set(groupName, new Map<string, Map<string, UptimeCheckRecord[]>>()); 21 + } 22 + 23 + const regionMap = groups.get(groupName)!; 24 + if (!regionMap.has(region)) { 25 + regionMap.set(region, new Map<string, UptimeCheckRecord[]>()); 26 + } 27 + 28 + const serviceMap = regionMap.get(region)!; 29 + if (!serviceMap.has(serviceName)) { 30 + serviceMap.set(serviceName, []); 31 + } 32 + serviceMap.get(serviceName)!.push(check); 33 + } 34 + 35 + // sort checks within each service by time (newest first) 36 + for (const [, regionMap] of groups) { 37 + for (const [, serviceMap] of regionMap) { 38 + for (const [, serviceChecks] of serviceMap) { 39 + serviceChecks.sort((a, b) => b.indexedAt.getTime() - a.indexedAt.getTime()); 40 + } 41 + } 42 + } 43 + 44 + return groups; 45 + }); 46 + 47 + function calculateUptime(checks: UptimeCheckRecord[]): string { 48 + if (checks.length === 0) { 49 + return '0'; 50 + } 51 + const upChecks = checks.filter((c) => c.value.status === 'up').length; 52 + return ((upChecks / checks.length) * 100).toFixed(2); 53 + } 54 + 55 + function formatResponseTime(ms: number): string { 56 + if (ms < 0) { 57 + return 'N/A'; 58 + } 59 + if (ms < 1000) { 60 + return `${ms}ms`; 61 + } 62 + return `${(ms / 1000).toFixed(2)}s`; 63 + } 64 + 65 + function formatTimestamp(date: Date): string { 66 + return new Intl.DateTimeFormat('en-US', { 67 + dateStyle: 'short', 68 + timeStyle: 'short', 69 + }).format(date); 70 + } 71 + </script> 72 + 73 + <div class="mt-8"> 74 + <h2 class="text-2xl font-semibold mb-6">uptime statistics</h2> 75 + 76 + {#each [...groupedData()] as [groupName, regionMap]} 77 + <div class="mb-8"> 78 + {#if groupName !== 'ungrouped'} 79 + <h2 class="text-3xl font-bold text-accent mb-4 pb-2 border-b-2 border-accent">{groupName}</h2> 80 + {/if} 81 + 82 + {#each [...regionMap] as [region, serviceMap]} 83 + <div class="mb-8"> 84 + <h3 class="text-xl font-semibold text-foreground mb-4 pl-2 border-l-4 border-accent">{region}</h3> 85 + 86 + {#each [...serviceMap] as [serviceName, serviceChecks]} 87 + <div class="bg-card rounded-lg shadow-sm p-6 mb-6"> 88 + <div class="flex justify-between items-center mb-2"> 89 + <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 92 + </div> 93 + </div> 94 + 95 + <div class="text-sm text-muted-foreground mb-4 break-all"> 96 + {serviceChecks[0].value.serviceUrl} 97 + </div> 98 + 99 + <div class="flex gap-1 flex-wrap mb-4"> 100 + {#each serviceChecks.slice(0, 20) as check} 101 + <div 102 + class="w-3 h-3 rounded-sm cursor-pointer transition-transform hover:scale-150 {check.value.status === 'up' ? 'bg-chart-4' : 'bg-destructive'}" 103 + title={`${check.value.status} - ${formatResponseTime(check.value.responseTime)} - ${formatTimestamp(check.indexedAt)}`} 104 + ></div> 105 + {/each} 106 + </div> 107 + 108 + <div class="flex flex-wrap gap-4 items-center pt-4 border-t border-border text-sm"> 109 + <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'}"> 110 + {serviceChecks[0].value.status} 111 + </span> 112 + {#if serviceChecks[0].value.status === 'up'} 113 + <span>response time: {formatResponseTime(serviceChecks[0].value.responseTime)}</span> 114 + {#if serviceChecks[0].value.httpStatus} 115 + <span>HTTP {serviceChecks[0].value.httpStatus}</span> 116 + {/if} 117 + {:else if serviceChecks[0].value.errorMessage} 118 + <span class="text-destructive">{serviceChecks[0].value.errorMessage}</span> 119 + {/if} 120 + <span class="text-muted-foreground ml-auto">checked {formatTimestamp(serviceChecks[0].indexedAt)}</span> 121 + </div> 122 + </div> 123 + {/each} 124 + </div> 125 + {/each} 126 + </div> 127 + {/each} 128 + </div> 129 +
+9
web/src/main.ts
··· 1 + import { mount } from 'svelte'; 2 + import App from './app.svelte'; 3 + import './app.css'; 4 + 5 + const app = mount(App, { 6 + target: document.getElementById('app')!, 7 + }); 8 + 9 + export default app;
+5
web/svelte.config.js
··· 1 + import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; 2 + 3 + export default { 4 + preprocess: vitePreprocess(), 5 + };
+18
web/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "target": "ESNext", 4 + "module": "ESNext", 5 + "lib": ["ESNext", "DOM", "DOM.Iterable"], 6 + "moduleResolution": "bundler", 7 + "resolveJsonModule": true, 8 + "allowImportingTsExtensions": true, 9 + "strict": true, 10 + "skipLibCheck": true, 11 + "noEmit": true, 12 + "isolatedModules": true, 13 + "esModuleInterop": true, 14 + "forceConsistentCasingInFileNames": true 15 + }, 16 + "include": ["src/**/*.ts", "src/**/*.svelte"], 17 + "exclude": ["node_modules", "dist"] 18 + }
+16
web/vite.config.js
··· 1 + import { defineConfig } from 'vite'; 2 + import { svelte } from '@sveltejs/vite-plugin-svelte'; 3 + import { readFileSync } from 'fs'; 4 + import { resolve } from 'path'; 5 + 6 + // read config at build time 7 + const config = JSON.parse( 8 + readFileSync(resolve(__dirname, 'config.json'), 'utf-8') 9 + ); 10 + 11 + export default defineConfig({ 12 + plugins: [svelte()], 13 + define: { 14 + __CONFIG__: JSON.stringify(config), 15 + }, 16 + });
+2
worker/.gitignore
··· 1 + node_modules/ 2 + config.json
+39
worker/config.example.json
··· 1 + { 2 + "pds": "https://bsky.social", 3 + "identifier": "your-handle.bsky.social", 4 + "password": "your-app-password", 5 + "checkInterval": 180, 6 + "services": [ 7 + { 8 + "group": "Production", 9 + "name": "US East", 10 + "url": "https://us-east.example.com", 11 + "timeout": 5000 12 + }, 13 + { 14 + "group": "Production", 15 + "name": "US West", 16 + "url": "https://us-west.example.com", 17 + "timeout": 5000 18 + }, 19 + { 20 + "group": "Production", 21 + "name": "Netherlands", 22 + "url": "https://nl.example.com", 23 + "timeout": 5000 24 + }, 25 + { 26 + "group": "Production", 27 + "name": "Singapore", 28 + "url": "https://sg.example.com", 29 + "timeout": 5000 30 + }, 31 + { 32 + "group": "Direct IP Example", 33 + "name": "US East (Direct IP)", 34 + "url": "http://203.0.113.10", 35 + "host": "example.com", 36 + "timeout": 5000 37 + } 38 + ] 39 + }
+17
worker/package.json
··· 1 + { 2 + "name": "@cuteuptime/worker", 3 + "version": "0.1.0", 4 + "type": "module", 5 + "scripts": { 6 + "dev": "bun run src/index.ts", 7 + "start": "bun run src/index.ts" 8 + }, 9 + "dependencies": { 10 + "@atcute/client": "^4.0.5", 11 + "@atcute/atproto": "*" 12 + }, 13 + "devDependencies": { 14 + "@types/bun": "latest", 15 + "@types/node": "latest" 16 + } 17 + }
+113
worker/src/index.ts
··· 1 + import { Client, CredentialManager, ok } from '@atcute/client'; 2 + import type {} from '@atcute/atproto'; 3 + import type { Did } from '@atcute/lexicons'; 4 + import { readFile } from 'node:fs/promises'; 5 + import { checkService } from './pinger.ts'; 6 + import type { ServiceConfig, UptimeCheck } from './types.ts'; 7 + 8 + /** 9 + * main worker function that monitors services and publishes uptime checks to AT Protocol 10 + */ 11 + async function main() { 12 + // load configuration 13 + const configPath = new URL('../config.json', import.meta.url); 14 + const configData = await readFile(configPath, 'utf-8'); 15 + const config = JSON.parse(configData) as { 16 + pds: string; 17 + identifier: string; 18 + password: string; 19 + services: ServiceConfig[]; 20 + checkInterval: number; 21 + }; 22 + 23 + // initialize AT Protocol client with authentication 24 + const manager = new CredentialManager({ service: config.pds }); 25 + const rpc = new Client({ handler: manager }); 26 + 27 + // authenticate with app password 28 + await manager.login({ 29 + identifier: config.identifier, 30 + password: config.password, 31 + }); 32 + 33 + console.log(`authenticated as ${manager.session?.did}`); 34 + 35 + // function to perform checks and publish results 36 + const performChecks = async () => { 37 + console.log(`\nchecking ${config.services.length} services...`); 38 + 39 + // run all checks concurrently 40 + const checkPromises = config.services.map(async (service) => { 41 + try { 42 + const check = await checkService(service); 43 + await publishCheck(rpc, manager.session!.did, check); 44 + console.log( 45 + `✓ ${service.name}: ${check.status} (${check.responseTime}ms)`, 46 + ); 47 + } catch (error) { 48 + console.error(`✗ ${service.name}: error publishing check`, error); 49 + } 50 + }); 51 + 52 + // wait for all checks to complete 53 + await Promise.all(checkPromises); 54 + }; 55 + 56 + // run checks immediately 57 + await performChecks(); 58 + 59 + // schedule periodic checks 60 + console.log(`scheduling checks every ${config.checkInterval} seconds`); 61 + const interval = setInterval(performChecks, config.checkInterval * 1000); 62 + 63 + // keep the process alive 64 + interval.unref(); 65 + process.stdin.resume(); 66 + 67 + // handle graceful shutdown 68 + process.on('SIGINT', () => { 69 + console.log('\nshutting down gracefully...'); 70 + clearInterval(interval); 71 + process.exit(0); 72 + }); 73 + 74 + process.on('SIGTERM', () => { 75 + console.log('\nshutting down gracefully...'); 76 + clearInterval(interval); 77 + process.exit(0); 78 + }); 79 + } 80 + 81 + /** 82 + * publishes an uptime check record to the PDS 83 + * 84 + * @param rpc the client instance 85 + * @param did the DID of the authenticated user 86 + * @param check the uptime check data to publish 87 + */ 88 + async function publishCheck(rpc: Client, did: Did, check: UptimeCheck) { 89 + await ok( 90 + rpc.post('com.atproto.repo.createRecord', { 91 + input: { 92 + repo: did, 93 + collection: 'pet.nkp.uptime.check', 94 + record: { 95 + ...(check.groupName && { groupName: check.groupName }), 96 + serviceName: check.serviceName, 97 + ...(check.region && { region: check.region }), 98 + serviceUrl: check.serviceUrl, 99 + checkedAt: check.checkedAt, 100 + status: check.status, 101 + responseTime: check.responseTime, 102 + ...(check.httpStatus && { httpStatus: check.httpStatus }), 103 + ...(check.errorMessage && { errorMessage: check.errorMessage }), 104 + }, 105 + }, 106 + }), 107 + ); 108 + } 109 + 110 + main().catch((error) => { 111 + console.error('fatal error:', error); 112 + process.exit(1); 113 + });
+69
worker/src/pinger.ts
··· 1 + import type { ServiceConfig, UptimeCheck } from './types.ts'; 2 + 3 + /** 4 + * performs an uptime check on a service 5 + * 6 + * @param service the service configuration 7 + * @returns the uptime check result 8 + */ 9 + export async function checkService(service: ServiceConfig): Promise<UptimeCheck> { 10 + const timeout = service.timeout || 5000; 11 + const startTime = Date.now(); 12 + const checkedAt = new Date().toISOString(); 13 + 14 + try { 15 + const controller = new AbortController(); 16 + const timeoutId = setTimeout(() => { 17 + controller.abort(); 18 + }, timeout); 19 + 20 + const headers: HeadersInit = {}; 21 + if (service.host) { 22 + headers.Host = service.host; 23 + } 24 + 25 + const response = await fetch(service.url, { 26 + method: 'GET', 27 + signal: controller.signal, 28 + redirect: 'follow', 29 + headers, 30 + }); 31 + 32 + clearTimeout(timeoutId); 33 + 34 + const responseTime = Date.now() - startTime; 35 + 36 + return { 37 + ...(service.group && { groupName: service.group }), 38 + serviceName: service.name, 39 + ...(service.region && { region: service.region }), 40 + serviceUrl: service.url, 41 + checkedAt, 42 + status: 'up', 43 + responseTime, 44 + httpStatus: response.status, 45 + }; 46 + } catch (error) { 47 + const responseTime = Date.now() - startTime; 48 + let errorMessage = 'unknown error'; 49 + 50 + if (error instanceof Error) { 51 + if (error.name === 'AbortError') { 52 + errorMessage = `timeout after ${timeout}ms`; 53 + } else { 54 + errorMessage = error.message; 55 + } 56 + } 57 + 58 + return { 59 + ...(service.group && { groupName: service.group }), 60 + serviceName: service.name, 61 + ...(service.region && { region: service.region }), 62 + serviceUrl: service.url, 63 + checkedAt, 64 + status: 'down', 65 + responseTime: -1, 66 + errorMessage, 67 + }; 68 + } 69 + }
+41
worker/src/types.ts
··· 1 + /** 2 + * configuration for a service to monitor 3 + */ 4 + export interface ServiceConfig { 5 + /** optional group or project name that multiple services belong to */ 6 + group?: string; 7 + /** human-readable name of the service */ 8 + name: string; 9 + /** optional region where the service is located */ 10 + region?: string; 11 + /** URL to check */ 12 + url: string; 13 + /** optional timeout in milliseconds (default: 5000) */ 14 + timeout?: number; 15 + /** optional Host header to use (for direct IP checks) */ 16 + host?: string; 17 + } 18 + 19 + /** 20 + * result of an uptime check 21 + */ 22 + export interface UptimeCheck { 23 + /** optional group or project name that multiple services belong to */ 24 + groupName?: string; 25 + /** human-readable name of the service */ 26 + serviceName: string; 27 + /** optional region where the service is located */ 28 + region?: string; 29 + /** URL that was checked */ 30 + serviceUrl: string; 31 + /** timestamp when the check was performed */ 32 + checkedAt: string; 33 + /** status of the service */ 34 + status: 'up' | 'down'; 35 + /** response time in milliseconds, -1 if down */ 36 + responseTime: number; 37 + /** HTTP status code if applicable */ 38 + httpStatus?: number; 39 + /** error message if the check failed */ 40 + errorMessage?: string; 41 + }
+17
worker/tsconfig.json
··· 1 + { 2 + "compilerOptions": { 3 + "lib": ["ES2020"], 4 + "module": "ESNext", 5 + "target": "ES2020", 6 + "moduleResolution": "bundler", 7 + "types": ["bun-types", "node"], 8 + "skipLibCheck": true, 9 + "strict": true, 10 + "resolveJsonModule": true, 11 + "jsx": "react-jsx", 12 + "allowJs": true, 13 + "allowImportingTsExtensions": true, 14 + "noEmit": true 15 + } 16 + } 17 +