{"id":50322907,"url":"https://github.com/cryguy/uptime","last_synced_at":"2026-05-29T04:01:26.544Z","repository":{"id":360885587,"uuid":"1252125042","full_name":"cryguy/uptime","owner":"cryguy","description":"Self-hosted uptime monitor with a public status page and admin console. HTTP/TCP/SSH checks, encrypted configs, webhook alerts, ⌘K palette. One Bun process, one SQLite file.","archived":false,"fork":false,"pushed_at":"2026-05-28T08:20:04.000Z","size":382,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-05-28T10:17:28.764Z","etag":null,"topics":["bun","htmx","monitoring","self-hosted","sqlite","status-page","typescript","uptime"],"latest_commit_sha":null,"homepage":null,"language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/cryguy.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-28T08:01:15.000Z","updated_at":"2026-05-28T08:20:10.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/cryguy/uptime","commit_stats":null,"previous_names":["cryguy/uptime"],"tags_count":null,"template":false,"template_full_name":null,"purl":"pkg:github/cryguy/uptime","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cryguy%2Fuptime","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cryguy%2Fuptime/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cryguy%2Fuptime/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cryguy%2Fuptime/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/cryguy","download_url":"https://codeload.github.com/cryguy/uptime/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/cryguy%2Fuptime/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33635961,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-29T02:00:06.066Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["bun","htmx","monitoring","self-hosted","sqlite","status-page","typescript","uptime"],"created_at":"2026-05-29T04:00:48.475Z","updated_at":"2026-05-29T04:01:26.536Z","avatar_url":"https://github.com/cryguy.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Uptime\n\nA self-hosted uptime monitor with a public status page and an admin console — all served by a single [Bun](https://bun.sh) process.\n\n- **Public by default.** Anyone with the URL sees monitor health, latency, uptime history, and active incidents. Secrets (target URLs, webhook tokens, SSH keys, error details) stay admin-only.\n- **One process, one binary, one SQLite file.** No Redis, no message broker, no separate worker. The scheduler, alert delivery loop, and HTTP server all run in the same Bun runtime.\n- **Three check types** out of the box: HTTP (with auth, headers, body matching, custom expected status), TCP port reachability, and SSH (key-based auth, optional command + exit-code assertion).\n- **HTMX-driven UI**, no SPA. ~40 lines of vanilla JS for the sparkline tooltip + command palette. The rest is server-rendered TSX.\n\n---\n\n## Features\n\n**Monitoring**\n- HTTP/HTTPS · TCP · SSH (key-based)\n- Per-monitor interval, timeout, failure threshold, success threshold\n- Sparkline (latency, last hour) and 24h uptime strip per monitor\n- Per-monitor groups, public/private visibility, mute (5m / 30m / 1h / 24h / indefinite)\n- Bulk pause / resume / mute / delete\n\n**Incidents**\n- Auto-opened on `up → down`, auto-closed on `down → up`\n- Acknowledge to silence the banner without resolving\n- Optional auto-acknowledge after N minutes\n- Open / Resolved / All tabs with per-incident timeline\n\n**Alerts**\n- Webhooks fire on state transitions (POST JSON payload)\n- Exponential backoff with dead-letter (5s → 30s → 5m → 30m → dead)\n- Mute suppresses delivery; incident still tracked\n\n**Console**\n- Live dashboard with KPI strip (Up · Down · p95 latency · MTTR)\n- Search, filter (status / type / group), group by, density toggle (comfort / compact / dense)\n- Dark + light themes (cookie-persisted)\n- Command palette (⌘K on Mac · Ctrl+K elsewhere) for navigation, jumping to monitors by name, and quick actions\n- Sound + tab-title flash on new unacked incidents\n- Per-row 5s auto-refresh via HTMX\n\n**API**\n- REST JSON API at `/api/v1/*` for scripts, MCP servers, and IaC tools\n- Bearer token auth with revocable, per-purpose tokens (mint in Settings)\n- ~20 endpoints covering monitors, incidents, webhooks, stats — see the [API section](#api) below\n\n**Security**\n- Argon2id password hashing, session cookies (HttpOnly · SameSite=Lax)\n- Per-IP login rate limiting (5/min)\n- AES-256-GCM encryption at rest for all monitor configs\n- Encryption key rotation re-encrypts all monitors in a single transaction\n- Settings actions are admin-only; public viewers see only redacted summaries\n\n**Operations**\n- DB-backed credentials and encryption key (env values seed the DB on first boot)\n- Configurable retention for check results, alert queue, incidents (with manual purge button)\n- Active session list with individual revoke\n\n---\n\n## Quick start\n\n```bash\ngit clone https://github.com/cryguy/uptime\ncd uptime\nbun install\n\n# Generate secrets and an admin password hash\nbun run keygen \u003e\u003e .env\nbun run hash 'your-password' \u003e\u003e .env\necho 'ADMIN_USERNAME=admin' \u003e\u003e .env\n\n# Run\nbun run dev\n```\n\nOpen \u003chttp://localhost:3000\u003e for the public dashboard, or `/login` for the admin console.\n\nTo boot in production mode (skips the file watcher):\n\n```bash\nNODE_ENV=production bun run start\n```\n\n---\n\n## Configuration\n\nAll configuration is via environment variables. `.env.example` is the canonical reference.\n\n| Variable | Required | Description |\n|---|---|---|\n| `PORT` | – | Defaults to `3000`. |\n| `ADMIN_USERNAME` | yes | Initial admin login. Mutable via UI after first boot. |\n| `ADMIN_PASSWORD_HASH` | yes | Argon2id hash from `bun run hash`. **Note:** `$` characters must be backslash-escaped — the `hash` script does this automatically. |\n| `SESSION_SECRET` | yes | 32 random bytes (hex). Generate with `bun run keygen`. |\n| `ENCRYPTION_KEY` | yes | 32 random bytes (hex). Encrypts monitor configs at rest. **Losing this means losing every monitor's config.** Back it up out-of-band. |\n| `DB_PATH` | – | Defaults to `./data/uptime.db`. |\n| `INCIDENT_AUTO_ACK_MINUTES` | – | `0` (default) disables auto-ack. Otherwise, open unacked incidents older than this are silently acknowledged. |\n| `NODE_ENV` | – | Set to `production` to enable the `Secure` cookie flag. |\n\nAfter first boot, the admin username, password hash, and encryption key are mirrored into the `settings` table and become DB-canonical. You can change them via `/settings` without restarting; the env values become inert defaults that only apply if `settings` is wiped.\n\n---\n\n## How it works\n\n**Scheduler.** A 1-second tick polls for monitors due to be checked (`last_checked_at + interval_seconds * 1000 \u003c= now`). Each due monitor's check fires in parallel; an `inflight` set guarantees a single monitor never has two overlapping checks. State transitions follow a 4-rule machine: `null → up` (silent on first healthy check), `null/up → down` after `failure_threshold` consecutive failures, `down → up` after `success_threshold` consecutive successes. Only `→ down` and `→ up` transitions emit alerts; the initial `null → up` is silent so a fresh healthy monitor doesn't ping the team.\n\n**Alert delivery loop.** Decoupled from the scheduler. State transitions enqueue a row into `alert_queue`; a 5-second tick drains it. Failed deliveries retry with exponential backoff (5s → 30s → 5m → 30m), then dead-letter with `last_error` preserved for forensics. A slow webhook can't stall monitoring — the scheduler keeps running while delivery retries.\n\n**Storage.** SQLite in WAL mode so the dashboard reads concurrently with the scheduler's writes. Monitor configs are stored as a single AES-256-GCM encrypted blob — adding a new check type is just code, no migration. Schema migrations are idempotent (`CREATE TABLE IF NOT EXISTS` + `ALTER TABLE ADD COLUMN` with duplicate-column try/catch).\n\n**Public vs admin rendering.** Routes that need both behaviors (e.g. `/monitors/:id`) use `publicRoute()` and switch on `ctx.isAdmin` to render either the edit form or a read-only summary card. Sensitive fields (target URLs, error detail strings, webhook URLs) are never serialized in the public branch.\n\n---\n\n## Stack\n\n- **Runtime:** [Bun](https://bun.sh) 1.3+ (uses built-in `bun:sqlite`, `fetch`, `Bun.password`, native sockets)\n- **Server:** [`Bun.serve`](https://bun.sh/docs/api/http) — typed routes API, no Express\n- **Database:** SQLite (WAL mode)\n- **Templates:** [`@kitajs/html`](https://github.com/kitajs/html) — JSX that compiles to HTML strings, with `safe` attribute for escaping\n- **Interactivity:** [HTMX 2](https://htmx.org) — search debounce, per-row refresh, form submits without a SPA\n- **SSH client:** [`ssh2`](https://github.com/mscdex/ssh2)\n- **Fonts:** Inter Tight + JetBrains Mono, self-hosted (run `bun run fetch-fonts` to regenerate)\n\nNo build step to develop or run — Bun executes the TSX/JSX directly. The only compile is the optional [release build](#release-builds), which bundles everything into standalone binaries.\n\n---\n\n## File layout\n\n```\nsrc/\n├── index.ts              entry: env, db, scheduler, alerts, Bun.serve\n├── config.ts             env loading + AES-GCM encrypt/decrypt\n├── secrets.ts            DB-backed credentials + encryption key (env-seeded)\n├── db.ts                 SQLite connection, schema, idempotent migrations\n├── auth.ts               argon2 verify, sessions, rate limiting\n├── scheduler.ts          tick loop, 4-rule state machine, incident open/close\n├── alerts.ts             webhook delivery loop with backoff\n├── queries.ts            aggregation queries (KPIs, uptime, sparklines, MTTR)\n├── assets.ts             static files embedded into the release binary\n├── checks/               http / tcp / ssh check implementations\n├── routes/               login · dashboard · monitor · webhooks · incidents · settings · preferences\n└── views/                layout, components, tokens + component CSS\npublic/\n├── htmx.min.js           pinned 2.0.6\n├── spark.js              sparkline hover tooltip\n├── uptime.js             bulk · filter · ⌘K palette · sound poll\n└── fonts/                self-hosted Inter Tight + JetBrains Mono woff2\nscripts/\n├── hash.ts               bun run hash \u003cpassword\u003e — emits .env-ready line\n├── keygen.ts             bun run keygen — emits SESSION_SECRET + ENCRYPTION_KEY\n├── fetch-fonts.ts        bun run fetch-fonts — re-downloads Google Fonts woff2 files\n└── build.ts              bun run build — cross-compiles standalone binaries to dist/\n```\n\n---\n\n## API\n\nA REST JSON API lives at `/api/v1/*` for programmatic clients (scripts, IaC tools, MCP servers). All endpoints require a Bearer token. Mint tokens in **Settings → API tokens** — the raw token is shown exactly once on creation, after which only its SHA-256 hash is stored. Revoke tokens individually from the same UI.\n\n### Quick start\n\n```bash\n# After minting a token in /settings → API tokens:\nTOKEN='up_\u003cyour-token-here\u003e'\n\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:3000/api/v1/monitors\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:3000/api/v1/stats/fleet\n```\n\n### Endpoints\n\n**Monitors**\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/api/v1/monitors` | List all monitors with current state |\n| `POST` | `/api/v1/monitors` | Create a monitor (returns the new id) |\n| `GET` | `/api/v1/monitors/:id` | Read a monitor (includes decrypted config + bound webhook ids) |\n| `PATCH` | `/api/v1/monitors/:id` | Partial update — only supplied fields are changed |\n| `DELETE` | `/api/v1/monitors/:id` | Delete monitor + history |\n| `POST` | `/api/v1/monitors/:id/pause` | Stop checks (sets `enabled=false`) |\n| `POST` | `/api/v1/monitors/:id/resume` | Re-enable checks |\n| `POST` | `/api/v1/monitors/:id/mute` | Body `{\"duration_ms\": 3600000}` or `{\"until\": \u003cepoch_ms\u003e}` |\n| `POST` | `/api/v1/monitors/:id/unmute` | Clear mute |\n| `POST` | `/api/v1/monitors/:id/run-now` | Trigger an immediate check (fire-and-forget) |\n| `GET` | `/api/v1/monitors/:id/stats` | Uptime windows, latency percentiles, MTTR, hourly buckets |\n| `GET` | `/api/v1/monitors/:id/checks?limit=100` | Recent check_results |\n\n**Incidents**\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/api/v1/incidents?tab=open\\|resolved\\|all` | List incidents (default `open`) |\n| `GET` | `/api/v1/incidents/:id` | Read with computed `failed_checks`, `alerts_sent`, and the timeline |\n| `POST` | `/api/v1/incidents/:id/ack` | Acknowledge — clears the banner without resolving |\n| `PATCH` | `/api/v1/incidents/:id` | Body `{\"notes\": \"...\"}` — set/update postmortem markdown |\n\n**Webhooks**\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/api/v1/webhooks` | List with per-webhook delivery stats |\n| `POST` | `/api/v1/webhooks` | Create — body `{\"name\": \"...\", \"url\": \"https://...\"}` |\n| `DELETE` | `/api/v1/webhooks/:id` | Delete |\n| `POST` | `/api/v1/webhooks/:id/toggle` | Flip enabled state |\n\n**Fleet stats + health**\n\n| Method | Path | Purpose |\n|---|---|---|\n| `GET` | `/api/v1/stats/fleet` | KPIs (total, up, down, p95, MTTR) + recent alert deliveries |\n| `GET` | `/api/v1/healthz` | Returns `{\"status\": \"ok\"}` for liveness probes |\n\n### OpenAPI spec\n\nA full machine-readable spec is published at [`openapi.yml`](./openapi.yml) and served live by the running server at both `/openapi.yml` and `/api/v1/openapi.yml` (no auth — the schema itself isn't sensitive). Point Swagger UI, Postman, or an MCP-OpenAPI bridge at either URL.\n\n### Conventions\n\n- Collections return `{ resource_name: [...] }`; single resources return `{ resource_name: {...} }`\n- Errors return `{ \"error\": \"human-readable message\" }` with appropriate 4xx/5xx status\n- Action endpoints with no useful payload return `204 No Content`\n- Timestamps are epoch milliseconds (matches the DB storage format)\n- Field names are snake_case throughout, matching the SQLite schema\n\n### Monitor body shape (POST/PATCH)\n\n```json\n{\n  \"name\": \"api.prod\",\n  \"type\": \"http\",\n  \"config\": {\n    \"url\": \"https://api.prod.example.com/health\",\n    \"method\": \"GET\",\n    \"expectedStatus\": 200,\n    \"headers\": { \"X-Custom\": \"value\" }\n  },\n  \"interval_seconds\": 60,\n  \"timeout_ms\": 10000,\n  \"failure_threshold\": 2,\n  \"success_threshold\": 1,\n  \"enabled\": true,\n  \"is_public\": true,\n  \"group_name\": \"production\",\n  \"notes\": \"**Owner:** ops team\",\n  \"webhook_ids\": [1, 2]\n}\n```\n\nFor TCP: `\"config\": {\"host\": \"db.internal\", \"port\": 5432}`.\nFor SSH: `\"config\": {\"host\": \"...\", \"username\": \"...\", \"privateKey\": \"...\", \"command\": \"...\", \"expectExitCode\": 0}`.\n\nPATCH accepts any subset of these fields. On `type` change, `config` must be re-supplied in the new shape.\n\n---\n\n## Development\n\n```bash\nbun run dev         # auto-reload on file changes\nbun run start       # one-shot\nbunx tsc --noEmit   # typecheck\n```\n\nThere's no separate test suite at the moment — verification is end-to-end via HTTP smoke tests (see commit history).\n\n---\n\n## Release builds\n\nDeveloping or self-hosting from source needs no build step — `bun run start` runs the TypeScript directly. To distribute the app as a standalone executable that needs **neither Bun nor `node_modules`** on the target machine, compile it:\n\n```bash\nbun run build\n```\n\nThis cross-compiles five self-contained binaries into `dist/`. Each one embeds the Bun runtime, `bun:sqlite`, every dependency, and all static assets (UI scripts, fonts, the OpenAPI spec) — so it runs from any directory with nothing alongside it:\n\n| Binary | Platform | Notes |\n|---|---|---|\n| `uptime-darwin-x64` | macOS (Intel) | |\n| `uptime-darwin-arm64` | macOS (Apple Silicon) | |\n| `uptime-linux-x64` | Linux · glibc | Baseline build — runs on any x64 CPU and every mainstream distro (Ubuntu, Debian, RHEL, Fedora, Arch). |\n| `uptime-linux-x64-musl` | Linux · musl | For Alpine and `FROM scratch` containers. The glibc binary won't start here, and vice-versa. |\n| `uptime-windows-x64.exe` | Windows | Baseline build. |\n\nA binary is self-contained but **not self-configuring** — it needs the same environment as running from source (see [Configuration](#configuration)). It auto-loads a `.env` from the working directory:\n\n```bash\ncd /opt/uptime          # directory holding .env and the binary\n./uptime-linux-x64      # SQLite DB is created at ./data/uptime.db\n```\n\nThe binary also bundles the setup helpers, so you can generate the secrets above on a fresh machine — no repo checkout or Bun install needed:\n\n```bash\n./uptime-linux-x64 keygen          # prints SESSION_SECRET + ENCRYPTION_KEY\n./uptime-linux-x64 hash 'your-pw'  # prints ADMIN_PASSWORD_HASH\n```\n\n**Notes**\n\n- **Secrets are never baked in.** `ADMIN_USERNAME`, `ADMIN_PASSWORD_HASH`, `SESSION_SECRET`, and `ENCRYPTION_KEY` are read at runtime, exactly as from source.\n- **macOS Gatekeeper:** binaries cross-compiled from another OS are unsigned, so the first run is quarantined. Clear it with `xattr -d com.apple.quarantine ./uptime-darwin-arm64`, or codesign on a Mac.\n- **No single universal Linux binary exists** — glibc and musl are a hard split (Bun's executables dynamically link their libc), so Alpine needs the `-musl` artifact. The `-baseline` builds run regardless of CPU age.\n- **SSH checks** use ssh2's pure-JS crypto in the binary (its optional native addon can't be cross-compiled) — no functional difference for monitoring.\n\n---\n\n## Status\n\nThis is a personal project I'm releasing as-is. It's functional and I run my own monitors on it, but:\n\n- No tests yet\n- No HA / multi-instance support (single process owns the SQLite file)\n- No external metrics export (Prometheus, etc.)\n- No public read-only embed widgets\n\nIf something looks interesting and you want to send a PR, go for it. If something's broken, open an issue.\n\n---\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcryguy%2Fuptime","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcryguy%2Fuptime","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcryguy%2Fuptime/lists"}