Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f80e519349 | |||
| 9a4a53f487 | |||
| 32982228b0 | |||
| ec898ff943 | |||
| 38c7739995 | |||
| 5679dffffa | |||
| 9a4985e355 | |||
| 65406ce69c | |||
| 2474b341ad | |||
| b0762800ac | |||
| 08bcdd6481 | |||
| ebf8bfb097 | |||
| b62a721277 | |||
| 8f17deba67 | |||
| 026e969b74 |
@@ -1,15 +1,10 @@
|
||||
.git
|
||||
.ssh/
|
||||
.gitea/
|
||||
tmp/
|
||||
vendor/
|
||||
|
||||
# Security: keep sensitive/local files out of Docker build context
|
||||
.ssh/
|
||||
.claude/
|
||||
.github/
|
||||
.gitea/
|
||||
CLAUDE.md
|
||||
*.db
|
||||
*.db-journal
|
||||
*.local.json
|
||||
*.local.md
|
||||
*.local
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
@@ -8,7 +8,13 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
shell: sh
|
||||
steps:
|
||||
- name: Install build tools
|
||||
run: apk add --no-cache git gcc musl-dev
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -34,9 +40,10 @@ jobs:
|
||||
env:
|
||||
GORELEASER_FORCE_TOKEN: gitea
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITEA_API_URL: http://gitea:3000/api/v1
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: docker-builder
|
||||
needs: [release]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -66,3 +73,10 @@ jobs:
|
||||
VERSION=${{ github.ref_name }}
|
||||
COMMIT=${{ github.sha }}
|
||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
||||
|
||||
- name: Update Docker Hub description
|
||||
uses: peter-evans/dockerhub-description@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
repository: lerkolabs/uptop
|
||||
|
||||
@@ -27,10 +27,6 @@ go.work
|
||||
# End of https://www.toptal.com/developers/gitignore/api/go
|
||||
|
||||
/uptop
|
||||
# stray binaries from `go build ./vhs/<tool>` without -o
|
||||
/backfill
|
||||
/crop
|
||||
# sqlite db + WAL sidecars (-shm/-wal)
|
||||
uptop.db*
|
||||
|
||||
.ssh
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
version: 2
|
||||
|
||||
gitea_urls:
|
||||
api: https://gitea.lerkolabs.com/api/v1
|
||||
api: "{{ if index .Env \"GITEA_API_URL\" }}{{ .Env.GITEA_API_URL }}{{ else }}https://gitea.lerkolabs.com/api/v1{{ end }}"
|
||||
download: https://gitea.lerkolabs.com
|
||||
|
||||
release:
|
||||
gitea:
|
||||
owner: lerko
|
||||
owner: lerkolabs
|
||||
name: uptop
|
||||
|
||||
builds:
|
||||
|
||||
@@ -1,46 +1,94 @@
|
||||
# Changelog
|
||||
|
||||
## [2026.05.2] — 2026-05-23
|
||||
## [2026.05.5] — 2026-05-29
|
||||
|
||||
### Added
|
||||
- Comprehensive test suite (94 tests across monitor, server, cluster)
|
||||
- golangci-lint config with CI enforcement
|
||||
- Gitea Actions CI pipeline (test + lint)
|
||||
- Graceful shutdown for HTTP and SSH servers
|
||||
- Context-aware alert delivery with timeout
|
||||
- Request size limits on all POST endpoints
|
||||
- Constant-time secret comparison
|
||||
- Check interval jitter to prevent thundering herd
|
||||
- `--version` flag with build metadata injection
|
||||
- Error reason display when monitors go DOWN (#33)
|
||||
- Push monitor lifecycle — PENDING, LATE, DOWN states (#34)
|
||||
- Logs tab overhaul — severity tags, filtering, recovery durations (#35)
|
||||
- Alert channel health indicator and test alerts (#36)
|
||||
- TUI screenshots in `assets/` (#32)
|
||||
- CI status badge in README
|
||||
|
||||
### Fixed
|
||||
- Silent JSON unmarshal failures in alert settings
|
||||
- Panic on crypto/rand failure replaced with error return
|
||||
- Alert delivery errors now logged instead of swallowed
|
||||
- log.Fatalf in goroutines replaced with log.Printf
|
||||
- Deprecated LineUp/LineDown API calls
|
||||
### Changed
|
||||
- Visual polish — detail sections, column headers, alert detail (#37)
|
||||
- README rewritten with hero image, badges, collapsible install sections (#32)
|
||||
- Changelog rewritten to match actual CalVer tag history
|
||||
- Migrated to `lerkolabs` org namespace (#38)
|
||||
- Docker-compose files moved to `deploy/`
|
||||
|
||||
## [2026.05.4] — 2026-05-27
|
||||
|
||||
### Added
|
||||
- SSH user seeding from `UPTOP_ADMIN_KEY` env var and `UPTOP_KEYS` file (#31)
|
||||
- GoReleaser for binary releases
|
||||
- govulncheck in CI pipeline
|
||||
- Multi-arch Docker builds (amd64 + arm64)
|
||||
|
||||
### Changed
|
||||
- CI overhaul — Go 1.26, build caching, streamlined pipeline (#30)
|
||||
- Bumped golang.org/x/crypto v0.47.0 → v0.52.0
|
||||
- Bumped Alpine 3.21 → 3.23
|
||||
|
||||
### Security
|
||||
- Cluster secret compared with crypto/subtle (timing-safe)
|
||||
- http.MaxBytesReader on all JSON endpoints
|
||||
- ReadHeaderTimeout added to HTTP server
|
||||
- Phase 1: SSRF protection, input validation, safe dial (#26)
|
||||
- Phase 2: TLS hardening, auth bypass fixes, rate limiting (#27)
|
||||
- Phase 3: Graceful degradation, connection limits, timeout enforcement (#28)
|
||||
- Phase 4: Code quality, error handling, linter fixes (#29)
|
||||
|
||||
## [2026.05.1] — 2026-05-14
|
||||
## [2026.05.3] — 2026-05-25
|
||||
|
||||
### Added
|
||||
- Theme system with 5 dark palettes — Default, Dracula, Nord, Tokyo Night, Gruvbox (#24)
|
||||
- `--version` flag with build metadata injection
|
||||
- Gitea Actions CI pipeline — test + lint (#20)
|
||||
- golangci-lint configuration
|
||||
- Comprehensive test suite — 94 tests across monitor, server, cluster (#19)
|
||||
- CONTRIBUTING.md and SECURITY.md
|
||||
|
||||
### Changed
|
||||
- Renamed project from go-upkeep to uptop (#25)
|
||||
- Updated LICENSE with dual copyright for independent fork
|
||||
|
||||
### Fixed
|
||||
- Form validators scoped to relevant monitor types (#23)
|
||||
- Graceful shutdown for HTTP, SSH servers and database (#19)
|
||||
- Constant-time secret comparison, request size limits (#19)
|
||||
- Check interval jitter to prevent thundering herd (#19)
|
||||
- TUI visual polish — zebra striping, group icons, sparkline stats (#18)
|
||||
|
||||
## [2026.05.2] — 2026-05-22
|
||||
|
||||
### Added
|
||||
- Incident management and maintenance windows (#17)
|
||||
- Production docker-compose.yml
|
||||
|
||||
### Fixed
|
||||
- Viewport sizing and dynamic chrome calculation (#16)
|
||||
- Form height constrained to terminal with resize forwarding
|
||||
- Maintenance'd monitors excluded from down count and pulse
|
||||
- Group status correctly skips children in maintenance
|
||||
|
||||
## [2026.05.1] — 2026-05-16
|
||||
|
||||
### Added
|
||||
- Distributed probing with leader + probe nodes
|
||||
- Config-as-code (YAML apply/export with dry-run, prune)
|
||||
- TUI visual polish (zebra striping, sparklines, breadcrumbs)
|
||||
- Incident management and maintenance windows
|
||||
- 9 alert providers (Discord, Slack, Email, Ntfy, Telegram, PagerDuty, Pushover, Gotify, Webhook)
|
||||
- Config-as-code — YAML apply/export with dry-run and prune
|
||||
- TUI polish — status bar, tab badges, detail panel, modals
|
||||
- DOWN-first sort, health pulse, site filter
|
||||
- Type icons in sites table
|
||||
- Sparkline history graphs
|
||||
- Persistent state — uptime, status, latency, and logs survive restarts
|
||||
- Push token stripping from /status/json response
|
||||
|
||||
## [2026.04.1] — Initial independent fork
|
||||
## [2026.04.1] — 2026-04-01
|
||||
|
||||
### Added
|
||||
- SSH-accessible TUI (Bubble Tea + Wish)
|
||||
- 6 check types (HTTP, Push, Ping, Port, DNS, Group)
|
||||
- SSH-accessible TUI built on Bubble Tea + Wish
|
||||
- 6 check types — HTTP, Push, Ping, Port, DNS, Group
|
||||
- 9 alert providers — Discord, Slack, Email, Ntfy, Telegram, PagerDuty, Pushover, Gotify, Webhook
|
||||
- SQLite and PostgreSQL support
|
||||
- HA clustering with automatic failover
|
||||
- Prometheus metrics endpoint
|
||||
- Public status page
|
||||
- Uptime Kuma import
|
||||
- Prometheus /metrics endpoint
|
||||
- Public status page (HTML + JSON)
|
||||
- Uptime Kuma backup import
|
||||
|
||||
@@ -17,7 +17,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
# --- Stage 2: Runner ---
|
||||
FROM alpine:3.23
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache ca-certificates openssh-client
|
||||
RUN apk add --no-cache ca-certificates && apk upgrade --no-cache
|
||||
RUN mkdir /data
|
||||
|
||||
COPY --from=builder /app/uptop .
|
||||
|
||||
@@ -1,19 +1,50 @@
|
||||
# uptop
|
||||
<div align="center">
|
||||
<h1>uptop</h1>
|
||||
<p>Self-hosted uptime monitoring with a TUI over SSH.</p>
|
||||
<p>No browser. No client install. Just <code>ssh -p 23234 your-server</code>.</p>
|
||||
|
||||
Self-hosted uptime monitor with a TUI you can access over SSH. No browser, no install on the client — just `ssh -p 23234 your-server`.
|
||||
<p>
|
||||
<a href="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml"><img src="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License">
|
||||
<img src="https://img.shields.io/badge/go-1.26-00ADD8?logo=go&logoColor=white" alt="Go 1.26">
|
||||
<img src="https://img.shields.io/docker/pulls/lerkolabs/uptop" alt="Docker Pulls">
|
||||
</p>
|
||||
|
||||
Built on the foundation of [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep).
|
||||
<img src="assets/monitors.png" alt="uptop monitors view" width="800">
|
||||
</div>
|
||||
|
||||
## What it does
|
||||
## What is this
|
||||
|
||||
- **6 check types**: HTTP, Push (heartbeat), Ping, Port, DNS, Groups
|
||||
- **9 alert providers**: Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify
|
||||
- **Config as code**: define monitors in YAML, apply declaratively, version control your setup
|
||||
- **HA clustering**: leader/follower with automatic failover
|
||||
- **Prometheus metrics**: `/metrics` endpoint for Grafana dashboards
|
||||
- **Public status page**: HTML + JSON, toggle with an env var
|
||||
- **SQLite or Postgres**: SQLite for single-node, Postgres for production
|
||||
- **Uptime Kuma import**: migrate from Kuma with one command
|
||||
An uptime monitor you manage entirely from the terminal. It runs as a server, exposes an SSH endpoint, and drops you into a full TUI — monitors, alerts, logs, nodes, all there.
|
||||
|
||||
Built on [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). Rewritten for clustering, config-as-code, and a proper dashboard.
|
||||
|
||||
## Features
|
||||
|
||||
- **6 check types** — HTTP, Push (heartbeat), Ping, Port, DNS, Groups
|
||||
- **9 alert providers** — Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify
|
||||
- **Config as code** — define monitors in YAML, apply declaratively, version control your setup
|
||||
- **HA clustering** — leader/follower with automatic failover
|
||||
- **Prometheus metrics** — `/metrics` endpoint, wire it straight to Grafana
|
||||
- **Public status page** — HTML + JSON, toggle with an env var
|
||||
- **SQLite or Postgres** — SQLite for single-node, Postgres for production
|
||||
- **Uptime Kuma import** — migrate from Kuma with one command
|
||||
|
||||
## Screenshots
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="assets/detail.png" alt="detail panel" width="400"></td>
|
||||
<td><img src="assets/alerts.png" alt="alerts view" width="400"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="assets/logs.png" alt="logs view" width="400"></td>
|
||||
<td><img src="assets/nodes.png" alt="cluster nodes" width="400"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" align="center"><img src="assets/theme.png" alt="theme selection" width="600"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -22,7 +53,7 @@ go run cmd/uptop/main.go
|
||||
ssh -p 23234 localhost
|
||||
```
|
||||
|
||||
Seed some demo data to see it in action:
|
||||
Want some data to look at first:
|
||||
|
||||
```bash
|
||||
go run cmd/uptop/main.go -demo
|
||||
@@ -30,22 +61,45 @@ go run cmd/uptop/main.go -demo
|
||||
|
||||
## Install
|
||||
|
||||
### From source
|
||||
<details>
|
||||
<summary><strong>Docker (recommended)</strong></summary>
|
||||
|
||||
```bash
|
||||
go install gitea.lerkolabs.com/lerko/uptop/cmd/uptop@latest
|
||||
```yaml
|
||||
services:
|
||||
uptop:
|
||||
image: lerkolabs/uptop:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "23234:23234"
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- UPTOP_DB_TYPE=sqlite
|
||||
- UPTOP_DB_DSN=/data/uptop.db
|
||||
- UPTOP_STATUS_ENABLED=true
|
||||
# - UPTOP_ADMIN_KEY=ssh-ed25519 AAAA... you@host
|
||||
volumes:
|
||||
- ./data:/data
|
||||
```
|
||||
|
||||
### Docker
|
||||
First run: set `UPTOP_ADMIN_KEY` to your SSH public key, or attach to the container and add it in the Users tab.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>Binary</strong></summary>
|
||||
|
||||
Download from [Releases](https://gitea.lerkolabs.com/lerkolabs/uptop/releases).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>From source</strong></summary>
|
||||
|
||||
```bash
|
||||
docker pull lerko/uptop:latest
|
||||
docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/uptop
|
||||
go install gitea.lerkolabs.com/lerkolabs/uptop/cmd/uptop@latest
|
||||
```
|
||||
|
||||
### Binary
|
||||
|
||||
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptop/releases).
|
||||
</details>
|
||||
|
||||
## Config as code
|
||||
|
||||
@@ -63,35 +117,11 @@ uptop apply -f monitors.yaml --dry-run # see what would change
|
||||
uptop apply -f monitors.yaml --prune # delete anything not in the YAML
|
||||
```
|
||||
|
||||
See [docs/config-as-code.md](docs/config-as-code.md) for the full reference.
|
||||
|
||||
## Docker
|
||||
|
||||
```yaml
|
||||
services:
|
||||
monitor:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
stdin_open: true
|
||||
tty: true
|
||||
ports:
|
||||
- "23234:23234"
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
- ./ssh_keys:/app/.ssh
|
||||
environment:
|
||||
- UPTOP_DB_TYPE=sqlite
|
||||
- UPTOP_DB_DSN=/data/uptop.db
|
||||
- UPTOP_STATUS_ENABLED=true
|
||||
- UPTOP_CLUSTER_SECRET=change-me
|
||||
```
|
||||
|
||||
First run: attach to the container (`docker attach uptop`), go to the Users tab, add your SSH public key. Then detach with `Ctrl+P, Ctrl+Q` and connect normally over SSH.
|
||||
Full reference in [docs/config-as-code.md](docs/config-as-code.md).
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | What it does |
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `UPTOP_PORT` | `23234` | SSH server port |
|
||||
| `UPTOP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
|
||||
@@ -103,6 +133,7 @@ First run: attach to the container (`docker attach uptop`), go to the Users tab,
|
||||
| `UPTOP_PEER_URL` | | Leader URL for follower nodes |
|
||||
| `UPTOP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
|
||||
| `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
|
||||
| `UPTOP_ADMIN_KEY` | | SSH public key seeded as first admin on startup |
|
||||
|
||||
## Migrating from Uptime Kuma
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 206 KiB After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 253 KiB |
@@ -17,14 +17,14 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/cluster"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/config"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/server"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/tui"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/cluster"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/config"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/importer"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/server"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/tui"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/ssh"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
module gitea.lerkolabs.com/lerko/uptop
|
||||
module gitea.lerkolabs.com/lerkolabs/uptop
|
||||
|
||||
go 1.26.3
|
||||
|
||||
@@ -53,7 +53,7 @@ require (
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||
golang.org/x/mod v0.35.0 // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
|
||||
@@ -107,8 +107,8 @@ golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
var alertClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func TestHTTPProviderDiscord(t *testing.T) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
)
|
||||
|
||||
// --- Mock Store (minimal, for monitor.NewEngine) ---
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
)
|
||||
|
||||
type ProbeConfig struct {
|
||||
|
||||
@@ -2,8 +2,8 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
"reflect"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"os"
|
||||
"sort"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ package importer
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -2,8 +2,8 @@ package metrics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
)
|
||||
|
||||
type mockStore struct {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
probing "github.com/prometheus-community/pro-bing"
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func TestRunCheck_HTTP_Success(t *testing.T) {
|
||||
|
||||
@@ -11,9 +11,9 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/alert"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/alert"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
// --- Mock Store ---
|
||||
|
||||
@@ -11,11 +11,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/metrics"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/importer"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/metrics"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
)
|
||||
|
||||
const maxRequestBody = 1 << 20
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
)
|
||||
|
||||
// --- Mock Store ---
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"testing"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
@@ -1,368 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: backfill <db-path>")
|
||||
os.Exit(1)
|
||||
}
|
||||
db, err := sql.Open("sqlite3", os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "open: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ids, err := loadSiteIDs(db)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "load site IDs: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
rng := rand.New(rand.NewPCG(42, 0)) //nolint:gosec // deterministic seed for reproducible demo data
|
||||
now := time.Now().UTC()
|
||||
|
||||
if err := backfillHistory(db, rng, now, ids); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "history: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := backfillStateChanges(db, now, ids); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "state changes: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := backfillLogs(db, now); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "logs: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := backfillNodes(db, now); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "nodes: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := backfillMaintenance(db, now, ids); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "maintenance: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
alertIDs, err := loadAlertIDs(db)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "load alert IDs: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := backfillAlertHealth(db, now, alertIDs); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "alert health: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var count int
|
||||
_ = db.QueryRow("SELECT COUNT(*) FROM check_history").Scan(&count)
|
||||
fmt.Printf("Backfill complete: %d check records\n", count)
|
||||
|
||||
var token string
|
||||
if err := db.QueryRow("SELECT token FROM sites WHERE name='Nightly Backup'").Scan(&token); err == nil {
|
||||
fmt.Printf("PUSH_TOKEN=%s\n", token)
|
||||
}
|
||||
}
|
||||
|
||||
func loadSiteIDs(db *sql.DB) (map[string]int, error) {
|
||||
rows, err := db.Query("SELECT id, name FROM sites")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanNameIDs(rows)
|
||||
}
|
||||
|
||||
func loadAlertIDs(db *sql.DB) (map[string]int, error) {
|
||||
rows, err := db.Query("SELECT id, name FROM alerts")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return scanNameIDs(rows)
|
||||
}
|
||||
|
||||
func scanNameIDs(rows *sql.Rows) (map[string]int, error) {
|
||||
defer rows.Close()
|
||||
ids := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var name string
|
||||
if err := rows.Scan(&id, &name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids[name] = id
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// backfillAlertHealth seeds realistic send health so the Alerts tab shows recent,
|
||||
// healthy "last sent" times and green health dots instead of "never" across the board.
|
||||
func backfillAlertHealth(db *sql.DB, now time.Time, alertIDs map[string]int) error {
|
||||
type health struct {
|
||||
name string
|
||||
sentAgo time.Duration
|
||||
ok bool
|
||||
sends int
|
||||
fails int
|
||||
}
|
||||
rows := []health{
|
||||
{"Discord Homelab", 4 * time.Minute, true, 37, 0},
|
||||
{"Slack Ops", 9 * time.Minute, true, 21, 1},
|
||||
{"Ntfy Alerts", 1 * time.Hour, true, 12, 0},
|
||||
{"Email Oncall", 3 * time.Hour, true, 5, 0},
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
stmt, err := tx.Prepare("INSERT OR REPLACE INTO alert_health (alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count) VALUES (?, ?, ?, ?, ?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, r := range rows {
|
||||
id, ok := alertIDs[r.name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
sentAt := now.Add(-r.sentAgo).Format("2006-01-02 15:04:05")
|
||||
if _, err := stmt.Exec(id, sentAt, r.ok, "", r.sends, r.fails); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
type monitorProfile struct {
|
||||
name string
|
||||
minMs int
|
||||
maxMs int
|
||||
downFrom int // first DOWN check index (-1 = always up)
|
||||
downTo int // exclusive end of the DOWN window; use 60 (total) for a still-down monitor
|
||||
}
|
||||
|
||||
func backfillHistory(db *sql.DB, rng *rand.Rand, now time.Time, ids map[string]int) error {
|
||||
// Latency ranges reflect monitoring public services over the internet, so the
|
||||
// detail histogram brackets the live latency the engine measures at capture time.
|
||||
// 60 checks * 24m spacing = a 24h window; dip indices place outages within it.
|
||||
profiles := []monitorProfile{
|
||||
{"Nextcloud", 200, 600, 47, 48}, // brief blip ~5h ago, recovered
|
||||
{"Jellyfin", 40, 180, 15, 16}, // brief blip ~18h ago, recovered
|
||||
{"Home Assistant", 30, 120, -1, 0}, //
|
||||
{"Gitea", 50, 200, -1, 0}, //
|
||||
{"Traefik Dashboard", 60, 200, -1, 0}, //
|
||||
{"Vaultwarden", 80, 250, -1, 0}, //
|
||||
{"Personal Blog", 40, 160, -1, 0}, //
|
||||
{"Immich", 60, 300, 30, 31}, // brief blip ~12h ago; periodic spikes below
|
||||
{"Auth Portal", 30, 90, 40, 60}, // DOWN ~8h ago, still down
|
||||
{"Edge Router", 5, 20, -1, 0}, // ping
|
||||
{"Postgres", 1, 6, -1, 0}, // port
|
||||
{"DNS Primary", 8, 30, -1, 0}, // dns
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
stmt, err := tx.Prepare("INSERT INTO check_history (site_id, latency_ns, is_up, checked_at) VALUES (?, ?, ?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
const total = 60
|
||||
for _, p := range profiles {
|
||||
siteID, ok := ids[p.name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for i := 0; i < total; i++ {
|
||||
minutesAgo := (total - i) * 24
|
||||
checkedAt := now.Add(-time.Duration(minutesAgo) * time.Minute)
|
||||
|
||||
var latencyNs int64
|
||||
isUp := true
|
||||
|
||||
if p.downFrom >= 0 && i >= p.downFrom && i < p.downTo {
|
||||
latencyNs = 0
|
||||
isUp = false
|
||||
} else {
|
||||
ms := p.minMs + rng.IntN(p.maxMs-p.minMs)
|
||||
if p.name == "Immich" && i%17 == 0 {
|
||||
ms = 250 + rng.IntN(100)
|
||||
}
|
||||
latencyNs = int64(ms) * 1_000_000
|
||||
}
|
||||
|
||||
if _, err := stmt.Exec(siteID, latencyNs, isUp, checkedAt.Format("2006-01-02 15:04:05")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func backfillStateChanges(db *sql.DB, now time.Time, ids map[string]int) error {
|
||||
type sc struct {
|
||||
name string
|
||||
from string
|
||||
to string
|
||||
reason string
|
||||
at time.Time
|
||||
}
|
||||
// Timed to line up with the history dips (Nextcloud ~5h, Immich ~12h, Jellyfin ~18h)
|
||||
// and the still-down Auth Portal (~8h), so detail panels read coherently.
|
||||
changes := []sc{
|
||||
{"Nextcloud", "UP", "DOWN", "read timeout", now.Add(-5 * time.Hour).Add(-8 * time.Minute)},
|
||||
{"Nextcloud", "DOWN", "UP", "", now.Add(-5 * time.Hour)},
|
||||
{"Auth Portal", "UP", "DOWN", "no such host", now.Add(-8 * time.Hour)},
|
||||
{"Immich", "UP", "DOWN", "502 Bad Gateway", now.Add(-12 * time.Hour).Add(-8 * time.Minute)},
|
||||
{"Immich", "DOWN", "UP", "", now.Add(-12 * time.Hour)},
|
||||
{"Jellyfin", "UP", "DOWN", "connection reset", now.Add(-18 * time.Hour).Add(-5 * time.Minute)},
|
||||
{"Jellyfin", "DOWN", "UP", "", now.Add(-18 * time.Hour)},
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
stmt, err := tx.Prepare("INSERT INTO state_changes (site_id, from_status, to_status, error_reason, changed_at) VALUES (?, ?, ?, ?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, c := range changes {
|
||||
siteID, ok := ids[c.name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(siteID, c.from, c.to, c.reason, c.at.Format("2006-01-02 15:04:05")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func backfillLogs(db *sql.DB, now time.Time) error {
|
||||
type logEntry struct {
|
||||
text string
|
||||
at time.Time
|
||||
}
|
||||
ago := func(h, m, s int) time.Time {
|
||||
return now.Add(-(time.Duration(h)*time.Hour + time.Duration(m)*time.Minute + time.Duration(s)*time.Second))
|
||||
}
|
||||
// Ordered newest-first. The bracket time is derived from `at` (not hardcoded), so the
|
||||
// Logs view — which renders the leading [HH:MM] — reads chronologically. Outage times
|
||||
// line up with the state changes and history dips above.
|
||||
logs := []logEntry{
|
||||
{"Monitor 'Nextcloud' recovered (was down 8m)", ago(5, 0, 0)},
|
||||
{"Monitor 'Nextcloud' confirmed DOWN: read timeout", ago(5, 8, 0)},
|
||||
{"Monitor 'Nextcloud' failed check 2/2", ago(5, 8, 30)},
|
||||
{"Monitor 'Nextcloud' failed check 1/2", ago(5, 9, 0)},
|
||||
{"Monitor 'Auth Portal' confirmed DOWN: no such host", ago(8, 0, 0)},
|
||||
{"Monitor 'Auth Portal' failed check 2/2", ago(8, 0, 30)},
|
||||
{"Monitor 'Auth Portal' failed check 1/2", ago(8, 1, 0)},
|
||||
{"Monitor 'Immich' recovered (was down 8m)", ago(12, 0, 0)},
|
||||
{"Monitor 'Immich' confirmed DOWN: 502 Bad Gateway", ago(12, 8, 0)},
|
||||
{"Monitor 'Immich' failed check 3/3", ago(12, 8, 30)},
|
||||
{"Monitor 'Immich' failed check 2/3", ago(12, 9, 0)},
|
||||
{"Monitor 'Immich' failed check 1/3", ago(12, 9, 30)},
|
||||
{"Monitor 'Jellyfin' recovered (was down 5m)", ago(18, 0, 0)},
|
||||
{"Monitor 'Jellyfin' confirmed DOWN: connection reset", ago(18, 5, 0)},
|
||||
{"Monitor 'Jellyfin' failed check 2/2", ago(18, 5, 30)},
|
||||
{"Monitor 'Jellyfin' failed check 1/2", ago(18, 6, 0)},
|
||||
{"SSL warning: certificate for 'Personal Blog' expires in 9 days", ago(20, 0, 0)},
|
||||
{"Engine RESUMED (Active)", ago(22, 0, 0)},
|
||||
{"Loaded check history from database", ago(22, 0, 5)},
|
||||
}
|
||||
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
stmt, err := tx.Prepare("INSERT INTO logs (message, created_at) VALUES (?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, l := range logs {
|
||||
// Bracket in local time to match the engine's live AddLog timestamps;
|
||||
// created_at stays UTC to match the store's CURRENT_TIMESTAMP ordering.
|
||||
msg := "[" + l.at.Local().Format("15:04") + "] " + l.text
|
||||
if _, err := stmt.Exec(msg, l.at.Format("2006-01-02 15:04:05")); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func backfillNodes(db *sql.DB, now time.Time) error {
|
||||
// Multiple regions to show distributed probes. All seen "now" so they read ONLINE
|
||||
// for the whole capture window (kept under the 60s freshness threshold by the tape).
|
||||
nodes := []struct{ id, name, region string }{
|
||||
{"node-use1", "leader", "us-east"},
|
||||
{"node-euw1", "probe-eu", "eu-west"},
|
||||
{"node-apse1", "probe-ap", "ap-southeast"},
|
||||
}
|
||||
ts := now.Format("2006-01-02 15:04:05")
|
||||
for _, n := range nodes {
|
||||
if _, err := db.Exec(
|
||||
"INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, ?, ?)",
|
||||
n.id, n.name, n.region, ts, "2026.05.1",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillMaintenance(db *sql.DB, now time.Time, ids map[string]int) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
stmt, err := tx.Prepare("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
jellyfinID := ids["Jellyfin"]
|
||||
past := now.Add(-3 * 24 * time.Hour)
|
||||
if _, err := stmt.Exec(jellyfinID, "Jellyfin upgrade", "Upgrade to v10.10 + plugin updates", "maintenance",
|
||||
past.Format("2006-01-02 15:04:05"),
|
||||
past.Add(2*time.Hour).Format("2006-01-02 15:04:05"),
|
||||
"admin"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
future := now.Add(2 * 24 * time.Hour)
|
||||
if _, err := stmt.Exec(0, "Network switch replacement", "Replacing core switch in rack 2", "maintenance",
|
||||
future.Format("2006-01-02 15:04:05"),
|
||||
future.Add(4*time.Hour).Format("2006-01-02 15:04:05"),
|
||||
"admin"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
// Command crop trims the uniform background border around each VHS screenshot so the
|
||||
// content fills the frame instead of floating in a large empty terminal. Sparse views
|
||||
// (alerts, detail, nodes) would otherwise sit in a sea of dead space.
|
||||
//
|
||||
// Usage: crop [dir] (dir defaults to vhs/screenshots)
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// pad is the margin (px) left around the detected content. tol is the per-channel
|
||||
// colour distance (summed) above which a pixel counts as content rather than background.
|
||||
const (
|
||||
pad = 24
|
||||
tol = 28
|
||||
)
|
||||
|
||||
func main() {
|
||||
dir := "vhs/screenshots"
|
||||
if len(os.Args) > 1 {
|
||||
dir = os.Args[1]
|
||||
}
|
||||
paths, err := filepath.Glob(filepath.Join(dir, "*.png"))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "glob: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
fmt.Fprintf(os.Stderr, "no PNGs in %s\n", dir)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, p := range paths {
|
||||
w, h, err := cropFile(p)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "crop %s: %v\n", p, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("cropped %s -> %dx%d\n", filepath.Base(p), w, h)
|
||||
}
|
||||
}
|
||||
|
||||
func cropFile(path string) (int, int, error) {
|
||||
f, err := os.Open(path) //nolint:gosec // dev tool: paths come from a trusted local glob
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
src, err := png.Decode(f)
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
b := src.Bounds()
|
||||
// Background colour sampled from a corner — always inside VHS's blank padding.
|
||||
bgR, bgG, bgB := rgb(src.At(b.Min.X+2, b.Min.Y+2))
|
||||
|
||||
minX, minY := b.Max.X, b.Max.Y
|
||||
maxX, maxY := b.Min.X, b.Min.Y
|
||||
found := false
|
||||
for y := b.Min.Y; y < b.Max.Y; y++ {
|
||||
for x := b.Min.X; x < b.Max.X; x++ {
|
||||
r, g, bl := rgb(src.At(x, y))
|
||||
if abs(r-bgR)+abs(g-bgG)+abs(bl-bgB) > tol {
|
||||
found = true
|
||||
minX, minY = min(minX, x), min(minY, y)
|
||||
maxX, maxY = max(maxX, x), max(maxY, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return b.Dx(), b.Dy(), nil // blank frame — leave untouched
|
||||
}
|
||||
|
||||
minX = clamp(minX-pad, b.Min.X, b.Max.X)
|
||||
minY = clamp(minY-pad, b.Min.Y, b.Max.Y)
|
||||
maxX = clamp(maxX+pad+1, b.Min.X, b.Max.X)
|
||||
maxY = clamp(maxY+pad+1, b.Min.Y, b.Max.Y)
|
||||
|
||||
dst := image.NewRGBA(image.Rect(0, 0, maxX-minX, maxY-minY))
|
||||
for y := minY; y < maxY; y++ {
|
||||
for x := minX; x < maxX; x++ {
|
||||
dst.Set(x-minX, y-minY, src.At(x, y))
|
||||
}
|
||||
}
|
||||
|
||||
out, err := os.Create(path) //nolint:gosec // dev tool: paths come from a trusted local glob
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer out.Close() //nolint:errcheck // best-effort close on write path
|
||||
if err := png.Encode(out, dst); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return dst.Bounds().Dx(), dst.Bounds().Dy(), nil
|
||||
}
|
||||
|
||||
func rgb(c color.Color) (int, int, int) {
|
||||
r, g, b, _ := c.RGBA()
|
||||
return int(r >> 8), int(g >> 8), int(b >> 8)
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func clamp(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
Set Shell "bash"
|
||||
Set Width 1400
|
||||
Set Height 800
|
||||
Set FontSize 14
|
||||
Set Padding 20
|
||||
Set Framerate 15
|
||||
Set TypingSpeed 50ms
|
||||
|
||||
# Seed demo data + start uptop (UPTOP_DEMO=1 → stable pulse dot for stills).
|
||||
Hide
|
||||
Type "bash vhs/setup.sh /tmp/uptop-vhs.db"
|
||||
Enter
|
||||
# Warm-up: push heartbeat lands (~10s) and initial checks settle. Kept short so every
|
||||
# capture stays inside the 60s node-freshness window (consistent "3 probes" footer).
|
||||
Sleep 18s
|
||||
Show
|
||||
Sleep 2s
|
||||
|
||||
# 1. Sites — hero shot: mixed states, history sparklines, SSL, retries.
|
||||
Screenshot vhs/screenshots/monitors.png
|
||||
Sleep 1s
|
||||
|
||||
# 2. Detail — drill into Nextcloud (6th row from the top).
|
||||
Down
|
||||
Sleep 150ms
|
||||
Down
|
||||
Sleep 150ms
|
||||
Down
|
||||
Sleep 150ms
|
||||
Down
|
||||
Sleep 150ms
|
||||
Down
|
||||
Sleep 300ms
|
||||
Type "i"
|
||||
Sleep 2s
|
||||
Screenshot vhs/screenshots/detail.png
|
||||
Sleep 500ms
|
||||
Escape
|
||||
Sleep 1s
|
||||
|
||||
# 3. Alerts — channels with health dots + recent "last sent".
|
||||
Tab
|
||||
Sleep 1500ms
|
||||
Screenshot vhs/screenshots/alerts.png
|
||||
Sleep 500ms
|
||||
|
||||
# 4. Logs — chronological, severity-coloured event stream.
|
||||
Tab
|
||||
Sleep 1500ms
|
||||
Screenshot vhs/screenshots/logs.png
|
||||
Sleep 500ms
|
||||
|
||||
# 5. Nodes — distributed probes across regions.
|
||||
Tab
|
||||
Sleep 1500ms
|
||||
Screenshot vhs/screenshots/nodes.png
|
||||
Sleep 500ms
|
||||
|
||||
# 6. Theme — cycle to the next theme, return to Sites for an alternate-palette hero.
|
||||
Type "T"
|
||||
Sleep 500ms
|
||||
Tab
|
||||
Sleep 200ms
|
||||
Tab
|
||||
Sleep 200ms
|
||||
Tab
|
||||
Sleep 1s
|
||||
Screenshot vhs/screenshots/theme.png
|
||||
Sleep 500ms
|
||||
|
||||
Type "q"
|
||||
Sleep 1s
|
||||
@@ -1,141 +0,0 @@
|
||||
alerts:
|
||||
- name: Discord Homelab
|
||||
type: discord
|
||||
settings:
|
||||
url: https://discord.com/api/webhooks/1234567890/demo-token
|
||||
|
||||
- name: Ntfy Alerts
|
||||
type: webhook
|
||||
settings:
|
||||
url: https://ntfy.example.com/homelab-alerts
|
||||
|
||||
- name: Email Oncall
|
||||
type: email
|
||||
settings:
|
||||
host: smtp.example.com
|
||||
port: "587"
|
||||
user: alerts@example.com
|
||||
pass: "••••••••"
|
||||
from: alerts@example.com
|
||||
to: oncall@example.com
|
||||
|
||||
- name: Slack Ops
|
||||
type: slack
|
||||
settings:
|
||||
url: https://hooks.slack.com/services/T00000/B00000/demo-token
|
||||
|
||||
monitors:
|
||||
# HTTP — homelab services
|
||||
- name: Nextcloud
|
||||
type: http
|
||||
url: https://nextcloud.com
|
||||
interval: 30
|
||||
alert: Discord Homelab
|
||||
check_ssl: true
|
||||
expiry_threshold: 14
|
||||
max_retries: 2
|
||||
|
||||
- name: Jellyfin
|
||||
type: http
|
||||
url: https://jellyfin.org
|
||||
interval: 30
|
||||
alert: Discord Homelab
|
||||
max_retries: 2
|
||||
|
||||
- name: Home Assistant
|
||||
type: http
|
||||
url: https://www.home-assistant.io
|
||||
interval: 30
|
||||
alert: Discord Homelab
|
||||
max_retries: 3
|
||||
|
||||
- name: Gitea
|
||||
type: http
|
||||
url: https://about.gitea.com
|
||||
interval: 60
|
||||
alert: Discord Homelab
|
||||
check_ssl: true
|
||||
expiry_threshold: 14
|
||||
max_retries: 2
|
||||
|
||||
- name: Traefik Dashboard
|
||||
type: http
|
||||
url: https://traefik.io
|
||||
interval: 60
|
||||
alert: Discord Homelab
|
||||
max_retries: 1
|
||||
|
||||
- name: Vaultwarden
|
||||
type: http
|
||||
url: https://bitwarden.com
|
||||
interval: 30
|
||||
alert: Discord Homelab
|
||||
check_ssl: true
|
||||
expiry_threshold: 14
|
||||
max_retries: 3
|
||||
|
||||
- name: Personal Blog
|
||||
type: http
|
||||
url: https://jvns.ca
|
||||
interval: 120
|
||||
alert: Discord Homelab
|
||||
check_ssl: true
|
||||
expiry_threshold: 14
|
||||
max_retries: 2
|
||||
|
||||
- name: Immich
|
||||
type: http
|
||||
url: https://immich.app
|
||||
interval: 60
|
||||
alert: Discord Homelab
|
||||
check_ssl: true
|
||||
expiry_threshold: 7
|
||||
max_retries: 3
|
||||
|
||||
# HTTP — deliberate failure (non-resolving homelab host → stays DOWN)
|
||||
- name: Auth Portal
|
||||
type: http
|
||||
url: https://auth.home.arpa
|
||||
interval: 30
|
||||
alert: Discord Homelab
|
||||
max_retries: 2
|
||||
|
||||
# Push — cron jobs
|
||||
- name: Nightly Backup
|
||||
type: push
|
||||
interval: 300
|
||||
alert: Discord Homelab
|
||||
|
||||
- name: Cert Renewal
|
||||
type: push
|
||||
interval: 300
|
||||
alert: Discord Homelab
|
||||
|
||||
# Infrastructure group
|
||||
- name: Infrastructure
|
||||
type: group
|
||||
alert: Discord Homelab
|
||||
monitors:
|
||||
- name: Edge Router
|
||||
type: ping
|
||||
hostname: 8.8.8.8
|
||||
interval: 30
|
||||
alert: Discord Homelab
|
||||
timeout: 5
|
||||
|
||||
- name: Postgres
|
||||
type: port
|
||||
hostname: localhost
|
||||
port: 18099
|
||||
interval: 60
|
||||
alert: Discord Homelab
|
||||
timeout: 5
|
||||
|
||||
- name: DNS Primary
|
||||
type: dns
|
||||
hostname: google.com
|
||||
dns_server: 8.8.8.8
|
||||
dns_resolve_type: A
|
||||
interval: 60
|
||||
alert: Discord Homelab
|
||||
timeout: 5
|
||||
@@ -1,31 +0,0 @@
|
||||
#!/bin/bash
|
||||
# VHS screenshot setup: seed monitors, backfill history, start server.
|
||||
set -e
|
||||
DB="${1:?usage: setup.sh <db-path>}"
|
||||
|
||||
rm -f "$DB" "$DB-shm" "$DB-wal"
|
||||
|
||||
echo "==> Seeding monitors and alerts..."
|
||||
UPTOP_DB_DSN="$DB" ./uptop apply -f vhs/seed.yaml 2>&1
|
||||
|
||||
echo "==> Backfilling check history..."
|
||||
# Build first so the backfill's `now` (node last_seen, heartbeat timing) isn't racing
|
||||
# a cold compile — keeps the capture window deterministic.
|
||||
go build -o /tmp/uptop-backfill ./vhs/backfill/
|
||||
BACKFILL_OUT=$(/tmp/uptop-backfill "$DB")
|
||||
echo "$BACKFILL_OUT"
|
||||
|
||||
PUSH_TOKEN=$(echo "$BACKFILL_OUT" | grep '^PUSH_TOKEN=' | cut -d= -f2)
|
||||
if [ -n "$PUSH_TOKEN" ]; then
|
||||
echo "==> Sending push heartbeat in 10s (background)..."
|
||||
(sleep 10 && curl -s "http://localhost:18099/api/push" -H "Authorization: Bearer $PUSH_TOKEN" > /dev/null 2>&1) &
|
||||
fi
|
||||
|
||||
echo "==> Starting uptop server..."
|
||||
exec env \
|
||||
UPTOP_DB_DSN="$DB" \
|
||||
UPTOP_PORT=23299 \
|
||||
UPTOP_HTTP_PORT=18099 \
|
||||
UPTOP_ALLOW_PRIVATE_TARGETS=true \
|
||||
UPTOP_DEMO=1 \
|
||||
./uptop serve 2>/dev/null
|
||||