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

yay

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