Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8d34524aa0 | |||
| b254f6ea05 | |||
| 87270490de | |||
| f80e519349 | |||
| 9a4a53f487 | |||
| 32982228b0 | |||
| ec898ff943 | |||
| 38c7739995 | |||
| 5679dffffa | |||
| 9a4985e355 | |||
| 65406ce69c | |||
| 2474b341ad | |||
| b0762800ac | |||
| 08bcdd6481 | |||
| ebf8bfb097 | |||
| b62a721277 | |||
| 8f17deba67 | |||
| 026e969b74 |
@@ -1,15 +1,10 @@
|
|||||||
.git
|
.git
|
||||||
|
.ssh/
|
||||||
|
.gitea/
|
||||||
tmp/
|
tmp/
|
||||||
vendor/
|
vendor/
|
||||||
|
*.db
|
||||||
# Security: keep sensitive/local files out of Docker build context
|
*.db-journal
|
||||||
.ssh/
|
|
||||||
.claude/
|
|
||||||
.github/
|
|
||||||
.gitea/
|
|
||||||
CLAUDE.md
|
|
||||||
*.local.json
|
*.local.json
|
||||||
*.local.md
|
*.local.md
|
||||||
*.local
|
*.local
|
||||||
*.db
|
|
||||||
*.db-journal
|
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: sh
|
||||||
steps:
|
steps:
|
||||||
|
- name: Install build tools
|
||||||
|
run: apk add --no-cache git gcc musl-dev
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
@@ -34,9 +40,10 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GORELEASER_FORCE_TOKEN: gitea
|
GORELEASER_FORCE_TOKEN: gitea
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
GITEA_API_URL: http://gitea:3000/api/v1
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
runs-on: ubuntu-latest
|
runs-on: docker-builder
|
||||||
needs: [release]
|
needs: [release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -59,6 +66,8 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
|
sbom: true
|
||||||
|
provenance: mode=max
|
||||||
tags: |
|
tags: |
|
||||||
lerkolabs/uptop:${{ github.ref_name }}
|
lerkolabs/uptop:${{ github.ref_name }}
|
||||||
lerkolabs/uptop:latest
|
lerkolabs/uptop:latest
|
||||||
@@ -66,3 +75,16 @@ jobs:
|
|||||||
VERSION=${{ github.ref_name }}
|
VERSION=${{ github.ref_name }}
|
||||||
COMMIT=${{ github.sha }}
|
COMMIT=${{ github.sha }}
|
||||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
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
|
||||||
|
|
||||||
|
- name: Cleanup Docker artifacts
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker image prune -f
|
||||||
|
docker builder prune -f --keep-storage=2GB
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ go.work
|
|||||||
# End of https://www.toptal.com/developers/gitignore/api/go
|
# End of https://www.toptal.com/developers/gitignore/api/go
|
||||||
|
|
||||||
/uptop
|
/uptop
|
||||||
uptop.db
|
uptop.db*
|
||||||
|
|
||||||
.ssh
|
.ssh
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
gitea_urls:
|
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
|
download: https://gitea.lerkolabs.com
|
||||||
|
|
||||||
release:
|
release:
|
||||||
gitea:
|
gitea:
|
||||||
owner: lerko
|
owner: lerkolabs
|
||||||
name: uptop
|
name: uptop
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
|
|||||||
@@ -1,46 +1,94 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [2026.05.2] — 2026-05-23
|
## [2026.05.5] — 2026-05-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Comprehensive test suite (94 tests across monitor, server, cluster)
|
- Error reason display when monitors go DOWN (#33)
|
||||||
- golangci-lint config with CI enforcement
|
- Push monitor lifecycle — PENDING, LATE, DOWN states (#34)
|
||||||
- Gitea Actions CI pipeline (test + lint)
|
- Logs tab overhaul — severity tags, filtering, recovery durations (#35)
|
||||||
- Graceful shutdown for HTTP and SSH servers
|
- Alert channel health indicator and test alerts (#36)
|
||||||
- Context-aware alert delivery with timeout
|
- TUI screenshots in `assets/` (#32)
|
||||||
- Request size limits on all POST endpoints
|
- CI status badge in README
|
||||||
- Constant-time secret comparison
|
|
||||||
- Check interval jitter to prevent thundering herd
|
|
||||||
- `--version` flag with build metadata injection
|
|
||||||
|
|
||||||
### Fixed
|
### Changed
|
||||||
- Silent JSON unmarshal failures in alert settings
|
- Visual polish — detail sections, column headers, alert detail (#37)
|
||||||
- Panic on crypto/rand failure replaced with error return
|
- README rewritten with hero image, badges, collapsible install sections (#32)
|
||||||
- Alert delivery errors now logged instead of swallowed
|
- Changelog rewritten to match actual CalVer tag history
|
||||||
- log.Fatalf in goroutines replaced with log.Printf
|
- Migrated to `lerkolabs` org namespace (#38)
|
||||||
- Deprecated LineUp/LineDown API calls
|
- 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
|
### Security
|
||||||
- Cluster secret compared with crypto/subtle (timing-safe)
|
- Phase 1: SSRF protection, input validation, safe dial (#26)
|
||||||
- http.MaxBytesReader on all JSON endpoints
|
- Phase 2: TLS hardening, auth bypass fixes, rate limiting (#27)
|
||||||
- ReadHeaderTimeout added to HTTP server
|
- 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
|
### Added
|
||||||
- Distributed probing with leader + probe nodes
|
- Distributed probing with leader + probe nodes
|
||||||
- Config-as-code (YAML apply/export with dry-run, prune)
|
- Config-as-code — YAML apply/export with dry-run and prune
|
||||||
- TUI visual polish (zebra striping, sparklines, breadcrumbs)
|
- TUI polish — status bar, tab badges, detail panel, modals
|
||||||
- Incident management and maintenance windows
|
- DOWN-first sort, health pulse, site filter
|
||||||
- 9 alert providers (Discord, Slack, Email, Ntfy, Telegram, PagerDuty, Pushover, Gotify, Webhook)
|
- 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
|
### Added
|
||||||
- SSH-accessible TUI (Bubble Tea + Wish)
|
- SSH-accessible TUI built on Bubble Tea + Wish
|
||||||
- 6 check types (HTTP, Push, Ping, Port, DNS, Group)
|
- 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
|
- SQLite and PostgreSQL support
|
||||||
- HA clustering with automatic failover
|
- HA clustering with automatic failover
|
||||||
- Prometheus metrics endpoint
|
- Prometheus /metrics endpoint
|
||||||
- Public status page
|
- Public status page (HTML + JSON)
|
||||||
- Uptime Kuma import
|
- Uptime Kuma backup import
|
||||||
|
|||||||
@@ -17,18 +17,21 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
|||||||
# --- Stage 2: Runner ---
|
# --- Stage 2: Runner ---
|
||||||
FROM alpine:3.23
|
FROM alpine:3.23
|
||||||
WORKDIR /app
|
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
|
RUN addgroup -g 1000 -S uptop && adduser -u 1000 -S uptop -G uptop
|
||||||
|
RUN mkdir -p /data/.ssh && chown -R uptop:uptop /data
|
||||||
|
|
||||||
COPY --from=builder /app/uptop .
|
COPY --from=builder /app/uptop .
|
||||||
|
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
|
||||||
|
|
||||||
# Set Default Configuration via ENV
|
|
||||||
# Docker users can override these in docker-compose.yml
|
|
||||||
ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true
|
ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true
|
||||||
ENV UPTOP_DB_TYPE=sqlite
|
ENV UPTOP_DB_TYPE=sqlite
|
||||||
ENV UPTOP_DB_DSN=/data/uptop.db
|
ENV UPTOP_DB_DSN=/data/uptop.db
|
||||||
ENV UPTOP_KEYS=/data/authorized_keys
|
ENV UPTOP_KEYS=/data/authorized_keys
|
||||||
|
ENV UPTOP_SSH_HOST_KEY=/data/.ssh/id_ed25519
|
||||||
ENV UPTOP_PORT=23234
|
ENV UPTOP_PORT=23234
|
||||||
|
|
||||||
EXPOSE 23234
|
EXPOSE 23234
|
||||||
|
USER uptop
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
CMD ["./uptop"]
|
CMD ["./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
|
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.
|
||||||
- **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
|
Built on [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). Rewritten for clustering, config-as-code, and a proper dashboard.
|
||||||
- **HA clustering**: leader/follower with automatic failover
|
|
||||||
- **Prometheus metrics**: `/metrics` endpoint for Grafana dashboards
|
## Features
|
||||||
- **Public status page**: HTML + JSON, toggle with an env var
|
|
||||||
- **SQLite or Postgres**: SQLite for single-node, Postgres for production
|
- **6 check types** — HTTP, Push (heartbeat), Ping, Port, DNS, Groups
|
||||||
- **Uptime Kuma import**: migrate from Kuma with one command
|
- **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
|
## Quick start
|
||||||
|
|
||||||
@@ -22,7 +53,7 @@ go run cmd/uptop/main.go
|
|||||||
ssh -p 23234 localhost
|
ssh -p 23234 localhost
|
||||||
```
|
```
|
||||||
|
|
||||||
Seed some demo data to see it in action:
|
Want some data to look at first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run cmd/uptop/main.go -demo
|
go run cmd/uptop/main.go -demo
|
||||||
@@ -30,22 +61,45 @@ go run cmd/uptop/main.go -demo
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
### From source
|
<details>
|
||||||
|
<summary><strong>Docker (recommended)</strong></summary>
|
||||||
|
|
||||||
```bash
|
```yaml
|
||||||
go install gitea.lerkolabs.com/lerko/uptop/cmd/uptop@latest
|
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
|
```bash
|
||||||
docker pull lerko/uptop:latest
|
go install gitea.lerkolabs.com/lerkolabs/uptop/cmd/uptop@latest
|
||||||
docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/uptop
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Binary
|
</details>
|
||||||
|
|
||||||
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptop/releases).
|
|
||||||
|
|
||||||
## Config as code
|
## 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
|
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.
|
Full reference in [docs/config-as-code.md](docs/config-as-code.md).
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `UPTOP_PORT` | `23234` | SSH server port |
|
| `UPTOP_PORT` | `23234` | SSH server port |
|
||||||
| `UPTOP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
|
| `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_PEER_URL` | | Leader URL for follower nodes |
|
||||||
| `UPTOP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
|
| `UPTOP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
|
||||||
| `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
|
| `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
|
## Migrating from Uptime Kuma
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 253 KiB |
@@ -17,14 +17,14 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/cluster"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/cluster"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/config"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/config"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/importer"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/server"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/server"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/tui"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/tui"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/ssh"
|
"github.com/charmbracelet/ssh"
|
||||||
@@ -385,6 +385,7 @@ func runServe(args []string) {
|
|||||||
|
|
||||||
eng.InitHistory()
|
eng.InitHistory()
|
||||||
eng.InitLogs()
|
eng.InitLogs()
|
||||||
|
eng.InitAlertHealth()
|
||||||
eng.Start(ctx)
|
eng.Start(ctx)
|
||||||
|
|
||||||
tlsCert := os.Getenv("UPTOP_TLS_CERT")
|
tlsCert := os.Getenv("UPTOP_TLS_CERT")
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ ! -w /data ]; then
|
||||||
|
echo "ERROR: /data is not writable by uptop user (UID $(id -u))." >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "If upgrading from a previous version that ran as root:" >&2
|
||||||
|
echo " docker run --rm -v <your_volume>:/data alpine chown -R 1000:1000 /data" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p /data/.ssh
|
||||||
|
|
||||||
|
exec "$@"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
module gitea.lerkolabs.com/lerko/uptop
|
module gitea.lerkolabs.com/lerkolabs/uptop
|
||||||
|
|
||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
@@ -53,7 +53,7 @@ require (
|
|||||||
golang.org/x/crypto v0.52.0 // indirect
|
golang.org/x/crypto v0.52.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||||
golang.org/x/mod v0.35.0 // 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/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
golang.org/x/text v0.37.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/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 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
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 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
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=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
var alertClient = &http.Client{Timeout: 10 * time.Second}
|
var alertClient = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHTTPProviderDiscord(t *testing.T) {
|
func TestHTTPProviderDiscord(t *testing.T) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mock Store (minimal, for monitor.NewEngine) ---
|
// --- Mock Store (minimal, for monitor.NewEngine) ---
|
||||||
@@ -53,6 +53,10 @@ func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return models.Pr
|
|||||||
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
||||||
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
||||||
func (m *mockStore) DeleteNode(string) error { return nil }
|
func (m *mockStore) DeleteNode(string) error { return nil }
|
||||||
|
func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
|
||||||
func (m *mockStore) SaveLog(string) error { return nil }
|
func (m *mockStore) SaveLog(string) error { return nil }
|
||||||
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProbeConfig struct {
|
type ProbeConfig struct {
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package importer
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package metrics
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockStore struct {
|
type mockStore struct {
|
||||||
@@ -51,6 +51,10 @@ func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return m
|
|||||||
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
||||||
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
||||||
func (m *mockStore) DeleteNode(string) error { return nil }
|
func (m *mockStore) DeleteNode(string) error { return nil }
|
||||||
|
func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
|
||||||
func (m *mockStore) SaveLog(string) error { return nil }
|
func (m *mockStore) SaveLog(string) error { return nil }
|
||||||
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||||
|
|||||||
@@ -79,6 +79,17 @@ type ProbeNode struct {
|
|||||||
Version string
|
Version string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AlertHealthRecord is the persisted send health of an alert channel. It lets the
|
||||||
|
// "last sent" / health indicators survive restarts instead of resetting to "never".
|
||||||
|
type AlertHealthRecord struct {
|
||||||
|
AlertID int
|
||||||
|
LastSendAt time.Time
|
||||||
|
LastSendOK bool
|
||||||
|
LastError string
|
||||||
|
SendCount int
|
||||||
|
FailCount int
|
||||||
|
}
|
||||||
|
|
||||||
type MaintenanceWindow struct {
|
type MaintenanceWindow struct {
|
||||||
ID int
|
ID int
|
||||||
MonitorID int
|
MonitorID int
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
probing "github.com/prometheus-community/pro-bing"
|
probing "github.com/prometheus-community/pro-bing"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunCheck_HTTP_Success(t *testing.T) {
|
func TestRunCheck_HTTP_Success(t *testing.T) {
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/alert"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/alert"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -146,6 +146,26 @@ func (e *Engine) InitLogs() {
|
|||||||
e.logStore = logs
|
e.logStore = logs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InitAlertHealth restores persisted alert send health so the dashboard shows real
|
||||||
|
// "last sent" / health state on startup instead of resetting every channel to "never".
|
||||||
|
func (e *Engine) InitAlertHealth() {
|
||||||
|
records, err := e.db.LoadAlertHealth()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.alertHealthMu.Lock()
|
||||||
|
defer e.alertHealthMu.Unlock()
|
||||||
|
for id, r := range records {
|
||||||
|
e.alertHealth[id] = AlertHealth{
|
||||||
|
LastSendAt: r.LastSendAt,
|
||||||
|
LastSendOK: r.LastSendOK,
|
||||||
|
LastError: r.LastError,
|
||||||
|
SendCount: r.SendCount,
|
||||||
|
FailCount: r.FailCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (e *Engine) GetLogs() []string {
|
func (e *Engine) GetLogs() []string {
|
||||||
e.logMu.RLock()
|
e.logMu.RLock()
|
||||||
defer e.logMu.RUnlock()
|
defer e.logMu.RUnlock()
|
||||||
@@ -612,6 +632,18 @@ func (e *Engine) recordAlertResult(alertID int, ok bool, errMsg string) {
|
|||||||
h.FailCount++
|
h.FailCount++
|
||||||
}
|
}
|
||||||
e.alertHealth[alertID] = h
|
e.alertHealth[alertID] = h
|
||||||
|
|
||||||
|
// Persist best-effort so health survives restarts; DB IO off the alert path.
|
||||||
|
go func(rec models.AlertHealthRecord) {
|
||||||
|
_ = e.db.SaveAlertHealth(rec)
|
||||||
|
}(models.AlertHealthRecord{
|
||||||
|
AlertID: alertID,
|
||||||
|
LastSendAt: h.LastSendAt,
|
||||||
|
LastSendOK: h.LastSendOK,
|
||||||
|
LastError: h.LastError,
|
||||||
|
SendCount: h.SendCount,
|
||||||
|
FailCount: h.FailCount,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) GetAlertHealth(alertID int) AlertHealth {
|
func (e *Engine) GetAlertHealth(alertID int) AlertHealth {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mock Store ---
|
// --- Mock Store ---
|
||||||
@@ -63,6 +63,10 @@ func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return m
|
|||||||
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
||||||
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
||||||
func (m *mockStore) DeleteNode(string) error { return nil }
|
func (m *mockStore) DeleteNode(string) error { return nil }
|
||||||
|
func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
|
||||||
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/importer"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/metrics"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/metrics"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxRequestBody = 1 << 20
|
const maxRequestBody = 1 << 20
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mock Store ---
|
// --- Mock Store ---
|
||||||
@@ -65,6 +65,10 @@ func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int,
|
|||||||
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
||||||
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
||||||
func (m *mockStore) DeleteNode(string) error { return nil }
|
func (m *mockStore) DeleteNode(string) error { return nil }
|
||||||
|
func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
|
||||||
func (m *mockStore) SaveLog(string) error { return nil }
|
func (m *mockStore) SaveLog(string) error { return nil }
|
||||||
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Dialect interface {
|
|||||||
ImportWipe(tx *sql.Tx)
|
ImportWipe(tx *sql.Tx)
|
||||||
ImportResetSequences(tx *sql.Tx)
|
ImportResetSequences(tx *sql.Tx)
|
||||||
UpsertNodeSQL() string
|
UpsertNodeSQL() string
|
||||||
|
UpsertAlertHealthSQL() string
|
||||||
}
|
}
|
||||||
|
|
||||||
func rewritePlaceholders(query string, dollarStyle bool) string {
|
func rewritePlaceholders(query string, dollarStyle bool) string {
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
|
|||||||
changed_at TIMESTAMP DEFAULT NOW()
|
changed_at TIMESTAMP DEFAULT NOW()
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`,
|
`CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS alert_health (
|
||||||
|
alert_id INTEGER PRIMARY KEY,
|
||||||
|
last_send_at TIMESTAMP,
|
||||||
|
last_send_ok BOOLEAN DEFAULT FALSE,
|
||||||
|
last_error TEXT DEFAULT '',
|
||||||
|
send_count INTEGER DEFAULT 0,
|
||||||
|
fail_count INTEGER DEFAULT 0
|
||||||
|
)`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +114,10 @@ func (d *PostgresDialect) UpsertNodeSQL() string {
|
|||||||
return "INSERT INTO nodes (id, name, region, last_seen, version) VALUES ($1, $2, $3, NOW(), $4) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, region = EXCLUDED.region, last_seen = NOW(), version = EXCLUDED.version"
|
return "INSERT INTO nodes (id, name, region, last_seen, version) VALUES ($1, $2, $3, NOW(), $4) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, region = EXCLUDED.region, last_seen = NOW(), version = EXCLUDED.version"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *PostgresDialect) UpsertAlertHealthSQL() string {
|
||||||
|
return "INSERT INTO alert_health (alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (alert_id) DO UPDATE SET last_send_at = EXCLUDED.last_send_at, last_send_ok = EXCLUDED.last_send_ok, last_error = EXCLUDED.last_error, send_count = EXCLUDED.send_count, fail_count = EXCLUDED.fail_count"
|
||||||
|
}
|
||||||
|
|
||||||
func (d *PostgresDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {}
|
func (d *PostgresDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {}
|
||||||
|
|
||||||
func (d *PostgresDialect) ImportWipe(tx *sql.Tx) {
|
func (d *PostgresDialect) ImportWipe(tx *sql.Tx) {
|
||||||
|
|||||||
@@ -88,6 +88,14 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
|
|||||||
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`,
|
`CREATE INDEX IF NOT EXISTS idx_state_changes_site ON state_changes(site_id, changed_at DESC)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS alert_health (
|
||||||
|
alert_id INTEGER PRIMARY KEY,
|
||||||
|
last_send_at DATETIME,
|
||||||
|
last_send_ok BOOLEAN DEFAULT 0,
|
||||||
|
last_error TEXT DEFAULT '',
|
||||||
|
send_count INTEGER DEFAULT 0,
|
||||||
|
fail_count INTEGER DEFAULT 0
|
||||||
|
)`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +121,10 @@ func (d *SQLiteDialect) UpsertNodeSQL() string {
|
|||||||
return "INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)"
|
return "INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (d *SQLiteDialect) UpsertAlertHealthSQL() string {
|
||||||
|
return "INSERT OR REPLACE INTO alert_health (alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count) VALUES (?, ?, ?, ?, ?, ?)"
|
||||||
|
}
|
||||||
|
|
||||||
func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {
|
func (d *SQLiteDialect) ResetSequenceOnEmpty(db *sql.DB, table string) {
|
||||||
var count int
|
var count int
|
||||||
_ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck
|
_ = db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count) //nolint:errcheck
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -430,6 +430,37 @@ func (s *SQLStore) DeleteNode(id string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
|
||||||
|
rows, err := s.db.Query("SELECT alert_id, last_send_at, last_send_ok, last_error, send_count, fail_count FROM alert_health")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make(map[int]models.AlertHealthRecord)
|
||||||
|
for rows.Next() {
|
||||||
|
var r models.AlertHealthRecord
|
||||||
|
var lastSend sql.NullTime
|
||||||
|
if err := rows.Scan(&r.AlertID, &lastSend, &r.LastSendOK, &r.LastError, &r.SendCount, &r.FailCount); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if lastSend.Valid {
|
||||||
|
r.LastSendAt = lastSend.Time
|
||||||
|
}
|
||||||
|
out[r.AlertID] = r
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SQLStore) SaveAlertHealth(h models.AlertHealthRecord) error {
|
||||||
|
var lastSend interface{}
|
||||||
|
if !h.LastSendAt.IsZero() {
|
||||||
|
lastSend = h.LastSendAt
|
||||||
|
}
|
||||||
|
_, err := s.db.Exec(s.dialect.UpsertAlertHealthSQL(),
|
||||||
|
h.AlertID, lastSend, h.LastSendOK, h.LastError, h.SendCount, h.FailCount)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SQLStore) SaveLog(message string) error {
|
func (s *SQLStore) SaveLog(message string) error {
|
||||||
_, err := s.db.Exec(s.q("INSERT INTO logs (message) VALUES (?)"), message)
|
_, err := s.db.Exec(s.q("INSERT INTO logs (message) VALUES (?)"), message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
@@ -49,6 +49,10 @@ type Store interface {
|
|||||||
UpdateNodeLastSeen(id string) error
|
UpdateNodeLastSeen(id string) error
|
||||||
DeleteNode(id string) error
|
DeleteNode(id string) error
|
||||||
|
|
||||||
|
// Alert Health
|
||||||
|
LoadAlertHealth() (map[int]models.AlertHealthRecord, error)
|
||||||
|
SaveAlertHealth(h models.AlertHealthRecord) error
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
SaveLog(message string) error
|
SaveLog(message string) error
|
||||||
LoadLogs(limit int) ([]string, error)
|
LoadLogs(limit int) ([]string, error)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
@@ -959,19 +959,26 @@ func (m Model) viewDetailPanel() string {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth))
|
b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth))
|
||||||
if len(hist.Latencies) > 0 {
|
// Stats over successful checks only — a failed check is stored as 0ns latency
|
||||||
minL, maxL := hist.Latencies[0], hist.Latencies[0]
|
// and would otherwise drag Min to 0ms and skew the average.
|
||||||
var total time.Duration
|
var minL, maxL, total time.Duration
|
||||||
for _, l := range hist.Latencies {
|
count := 0
|
||||||
total += l
|
for i, l := range hist.Latencies {
|
||||||
if l < minL {
|
if i < len(hist.Statuses) && !hist.Statuses[i] {
|
||||||
minL = l
|
continue
|
||||||
}
|
}
|
||||||
if l > maxL {
|
if count == 0 {
|
||||||
|
minL, maxL = l, l
|
||||||
|
} else if l < minL {
|
||||||
|
minL = l
|
||||||
|
} else if l > maxL {
|
||||||
maxL = l
|
maxL = l
|
||||||
}
|
}
|
||||||
|
total += l
|
||||||
|
count++
|
||||||
}
|
}
|
||||||
avg := total / time.Duration(len(hist.Latencies))
|
if count > 0 {
|
||||||
|
avg := total / time.Duration(count)
|
||||||
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
|
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
|
||||||
subtleStyle.Render("Min"), minL.Milliseconds(),
|
subtleStyle.Render("Min"), minL.Milliseconds(),
|
||||||
subtleStyle.Render("Avg"), avg.Milliseconds(),
|
subtleStyle.Render("Avg"), avg.Milliseconds(),
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||||
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -122,6 +123,10 @@ type Model struct {
|
|||||||
|
|
||||||
filterMode bool
|
filterMode bool
|
||||||
filterText string
|
filterText string
|
||||||
|
|
||||||
|
// demoMode renders a stable status dot instead of the animated pulse so
|
||||||
|
// screenshots/recordings don't capture the spinner mid-frame. Set via UPTOP_DEMO=1.
|
||||||
|
demoMode bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
|
func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
|
||||||
@@ -155,6 +160,7 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
|
|||||||
collapsed: collapsed,
|
collapsed: collapsed,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
themeIndex: themeIdx,
|
themeIndex: themeIdx,
|
||||||
|
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,11 +760,6 @@ func (m *Model) submitForm() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) pulseIndicator() string {
|
func (m Model) pulseIndicator() string {
|
||||||
frame := m.tickCount % len(pulseFrames)
|
|
||||||
brightness := int(m.pulsePos*155) + 100
|
|
||||||
if brightness > 255 {
|
|
||||||
brightness = 255
|
|
||||||
}
|
|
||||||
hasDown := false
|
hasDown := false
|
||||||
for _, s := range m.sites {
|
for _, s := range m.sites {
|
||||||
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
||||||
@@ -766,6 +767,19 @@ func (m Model) pulseIndicator() string {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Stills can't show animation: render a stable status dot in demo mode.
|
||||||
|
if m.demoMode {
|
||||||
|
c := m.theme.Success
|
||||||
|
if hasDown {
|
||||||
|
c = m.theme.Danger
|
||||||
|
}
|
||||||
|
return lipgloss.NewStyle().Foreground(c).Render("●")
|
||||||
|
}
|
||||||
|
frame := m.tickCount % len(pulseFrames)
|
||||||
|
brightness := int(m.pulsePos*155) + 100
|
||||||
|
if brightness > 255 {
|
||||||
|
brightness = 255
|
||||||
|
}
|
||||||
var color string
|
var color string
|
||||||
if hasDown {
|
if hasDown {
|
||||||
color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4)
|
color = fmt.Sprintf("#%02x%02x%02x", brightness, brightness/4, brightness/4)
|
||||||
@@ -953,7 +967,11 @@ func (m Model) viewDashboard() string {
|
|||||||
online++
|
online++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
statusParts = append(statusParts, fmt.Sprintf("%d probes", online))
|
probeLabel := "probes"
|
||||||
|
if online == 1 {
|
||||||
|
probeLabel = "probe"
|
||||||
|
}
|
||||||
|
statusParts = append(statusParts, fmt.Sprintf("%d %s", online, probeLabel))
|
||||||
}
|
}
|
||||||
statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))
|
statusLine := strings.Join(statusParts, subtleStyle.Render(" · "))
|
||||||
|
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"math/rand"
|
|
||||||
"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.NewSource(42))
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
type monitorProfile struct {
|
|
||||||
name string
|
|
||||||
minMs int
|
|
||||||
maxMs int
|
|
||||||
downFrom int // check index where DOWN starts (-1 = never)
|
|
||||||
}
|
|
||||||
|
|
||||||
func backfillHistory(db *sql.DB, rng *rand.Rand, now time.Time, ids map[string]int) error {
|
|
||||||
profiles := []monitorProfile{
|
|
||||||
{"Nextcloud", 40, 80, -1},
|
|
||||||
{"Jellyfin", 80, 200, -1},
|
|
||||||
{"Home Assistant", 15, 45, -1},
|
|
||||||
{"Gitea", 40, 90, -1},
|
|
||||||
{"Traefik Dashboard", 5, 25, -1},
|
|
||||||
{"Vaultwarden", 50, 130, -1},
|
|
||||||
{"Personal Blog", 25, 65, -1},
|
|
||||||
{"Immich", 100, 280, -1}, // spikes handled below
|
|
||||||
{"Auth Portal", 30, 70, 40}, // DOWN after check 40
|
|
||||||
{"Edge Router", 5, 15, -1}, // ping
|
|
||||||
{"Postgres", 1, 5, -1}, // port
|
|
||||||
{"DNS Primary", 10, 30, -1},
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer 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 {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
changes := []sc{
|
|
||||||
{"Nextcloud", "UP", "DOWN", "read timeout", now.Add(-3 * 24 * time.Hour).Add(-5 * time.Minute)},
|
|
||||||
{"Nextcloud", "DOWN", "UP", "", now.Add(-3 * 24 * time.Hour)},
|
|
||||||
{"Jellyfin", "UP", "DOWN", "connection reset", now.Add(-18 * time.Hour).Add(-3 * time.Minute)},
|
|
||||||
{"Jellyfin", "DOWN", "UP", "", now.Add(-18 * time.Hour)},
|
|
||||||
{"Auth Portal", "UP", "DOWN", "connection refused", 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)},
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer 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 {
|
|
||||||
msg string
|
|
||||||
at time.Time
|
|
||||||
}
|
|
||||||
logs := []logEntry{
|
|
||||||
{"[06:12] Monitor 'Auth Portal' confirmed DOWN: connection refused", now.Add(-8 * time.Hour)},
|
|
||||||
{"[06:12] Monitor 'Auth Portal' failed check 2/2", now.Add(-8*time.Hour - 30*time.Second)},
|
|
||||||
{"[06:11] Monitor 'Auth Portal' failed check 1/2", now.Add(-8*time.Hour - 60*time.Second)},
|
|
||||||
{"[12:33] Monitor 'Immich' recovered (was down 8m)", now.Add(-12 * time.Hour)},
|
|
||||||
{"[12:25] Monitor 'Immich' confirmed DOWN: 502 Bad Gateway", now.Add(-12*time.Hour - 8*time.Minute)},
|
|
||||||
{"[12:25] Monitor 'Immich' failed check 3/3", now.Add(-12*time.Hour - 8*time.Minute - 30*time.Second)},
|
|
||||||
{"[12:25] Monitor 'Immich' failed check 2/3", now.Add(-12*time.Hour - 8*time.Minute - 60*time.Second)},
|
|
||||||
{"[12:24] Monitor 'Immich' failed check 1/3", now.Add(-12*time.Hour - 9*time.Minute)},
|
|
||||||
{"[06:14] Monitor 'Jellyfin' recovered (was down 3m)", now.Add(-18 * time.Hour)},
|
|
||||||
{"[06:11] Monitor 'Jellyfin' confirmed DOWN: connection reset", now.Add(-18*time.Hour - 3*time.Minute)},
|
|
||||||
{"[06:11] Monitor 'Jellyfin' failed check 2/2", now.Add(-18*time.Hour - 3*time.Minute - 30*time.Second)},
|
|
||||||
{"[06:10] Monitor 'Jellyfin' failed check 1/2", now.Add(-18*time.Hour - 4*time.Minute)},
|
|
||||||
{"[23:45] SSL certificate for 'Personal Blog' expires in 42 days", now.Add(-28 * time.Hour)},
|
|
||||||
{"[08:00] Loaded check history from database", now.Add(-32*time.Hour - 30*time.Minute)},
|
|
||||||
{"[08:00] Engine RESUMED (Active)", now.Add(-32*time.Hour - 30*time.Minute - 5*time.Second)},
|
|
||||||
}
|
|
||||||
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer 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 {
|
|
||||||
if _, err := stmt.Exec(l.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 {
|
|
||||||
_, err := db.Exec(
|
|
||||||
"INSERT OR REPLACE INTO nodes (id, name, region, last_seen, version) VALUES (?, ?, ?, ?, ?)",
|
|
||||||
"node-1", "leader", "us-east", now.Format("2006-01-02 15:04:05"), "2026.05.1",
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func backfillMaintenance(db *sql.DB, now time.Time, ids map[string]int) error {
|
|
||||||
tx, err := db.Begin()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer 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,54 +0,0 @@
|
|||||||
Set Shell "bash"
|
|
||||||
Set Width 1400
|
|
||||||
Set Height 800
|
|
||||||
Set FontSize 14
|
|
||||||
Set Padding 20
|
|
||||||
Set Framerate 15
|
|
||||||
Set TypingSpeed 50ms
|
|
||||||
|
|
||||||
Hide
|
|
||||||
Type "bash vhs/setup.sh /tmp/uptop-vhs.db"
|
|
||||||
Enter
|
|
||||||
Sleep 45s
|
|
||||||
Show
|
|
||||||
Sleep 5s
|
|
||||||
|
|
||||||
# Sites tab — hero shot with mixed monitor states
|
|
||||||
Screenshot vhs/screenshots/monitors.png
|
|
||||||
Sleep 1s
|
|
||||||
|
|
||||||
# Navigate to Nextcloud (row 6: group + 3 children + Auth Portal)
|
|
||||||
Down
|
|
||||||
Sleep 200ms
|
|
||||||
Down
|
|
||||||
Sleep 200ms
|
|
||||||
Down
|
|
||||||
Sleep 200ms
|
|
||||||
Down
|
|
||||||
Sleep 200ms
|
|
||||||
Down
|
|
||||||
Sleep 200ms
|
|
||||||
Type "i"
|
|
||||||
Sleep 3s
|
|
||||||
Screenshot vhs/screenshots/detail.png
|
|
||||||
Sleep 1s
|
|
||||||
|
|
||||||
# Close detail
|
|
||||||
Escape
|
|
||||||
Sleep 1s
|
|
||||||
|
|
||||||
# Tab to Alerts
|
|
||||||
Tab
|
|
||||||
Sleep 2s
|
|
||||||
Screenshot vhs/screenshots/alerts.png
|
|
||||||
Sleep 1s
|
|
||||||
|
|
||||||
# Tab to Logs
|
|
||||||
Tab
|
|
||||||
Sleep 2s
|
|
||||||
Screenshot vhs/screenshots/logs.png
|
|
||||||
Sleep 1s
|
|
||||||
|
|
||||||
# Quit
|
|
||||||
Type "q"
|
|
||||||
Sleep 1s
|
|
||||||
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 219 KiB |
@@ -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://example.com
|
|
||||||
interval: 30
|
|
||||||
alert: Discord Homelab
|
|
||||||
check_ssl: true
|
|
||||||
expiry_threshold: 14
|
|
||||||
max_retries: 2
|
|
||||||
|
|
||||||
- name: Jellyfin
|
|
||||||
type: http
|
|
||||||
url: https://example.com
|
|
||||||
interval: 30
|
|
||||||
alert: Discord Homelab
|
|
||||||
max_retries: 2
|
|
||||||
|
|
||||||
- name: Home Assistant
|
|
||||||
type: http
|
|
||||||
url: https://example.com
|
|
||||||
interval: 30
|
|
||||||
alert: Discord Homelab
|
|
||||||
max_retries: 3
|
|
||||||
|
|
||||||
- name: Gitea
|
|
||||||
type: http
|
|
||||||
url: https://example.com
|
|
||||||
interval: 60
|
|
||||||
alert: Discord Homelab
|
|
||||||
check_ssl: true
|
|
||||||
expiry_threshold: 14
|
|
||||||
max_retries: 2
|
|
||||||
|
|
||||||
- name: Traefik Dashboard
|
|
||||||
type: http
|
|
||||||
url: https://example.com
|
|
||||||
interval: 60
|
|
||||||
alert: Discord Homelab
|
|
||||||
max_retries: 1
|
|
||||||
|
|
||||||
- name: Vaultwarden
|
|
||||||
type: http
|
|
||||||
url: https://example.com
|
|
||||||
interval: 30
|
|
||||||
alert: Discord Homelab
|
|
||||||
check_ssl: true
|
|
||||||
expiry_threshold: 14
|
|
||||||
max_retries: 3
|
|
||||||
|
|
||||||
- name: Personal Blog
|
|
||||||
type: http
|
|
||||||
url: https://example.com
|
|
||||||
interval: 120
|
|
||||||
alert: Discord Homelab
|
|
||||||
check_ssl: true
|
|
||||||
expiry_threshold: 14
|
|
||||||
max_retries: 2
|
|
||||||
|
|
||||||
- name: Immich
|
|
||||||
type: http
|
|
||||||
url: https://example.com
|
|
||||||
interval: 60
|
|
||||||
alert: Discord Homelab
|
|
||||||
check_ssl: true
|
|
||||||
expiry_threshold: 7
|
|
||||||
max_retries: 3
|
|
||||||
|
|
||||||
# HTTP — deliberate failure
|
|
||||||
- name: Auth Portal
|
|
||||||
type: http
|
|
||||||
url: http://localhost:1
|
|
||||||
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,27 +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..."
|
|
||||||
BACKFILL_OUT=$(go run ./vhs/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 15s (background)..."
|
|
||||||
(sleep 15 && 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 serve 2>/dev/null
|
|
||||||