+21
.gitignore
+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
+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
+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
+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
+7
web/config.example.json
+7
web/config.example.json
+12
web/index.html
+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
+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
+6
web/postcss.config.js
+137
web/src/app.css
+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
+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
+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
+15
web/src/lib/config.ts
+33
web/src/lib/types.ts
+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
+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
+9
web/src/main.ts
+5
web/svelte.config.js
+5
web/svelte.config.js
+18
web/tsconfig.json
+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
+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
+
});
+39
worker/config.example.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
+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
+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
+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
+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
+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
+