Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e36d9a7a26 |
@@ -1,10 +1,15 @@
|
|||||||
.git
|
.git
|
||||||
.ssh/
|
|
||||||
.gitea/
|
|
||||||
tmp/
|
tmp/
|
||||||
vendor/
|
vendor/
|
||||||
*.db
|
|
||||||
*.db-journal
|
# Security: keep sensitive/local files out of Docker build context
|
||||||
|
.ssh/
|
||||||
|
.claude/
|
||||||
|
.github/
|
||||||
|
.gitea/
|
||||||
|
CLAUDE.md
|
||||||
*.local.json
|
*.local.json
|
||||||
*.local.md
|
*.local.md
|
||||||
*.local
|
*.local
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
|||||||
@@ -8,13 +8,7 @@ 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
|
||||||
@@ -40,10 +34,9 @@ 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: docker-builder
|
runs-on: ubuntu-latest
|
||||||
needs: [release]
|
needs: [release]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -36,4 +36,8 @@ authorized_keys
|
|||||||
tmp
|
tmp
|
||||||
|
|
||||||
*.local.json
|
*.local.json
|
||||||
*.local.md
|
*.local.md
|
||||||
|
|
||||||
|
# VHS — seed has personal URLs, tape is local workflow
|
||||||
|
vhs/seed.yaml
|
||||||
|
vhs/demo.tape
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
gitea_urls:
|
gitea_urls:
|
||||||
api: "{{ if index .Env \"GITEA_API_URL\" }}{{ .Env.GITEA_API_URL }}{{ else }}https://gitea.lerkolabs.com/api/v1{{ end }}"
|
api: https://gitea.lerkolabs.com/api/v1
|
||||||
download: https://gitea.lerkolabs.com
|
download: https://gitea.lerkolabs.com
|
||||||
|
|
||||||
release:
|
release:
|
||||||
gitea:
|
gitea:
|
||||||
owner: lerkolabs
|
owner: lerko
|
||||||
name: uptop
|
name: uptop
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
|
|||||||
@@ -1,94 +1,46 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [2026.05.5] — 2026-05-29
|
## [2026.05.2] — 2026-05-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Error reason display when monitors go DOWN (#33)
|
- Comprehensive test suite (94 tests across monitor, server, cluster)
|
||||||
- Push monitor lifecycle — PENDING, LATE, DOWN states (#34)
|
- golangci-lint config with CI enforcement
|
||||||
- Logs tab overhaul — severity tags, filtering, recovery durations (#35)
|
- Gitea Actions CI pipeline (test + lint)
|
||||||
- Alert channel health indicator and test alerts (#36)
|
- Graceful shutdown for HTTP and SSH servers
|
||||||
- TUI screenshots in `assets/` (#32)
|
- Context-aware alert delivery with timeout
|
||||||
- CI status badge in README
|
- Request size limits on all POST endpoints
|
||||||
|
- Constant-time secret comparison
|
||||||
|
- Check interval jitter to prevent thundering herd
|
||||||
|
- `--version` flag with build metadata injection
|
||||||
|
|
||||||
### Changed
|
### Fixed
|
||||||
- Visual polish — detail sections, column headers, alert detail (#37)
|
- Silent JSON unmarshal failures in alert settings
|
||||||
- README rewritten with hero image, badges, collapsible install sections (#32)
|
- Panic on crypto/rand failure replaced with error return
|
||||||
- Changelog rewritten to match actual CalVer tag history
|
- Alert delivery errors now logged instead of swallowed
|
||||||
- Migrated to `lerkolabs` org namespace (#38)
|
- log.Fatalf in goroutines replaced with log.Printf
|
||||||
- Docker-compose files moved to `deploy/`
|
- Deprecated LineUp/LineDown API calls
|
||||||
|
|
||||||
## [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
|
||||||
- Phase 1: SSRF protection, input validation, safe dial (#26)
|
- Cluster secret compared with crypto/subtle (timing-safe)
|
||||||
- Phase 2: TLS hardening, auth bypass fixes, rate limiting (#27)
|
- http.MaxBytesReader on all JSON endpoints
|
||||||
- Phase 3: Graceful degradation, connection limits, timeout enforcement (#28)
|
- ReadHeaderTimeout added to HTTP server
|
||||||
- Phase 4: Code quality, error handling, linter fixes (#29)
|
|
||||||
|
|
||||||
## [2026.05.3] — 2026-05-25
|
## [2026.05.1] — 2026-05-14
|
||||||
|
|
||||||
### 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 and prune
|
- Config-as-code (YAML apply/export with dry-run, prune)
|
||||||
- TUI polish — status bar, tab badges, detail panel, modals
|
- TUI visual polish (zebra striping, sparklines, breadcrumbs)
|
||||||
- DOWN-first sort, health pulse, site filter
|
- Incident management and maintenance windows
|
||||||
- Type icons in sites table
|
- 9 alert providers (Discord, Slack, Email, Ntfy, Telegram, PagerDuty, Pushover, Gotify, Webhook)
|
||||||
- Sparkline history graphs
|
|
||||||
- Persistent state — uptime, status, latency, and logs survive restarts
|
|
||||||
- Push token stripping from /status/json response
|
|
||||||
|
|
||||||
## [2026.04.1] — 2026-04-01
|
## [2026.04.1] — Initial independent fork
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- SSH-accessible TUI built on Bubble Tea + Wish
|
- SSH-accessible TUI (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 (HTML + JSON)
|
- Public status page
|
||||||
- Uptime Kuma backup import
|
- Uptime Kuma import
|
||||||
|
|||||||
@@ -1,50 +1,19 @@
|
|||||||
<div align="center">
|
# uptop
|
||||||
<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>
|
|
||||||
|
|
||||||
<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`.
|
||||||
<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>
|
|
||||||
|
|
||||||
<img src="assets/monitors.png" alt="uptop monitors view" width="800">
|
Built on the foundation of [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep).
|
||||||
</div>
|
|
||||||
|
|
||||||
## What is this
|
## What it does
|
||||||
|
|
||||||
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.
|
- **6 check types**: HTTP, Push (heartbeat), Ping, Port, DNS, Groups
|
||||||
|
- **9 alert providers**: Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify
|
||||||
Built on [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). Rewritten for clustering, config-as-code, and a proper dashboard.
|
- **Config as code**: define monitors in YAML, apply declaratively, version control your setup
|
||||||
|
- **HA clustering**: leader/follower with automatic failover
|
||||||
## Features
|
- **Prometheus metrics**: `/metrics` endpoint for Grafana dashboards
|
||||||
|
- **Public status page**: HTML + JSON, toggle with an env var
|
||||||
- **6 check types** — HTTP, Push (heartbeat), Ping, Port, DNS, Groups
|
- **SQLite or Postgres**: SQLite for single-node, Postgres for production
|
||||||
- **9 alert providers** — Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify
|
- **Uptime Kuma import**: migrate from Kuma with one command
|
||||||
- **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
|
||||||
|
|
||||||
@@ -53,7 +22,7 @@ go run cmd/uptop/main.go
|
|||||||
ssh -p 23234 localhost
|
ssh -p 23234 localhost
|
||||||
```
|
```
|
||||||
|
|
||||||
Want some data to look at first:
|
Seed some demo data to see it in action:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run cmd/uptop/main.go -demo
|
go run cmd/uptop/main.go -demo
|
||||||
@@ -61,45 +30,22 @@ go run cmd/uptop/main.go -demo
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
<details>
|
### From source
|
||||||
<summary><strong>Docker (recommended)</strong></summary>
|
|
||||||
|
|
||||||
```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
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
||||||
go install gitea.lerkolabs.com/lerkolabs/uptop/cmd/uptop@latest
|
go install gitea.lerkolabs.com/lerko/uptop/cmd/uptop@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull lerko/uptop:latest
|
||||||
|
docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/uptop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Binary
|
||||||
|
|
||||||
|
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptop/releases).
|
||||||
|
|
||||||
## Config as code
|
## Config as code
|
||||||
|
|
||||||
@@ -117,11 +63,35 @@ 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
|
||||||
```
|
```
|
||||||
|
|
||||||
Full reference in [docs/config-as-code.md](docs/config-as-code.md).
|
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.
|
||||||
|
|
||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | What it does |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `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) |
|
||||||
@@ -133,7 +103,6 @@ Full reference in [docs/config-as-code.md](docs/config-as-code.md).
|
|||||||
| `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
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 232 KiB |
|
Before Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 253 KiB |
@@ -17,14 +17,14 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/cluster"
|
"gitea.lerkolabs.com/lerko/uptop/internal/cluster"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/config"
|
"gitea.lerkolabs.com/lerko/uptop/internal/config"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/importer"
|
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/server"
|
"gitea.lerkolabs.com/lerko/uptop/internal/server"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/tui"
|
"gitea.lerkolabs.com/lerko/uptop/internal/tui"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/ssh"
|
"github.com/charmbracelet/ssh"
|
||||||
@@ -385,7 +385,6 @@ 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")
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
module gitea.lerkolabs.com/lerkolabs/uptop
|
module gitea.lerkolabs.com/lerko/uptop
|
||||||
|
|
||||||
go 1.26.3
|
go 1.26.3
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/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/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/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/lerkolabs/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mock Store (minimal, for monitor.NewEngine) ---
|
// --- Mock Store (minimal, for monitor.NewEngine) ---
|
||||||
@@ -53,27 +53,21 @@ 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) {
|
func (m *mockStore) SaveLog(string) error { return nil }
|
||||||
return nil, nil
|
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
}
|
|
||||||
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
|
|
||||||
func (m *mockStore) SaveLog(string) error { return 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) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
|
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
|
||||||
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
|
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
|
||||||
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
|
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
|
||||||
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
|
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
|
||||||
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
|
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
|
||||||
func (m *mockStore) SetPreference(string, string) error { return nil }
|
func (m *mockStore) SetPreference(string, string) error { return nil }
|
||||||
func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil }
|
func (m *mockStore) Close() error { return nil }
|
||||||
func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
|
|
||||||
func (m *mockStore) Close() error { return nil }
|
|
||||||
|
|
||||||
// --- Cluster Start Tests ---
|
// --- Cluster Start Tests ---
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProbeConfig struct {
|
type ProbeConfig struct {
|
||||||
@@ -127,10 +127,9 @@ func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeCo
|
|||||||
}
|
}
|
||||||
|
|
||||||
type probeResultItem struct {
|
type probeResultItem struct {
|
||||||
SiteID int `json:"site_id"`
|
SiteID int `json:"site_id"`
|
||||||
LatencyNs int64 `json:"latency_ns"`
|
LatencyNs int64 `json:"latency_ns"`
|
||||||
IsUp bool `json:"is_up"`
|
IsUp bool `json:"is_up"`
|
||||||
ErrorReason string `json:"error_reason,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecure *http.Client, allowPrivate bool) []probeResultItem {
|
func probeExecuteChecks(ctx context.Context, sites []models.Site, strict, insecure *http.Client, allowPrivate bool) []probeResultItem {
|
||||||
@@ -155,10 +154,9 @@ loop:
|
|||||||
cr := monitor.RunCheck(s, strict, insecure, false, allowPrivate)
|
cr := monitor.RunCheck(s, strict, insecure, false, allowPrivate)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
results = append(results, probeResultItem{
|
results = append(results, probeResultItem{
|
||||||
SiteID: s.ID,
|
SiteID: s.ID,
|
||||||
LatencyNs: cr.LatencyNs,
|
LatencyNs: cr.LatencyNs,
|
||||||
IsUp: cr.Status == "UP",
|
IsUp: cr.Status == "UP",
|
||||||
ErrorReason: cr.ErrorReason,
|
|
||||||
})
|
})
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}(site)
|
}(site)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package importer
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package metrics
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ package metrics
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
|
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type mockStore struct {
|
type mockStore struct {
|
||||||
@@ -51,27 +50,21 @@ 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) {
|
func (m *mockStore) SaveLog(string) error { return nil }
|
||||||
return nil, nil
|
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
}
|
|
||||||
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
|
|
||||||
func (m *mockStore) SaveLog(string) error { return 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) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
|
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
|
||||||
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
|
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
|
||||||
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
|
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
|
||||||
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
|
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
|
||||||
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
|
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
|
||||||
func (m *mockStore) SetPreference(string, string) error { return nil }
|
func (m *mockStore) SetPreference(string, string) error { return nil }
|
||||||
func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil }
|
func (m *mockStore) Close() error { return nil }
|
||||||
func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
|
|
||||||
func (m *mockStore) Close() error { return nil }
|
|
||||||
|
|
||||||
func TestMetricsHandler(t *testing.T) {
|
func TestMetricsHandler(t *testing.T) {
|
||||||
ms := &mockStore{
|
ms := &mockStore{
|
||||||
|
|||||||
@@ -27,26 +27,14 @@ type Site struct {
|
|||||||
Paused bool
|
Paused bool
|
||||||
Regions string
|
Regions string
|
||||||
|
|
||||||
FailureCount int
|
FailureCount int
|
||||||
Status string
|
Status string
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Latency time.Duration
|
Latency time.Duration
|
||||||
CertExpiry time.Time
|
CertExpiry time.Time
|
||||||
HasSSL bool
|
HasSSL bool
|
||||||
LastCheck time.Time
|
LastCheck time.Time
|
||||||
SentSSLWarning bool
|
SentSSLWarning bool
|
||||||
LastError string
|
|
||||||
StatusChangedAt time.Time
|
|
||||||
LastSuccessAt time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type StateChange struct {
|
|
||||||
ID int
|
|
||||||
SiteID int
|
|
||||||
FromStatus string
|
|
||||||
ToStatus string
|
|
||||||
ErrorReason string
|
|
||||||
ChangedAt time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertConfig struct {
|
type AlertConfig struct {
|
||||||
@@ -79,17 +67,6 @@ 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
|
||||||
|
|||||||
@@ -11,11 +11,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type NodeResult struct {
|
type NodeResult struct {
|
||||||
NodeID string
|
NodeID string
|
||||||
IsUp bool
|
IsUp bool
|
||||||
LatencyNs int64
|
LatencyNs int64
|
||||||
CheckedAt time.Time
|
CheckedAt time.Time
|
||||||
ErrorReason string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func AggregateStatus(results []NodeResult, strategy AggregationStrategy) (isUp bool, avgLatencyNs int64) {
|
func AggregateStatus(results []NodeResult, strategy AggregationStrategy) (isUp bool, avgLatencyNs int64) {
|
||||||
|
|||||||
@@ -2,27 +2,25 @@ package monitor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/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"
|
||||||
)
|
)
|
||||||
|
|
||||||
type CheckResult struct {
|
type CheckResult struct {
|
||||||
SiteID int
|
SiteID int
|
||||||
Status string // "UP", "DOWN", "SSL EXP"
|
Status string // "UP", "DOWN", "SSL EXP"
|
||||||
StatusCode int
|
StatusCode int
|
||||||
LatencyNs int64
|
LatencyNs int64
|
||||||
HasSSL bool
|
HasSSL bool
|
||||||
CertExpiry time.Time
|
CertExpiry time.Time
|
||||||
ErrorReason string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool, allowPrivate ...bool) CheckResult {
|
func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bool, allowPrivate ...bool) CheckResult {
|
||||||
@@ -37,7 +35,7 @@ func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bo
|
|||||||
if ips, err := net.LookupIP(host); err == nil {
|
if ips, err := net.LookupIP(host); err == nil {
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
if isPrivateIP(ip) {
|
if isPrivateIP(ip) {
|
||||||
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "target resolves to private IP"}
|
return CheckResult{SiteID: site.ID, Status: "DOWN"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,7 +52,7 @@ func RunCheck(site models.Site, strict, insecure *http.Client, globalInsecure bo
|
|||||||
case "dns":
|
case "dns":
|
||||||
return runDNSCheck(site)
|
return runDNSCheck(site)
|
||||||
default:
|
default:
|
||||||
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "unsupported monitor type: " + site.Type}
|
return CheckResult{SiteID: site.ID, Status: "DOWN"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +68,7 @@ func runHTTPCheck(site models.Site, strict, insecure *http.Client, globalInsecur
|
|||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, method, site.URL, nil)
|
req, err := http.NewRequestWithContext(ctx, method, site.URL, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "invalid request: " + err.Error()}
|
return CheckResult{SiteID: site.ID, Status: "DOWN"}
|
||||||
}
|
}
|
||||||
|
|
||||||
client := strict
|
client := strict
|
||||||
@@ -90,7 +88,6 @@ func runHTTPCheck(site models.Site, strict, insecure *http.Client, globalInsecur
|
|||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Status = "DOWN"
|
result.Status = "DOWN"
|
||||||
result.ErrorReason = truncateError(err.Error(), 256)
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
@@ -98,11 +95,6 @@ func runHTTPCheck(site models.Site, strict, insecure *http.Client, globalInsecur
|
|||||||
result.StatusCode = resp.StatusCode
|
result.StatusCode = resp.StatusCode
|
||||||
if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) {
|
if !isCodeAccepted(resp.StatusCode, site.AcceptedCodes) {
|
||||||
result.Status = "DOWN"
|
result.Status = "DOWN"
|
||||||
expected := site.AcceptedCodes
|
|
||||||
if expected == "" {
|
|
||||||
expected = "200-299"
|
|
||||||
}
|
|
||||||
result.ErrorReason = fmt.Sprintf("HTTP %d (expected %s)", resp.StatusCode, expected)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if site.CheckSSL && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
|
if site.CheckSSL && resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
|
||||||
@@ -111,7 +103,6 @@ func runHTTPCheck(site models.Site, strict, insecure *http.Client, globalInsecur
|
|||||||
result.CertExpiry = cert.NotAfter
|
result.CertExpiry = cert.NotAfter
|
||||||
if time.Now().After(cert.NotAfter) {
|
if time.Now().After(cert.NotAfter) {
|
||||||
result.Status = "SSL EXP"
|
result.Status = "SSL EXP"
|
||||||
result.ErrorReason = "SSL certificate expired"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +117,7 @@ func runPingCheck(site models.Site) CheckResult {
|
|||||||
|
|
||||||
pinger, err := probing.NewPinger(host)
|
pinger, err := probing.NewPinger(host)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "ping setup: " + err.Error()}
|
return CheckResult{SiteID: site.ID, Status: "DOWN"}
|
||||||
}
|
}
|
||||||
pinger.Count = 1
|
pinger.Count = 1
|
||||||
pinger.Timeout = siteTimeout(site)
|
pinger.Timeout = siteTimeout(site)
|
||||||
@@ -136,11 +127,8 @@ func runPingCheck(site models.Site) CheckResult {
|
|||||||
err = pinger.Run()
|
err = pinger.Run()
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil || pinger.Statistics().PacketsRecv == 0 {
|
||||||
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "ping failed: " + err.Error()}
|
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()}
|
||||||
}
|
|
||||||
if pinger.Statistics().PacketsRecv == 0 {
|
|
||||||
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "no ICMP response"}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
stats := pinger.Statistics()
|
stats := pinger.Statistics()
|
||||||
@@ -160,7 +148,7 @@ func runPortCheck(site models.Site) CheckResult {
|
|||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: truncateError(err.Error(), 256)}
|
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()}
|
||||||
}
|
}
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
|
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
|
||||||
@@ -211,10 +199,10 @@ func runDNSCheck(site models.Site) CheckResult {
|
|||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS query failed: " + err.Error()}
|
return CheckResult{SiteID: site.ID, Status: "DOWN", LatencyNs: latency.Nanoseconds()}
|
||||||
}
|
}
|
||||||
if r.Rcode != dns.RcodeSuccess {
|
if r.Rcode != dns.RcodeSuccess {
|
||||||
return CheckResult{SiteID: site.ID, Status: "DOWN", StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds(), ErrorReason: "DNS RCODE: " + dns.RcodeToString[r.Rcode]}
|
return CheckResult{SiteID: site.ID, Status: "DOWN", StatusCode: r.Rcode, LatencyNs: latency.Nanoseconds()}
|
||||||
}
|
}
|
||||||
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
|
return CheckResult{SiteID: site.ID, Status: "UP", LatencyNs: latency.Nanoseconds()}
|
||||||
}
|
}
|
||||||
@@ -247,10 +235,3 @@ func isCodeAccepted(code int, accepted string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func truncateError(s string, max int) string {
|
|
||||||
if len(s) <= max {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
return s[:max-3] + "..."
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunCheck_HTTP_Success(t *testing.T) {
|
func TestRunCheck_HTTP_Success(t *testing.T) {
|
||||||
|
|||||||
@@ -11,26 +11,18 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/alert"
|
"gitea.lerkolabs.com/lerko/uptop/internal/alert"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxLogEntries = 100
|
maxLogEntries = 100
|
||||||
pollInterval = 5 * time.Second
|
pollInterval = 5 * time.Second
|
||||||
|
pushGracePeriod = 5 * time.Second
|
||||||
minCheckInterval = 5
|
minCheckInterval = 5
|
||||||
minPushGrace = 60 * time.Second
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertHealth struct {
|
|
||||||
LastSendAt time.Time
|
|
||||||
LastSendOK bool
|
|
||||||
LastError string
|
|
||||||
SendCount int
|
|
||||||
FailCount int
|
|
||||||
}
|
|
||||||
|
|
||||||
type Engine struct {
|
type Engine struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
liveState map[int]models.Site
|
liveState map[int]models.Site
|
||||||
@@ -50,9 +42,6 @@ type Engine struct {
|
|||||||
probeResults map[int]map[string]NodeResult
|
probeResults map[int]map[string]NodeResult
|
||||||
aggStrategy AggregationStrategy
|
aggStrategy AggregationStrategy
|
||||||
|
|
||||||
alertHealthMu sync.RWMutex
|
|
||||||
alertHealth map[int]AlertHealth
|
|
||||||
|
|
||||||
db store.Store
|
db store.Store
|
||||||
insecureSkipVerify bool
|
insecureSkipVerify bool
|
||||||
allowPrivateTargets bool
|
allowPrivateTargets bool
|
||||||
@@ -75,7 +64,6 @@ func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
|
|||||||
histories: make(map[int]*SiteHistory),
|
histories: make(map[int]*SiteHistory),
|
||||||
tokenIndex: make(map[string]int),
|
tokenIndex: make(map[string]int),
|
||||||
probeResults: make(map[int]map[string]NodeResult),
|
probeResults: make(map[int]map[string]NodeResult),
|
||||||
alertHealth: make(map[int]AlertHealth),
|
|
||||||
aggStrategy: AggAnyDown,
|
aggStrategy: AggAnyDown,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
allowPrivateTargets: allowPrivateTargets,
|
allowPrivateTargets: allowPrivateTargets,
|
||||||
@@ -108,19 +96,6 @@ func sanitizeLog(s string) string {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func fmtDurationShort(d time.Duration) string {
|
|
||||||
if d < time.Minute {
|
|
||||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
|
||||||
}
|
|
||||||
if d < time.Hour {
|
|
||||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
|
||||||
}
|
|
||||||
if d < 24*time.Hour {
|
|
||||||
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dd %dh", int(d.Hours())/24, int(d.Hours())%24)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) AddLog(msg string) {
|
func (e *Engine) AddLog(msg string) {
|
||||||
e.logMu.Lock()
|
e.logMu.Lock()
|
||||||
defer e.logMu.Unlock()
|
defer e.logMu.Unlock()
|
||||||
@@ -146,26 +121,6 @@ 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()
|
||||||
@@ -231,38 +186,17 @@ func (e *Engine) RecordHeartbeat(token string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
prevStatus := site.Status
|
|
||||||
site.LastCheck = time.Now()
|
site.LastCheck = time.Now()
|
||||||
|
wasDown := site.Status == "DOWN"
|
||||||
site.Status = "UP"
|
site.Status = "UP"
|
||||||
site.FailureCount = 0
|
site.FailureCount = 0
|
||||||
site.Latency = 0
|
site.Latency = 0
|
||||||
site.LastError = ""
|
|
||||||
site.LastSuccessAt = time.Now()
|
|
||||||
|
|
||||||
if prevStatus != "UP" {
|
|
||||||
site.StatusChangedAt = time.Now()
|
|
||||||
}
|
|
||||||
|
|
||||||
e.liveState[targetID] = site
|
e.liveState[targetID] = site
|
||||||
|
|
||||||
switch prevStatus {
|
if wasDown {
|
||||||
case "PENDING":
|
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered", site.Name))
|
||||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", site.Name))
|
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.", site.Name))
|
||||||
case "LATE":
|
|
||||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", site.Name))
|
|
||||||
case "DOWN":
|
|
||||||
downDur := ""
|
|
||||||
if !site.StatusChangedAt.IsZero() {
|
|
||||||
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(site.StatusChangedAt)))
|
|
||||||
}
|
|
||||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered%s", site.Name, downDur))
|
|
||||||
go e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", site.Name, downDur))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if prevStatus != "UP" && prevStatus != "PENDING" {
|
|
||||||
go func() { _ = e.db.SaveStateChange(targetID, prevStatus, "UP", "") }()
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +241,9 @@ func (e *Engine) Start(ctx context.Context) {
|
|||||||
if !exists {
|
if !exists {
|
||||||
e.mu.Lock()
|
e.mu.Lock()
|
||||||
s.Status = "PENDING"
|
s.Status = "PENDING"
|
||||||
|
if s.Type == "push" {
|
||||||
|
s.LastCheck = time.Now()
|
||||||
|
}
|
||||||
if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 {
|
if h, ok := e.GetHistory(s.ID); ok && len(h.Statuses) > 0 {
|
||||||
if h.Statuses[len(h.Statuses)-1] {
|
if h.Statuses[len(h.Statuses)-1] {
|
||||||
s.Status = "UP"
|
s.Status = "UP"
|
||||||
@@ -346,9 +283,6 @@ func (e *Engine) UpdateSiteConfig(site models.Site) {
|
|||||||
site.LastCheck = existing.LastCheck
|
site.LastCheck = existing.LastCheck
|
||||||
site.SentSSLWarning = existing.SentSSLWarning
|
site.SentSSLWarning = existing.SentSSLWarning
|
||||||
site.FailureCount = existing.FailureCount
|
site.FailureCount = existing.FailureCount
|
||||||
site.LastError = existing.LastError
|
|
||||||
site.StatusChangedAt = existing.StatusChangedAt
|
|
||||||
site.LastSuccessAt = existing.LastSuccessAt
|
|
||||||
e.liveState[site.ID] = site
|
e.liveState[site.ID] = site
|
||||||
e.addToTokenIndex(site)
|
e.addToTokenIndex(site)
|
||||||
}
|
}
|
||||||
@@ -459,62 +393,33 @@ func (e *Engine) checkByID(id int) {
|
|||||||
updatedSite.CertExpiry = result.CertExpiry
|
updatedSite.CertExpiry = result.CertExpiry
|
||||||
updatedSite.Latency = time.Duration(result.LatencyNs)
|
updatedSite.Latency = time.Duration(result.LatencyNs)
|
||||||
updatedSite.LastCheck = time.Now()
|
updatedSite.LastCheck = time.Now()
|
||||||
e.handleStatusChange(updatedSite, result.Status, result.StatusCode, time.Duration(result.LatencyNs), result.ErrorReason)
|
e.handleStatusChange(updatedSite, result.Status, result.StatusCode, time.Duration(result.LatencyNs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) checkPush(site models.Site) {
|
func (e *Engine) checkPush(site models.Site) {
|
||||||
if site.Status == "PENDING" {
|
deadline := site.LastCheck.Add(time.Duration(site.Interval) * time.Second).Add(pushGracePeriod)
|
||||||
return
|
if time.Now().After(deadline) {
|
||||||
}
|
e.handleStatusChange(site, "DOWN", 0, 0)
|
||||||
|
} else if site.Status != "UP" {
|
||||||
interval := time.Duration(site.Interval) * time.Second
|
e.handleStatusChange(site, "UP", 200, 0)
|
||||||
grace := interval / 2
|
|
||||||
if grace < minPushGrace {
|
|
||||||
grace = minPushGrace
|
|
||||||
}
|
|
||||||
|
|
||||||
overdue := site.LastCheck.Add(interval)
|
|
||||||
graceEnd := overdue.Add(grace)
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
if now.After(graceEnd) {
|
|
||||||
if site.Status != "DOWN" {
|
|
||||||
e.handleStatusChange(site, "DOWN", 0, 0, "heartbeat missed")
|
|
||||||
}
|
|
||||||
} else if now.After(overdue) {
|
|
||||||
if site.Status != "LATE" {
|
|
||||||
e.handleStatusChange(site, "LATE", 0, 0, "heartbeat overdue")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int, latency time.Duration, errorReason string) {
|
func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int, latency time.Duration) {
|
||||||
if !e.IsActive() {
|
if !e.IsActive() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
newState := site
|
newState := site
|
||||||
newState.StatusCode = code
|
newState.StatusCode = code
|
||||||
newState.LastError = errorReason
|
|
||||||
|
|
||||||
if rawStatus == "UP" {
|
|
||||||
newState.LastSuccessAt = time.Now()
|
|
||||||
newState.LastError = ""
|
|
||||||
} else {
|
|
||||||
newState.LastSuccessAt = site.LastSuccessAt
|
|
||||||
}
|
|
||||||
|
|
||||||
if site.Status == "UP" && rawStatus != "UP" {
|
if site.Status == "UP" && rawStatus != "UP" {
|
||||||
newState.FailureCount++
|
newState.FailureCount++
|
||||||
if newState.FailureCount > site.MaxRetries {
|
if newState.FailureCount > site.MaxRetries {
|
||||||
newState.Status = rawStatus
|
newState.Status = rawStatus
|
||||||
newState.FailureCount = site.MaxRetries + 1
|
newState.FailureCount = site.MaxRetries + 1
|
||||||
if errorReason != "" {
|
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", site.Name))
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN: %s", site.Name, errorReason))
|
|
||||||
} else {
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", site.Name))
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", site.Name, newState.FailureCount, site.MaxRetries))
|
e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", site.Name, newState.FailureCount, site.MaxRetries))
|
||||||
}
|
}
|
||||||
@@ -526,14 +431,6 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
|
|||||||
newState.FailureCount = site.MaxRetries + 1
|
newState.FailureCount = site.MaxRetries + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
if newState.Status != site.Status && site.Status != "PENDING" {
|
|
||||||
newState.StatusChangedAt = time.Now()
|
|
||||||
} else if site.StatusChangedAt.IsZero() && newState.Status != "PENDING" {
|
|
||||||
newState.StatusChangedAt = time.Now()
|
|
||||||
} else {
|
|
||||||
newState.StatusChangedAt = site.StatusChangedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
inMaint := e.isInMaintenance(site.ID)
|
inMaint := e.isInMaintenance(site.ID)
|
||||||
|
|
||||||
if site.Type == "http" && site.CheckSSL && site.HasSSL {
|
if site.Type == "http" && site.CheckSSL && site.HasSSL {
|
||||||
@@ -558,24 +455,12 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
|
|||||||
|
|
||||||
e.recordCheck(site.ID, latency, rawStatus == "UP")
|
e.recordCheck(site.ID, latency, rawStatus == "UP")
|
||||||
|
|
||||||
if newState.Status != site.Status && site.Status != "PENDING" {
|
|
||||||
go func() { _ = e.db.SaveStateChange(site.ID, site.Status, newState.Status, errorReason) }()
|
|
||||||
}
|
|
||||||
|
|
||||||
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" }
|
isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" }
|
||||||
|
|
||||||
if site.Status == "UP" && newState.Status == "LATE" {
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", site.Name))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" {
|
if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" {
|
||||||
if inMaint {
|
if inMaint {
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", site.Name))
|
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", site.Name))
|
||||||
} else {
|
} else {
|
||||||
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", site.Name, rawStatus)
|
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", site.Name, rawStatus)
|
||||||
if errorReason != "" {
|
|
||||||
msg = fmt.Sprintf("Monitor '%s' is DOWN: %s", site.Name, errorReason)
|
|
||||||
}
|
|
||||||
if site.Type == "push" {
|
if site.Type == "push" {
|
||||||
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name)
|
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", site.Name)
|
||||||
}
|
}
|
||||||
@@ -583,18 +468,12 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if isBroken(site.Status) && newState.Status == "UP" {
|
if isBroken(site.Status) && newState.Status == "UP" {
|
||||||
downDur := ""
|
|
||||||
if !site.StatusChangedAt.IsZero() {
|
|
||||||
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(site.StatusChangedAt)))
|
|
||||||
}
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' recovered%s", site.Name, downDur))
|
|
||||||
if !inMaint {
|
if !inMaint {
|
||||||
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", site.Name, downDur))
|
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name))
|
||||||
|
} else {
|
||||||
|
e.AddLog(fmt.Sprintf("Monitor '%s' recovered (maintenance active, alert suppressed)", site.Name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if site.Status == "LATE" && newState.Status == "UP" && !isBroken(site.Status) {
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", site.Name))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) triggerAlert(alertID int, title, message string) {
|
func (e *Engine) triggerAlert(alertID int, title, message string) {
|
||||||
@@ -610,69 +489,11 @@ func (e *Engine) triggerAlert(alertID int, title, message string) {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
if err := provider.Send(ctx, title, message); err != nil {
|
if err := provider.Send(ctx, title, message); err != nil {
|
||||||
e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err))
|
e.AddLog(fmt.Sprintf("Alert send failed (%s): %v", cfg.Name, err))
|
||||||
e.recordAlertResult(alertID, false, err.Error())
|
|
||||||
} else {
|
|
||||||
e.recordAlertResult(alertID, true, "")
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) recordAlertResult(alertID int, ok bool, errMsg string) {
|
|
||||||
e.alertHealthMu.Lock()
|
|
||||||
defer e.alertHealthMu.Unlock()
|
|
||||||
h := e.alertHealth[alertID]
|
|
||||||
h.LastSendAt = time.Now()
|
|
||||||
h.LastSendOK = ok
|
|
||||||
h.SendCount++
|
|
||||||
if ok {
|
|
||||||
h.LastError = ""
|
|
||||||
} else {
|
|
||||||
h.LastError = errMsg
|
|
||||||
h.FailCount++
|
|
||||||
}
|
|
||||||
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 {
|
|
||||||
e.alertHealthMu.RLock()
|
|
||||||
defer e.alertHealthMu.RUnlock()
|
|
||||||
return e.alertHealth[alertID]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) TestAlert(alertID int) error {
|
|
||||||
cfg, err := e.db.GetAlert(alertID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load alert: %w", err)
|
|
||||||
}
|
|
||||||
provider := alert.GetProvider(cfg)
|
|
||||||
if provider == nil {
|
|
||||||
return fmt.Errorf("no provider for type %q", cfg.Type)
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
err = provider.Send(ctx, "🧪 Test Alert", fmt.Sprintf("Test notification from uptop for channel '%s'.", cfg.Name))
|
|
||||||
if err != nil {
|
|
||||||
e.recordAlertResult(alertID, false, err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
e.recordAlertResult(alertID, true, "")
|
|
||||||
e.AddLog(fmt.Sprintf("Test alert sent to '%s'", cfg.Name))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Engine) isInMaintenance(monitorID int) bool {
|
func (e *Engine) isInMaintenance(monitorID int) bool {
|
||||||
inMaint, err := e.db.IsMonitorInMaintenance(monitorID)
|
inMaint, err := e.db.IsMonitorInMaintenance(monitorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -733,17 +554,16 @@ func (e *Engine) SetAggStrategy(strategy AggregationStrategy) {
|
|||||||
e.aggStrategy = strategy
|
e.aggStrategy = strategy
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool, errorReason string) {
|
func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool) {
|
||||||
e.probeResultsMu.Lock()
|
e.probeResultsMu.Lock()
|
||||||
if e.probeResults[siteID] == nil {
|
if e.probeResults[siteID] == nil {
|
||||||
e.probeResults[siteID] = make(map[string]NodeResult)
|
e.probeResults[siteID] = make(map[string]NodeResult)
|
||||||
}
|
}
|
||||||
e.probeResults[siteID][nodeID] = NodeResult{
|
e.probeResults[siteID][nodeID] = NodeResult{
|
||||||
NodeID: nodeID,
|
NodeID: nodeID,
|
||||||
IsUp: isUp,
|
IsUp: isUp,
|
||||||
LatencyNs: latencyNs,
|
LatencyNs: latencyNs,
|
||||||
CheckedAt: time.Now(),
|
CheckedAt: time.Now(),
|
||||||
ErrorReason: errorReason,
|
|
||||||
}
|
}
|
||||||
results := make([]NodeResult, 0, len(e.probeResults[siteID]))
|
results := make([]NodeResult, 0, len(e.probeResults[siteID]))
|
||||||
for _, r := range e.probeResults[siteID] {
|
for _, r := range e.probeResults[siteID] {
|
||||||
@@ -768,7 +588,7 @@ func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, i
|
|||||||
updatedSite := site
|
updatedSite := site
|
||||||
updatedSite.Latency = time.Duration(avgLatency)
|
updatedSite.Latency = time.Duration(avgLatency)
|
||||||
updatedSite.LastCheck = time.Now()
|
updatedSite.LastCheck = time.Now()
|
||||||
e.handleStatusChange(updatedSite, rawStatus, 0, time.Duration(avgLatency), errorReason)
|
e.handleStatusChange(updatedSite, rawStatus, 0, time.Duration(avgLatency))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
|
func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
|
||||||
@@ -781,11 +601,3 @@ func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
|
|||||||
}
|
}
|
||||||
return cp
|
return cp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) GetStateChanges(siteID int, limit int) []models.StateChange {
|
|
||||||
changes, err := e.db.GetStateChanges(siteID, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return changes
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ package monitor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mock Store ---
|
// --- Mock Store ---
|
||||||
@@ -63,24 +62,18 @@ 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
|
||||||
}
|
}
|
||||||
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
|
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
|
||||||
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
|
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
|
||||||
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
|
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
|
||||||
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
|
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
|
||||||
func (m *mockStore) SetPreference(string, string) error { return nil }
|
func (m *mockStore) SetPreference(string, string) error { return nil }
|
||||||
func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil }
|
func (m *mockStore) Close() error { return nil }
|
||||||
func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
|
|
||||||
func (m *mockStore) Close() error { return nil }
|
|
||||||
|
|
||||||
func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) {
|
func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -181,7 +174,7 @@ func TestHandleStatusChange_PendingToUp(t *testing.T) {
|
|||||||
site := models.Site{ID: 1, Name: "test", Status: "PENDING", MaxRetries: 3, AlertID: 1}
|
site := models.Site{ID: 1, Name: "test", Status: "PENDING", MaxRetries: 3, AlertID: 1}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "UP", 200, 10*time.Millisecond, "")
|
e.handleStatusChange(site, "UP", 200, 10*time.Millisecond)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if s.Status != "UP" {
|
if s.Status != "UP" {
|
||||||
@@ -202,7 +195,7 @@ func TestHandleStatusChange_UpIncrementFailure(t *testing.T) {
|
|||||||
site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 3, FailureCount: 0}
|
site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 3, FailureCount: 0}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "DOWN", 500, 0, "test error")
|
e.handleStatusChange(site, "DOWN", 500, 0)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if s.Status != "UP" {
|
if s.Status != "UP" {
|
||||||
@@ -220,7 +213,7 @@ func TestHandleStatusChange_UpToDown_ExceedsRetries(t *testing.T) {
|
|||||||
site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 2, FailureCount: 2, AlertID: 1}
|
site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 2, FailureCount: 2, AlertID: 1}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "DOWN", 500, 0, "test error")
|
e.handleStatusChange(site, "DOWN", 500, 0)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if s.Status != "DOWN" {
|
if s.Status != "DOWN" {
|
||||||
@@ -243,7 +236,7 @@ func TestHandleStatusChange_UpToDown_ZeroRetries(t *testing.T) {
|
|||||||
site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, FailureCount: 0, AlertID: 1}
|
site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, FailureCount: 0, AlertID: 1}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
|
e.handleStatusChange(site, "DOWN", 0, 0)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if s.Status != "DOWN" {
|
if s.Status != "DOWN" {
|
||||||
@@ -262,7 +255,7 @@ func TestHandleStatusChange_DownToUp_Recovery(t *testing.T) {
|
|||||||
site := models.Site{ID: 1, Name: "test", Status: "DOWN", FailureCount: 4, AlertID: 1}
|
site := models.Site{ID: 1, Name: "test", Status: "DOWN", FailureCount: 4, AlertID: 1}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "")
|
e.handleStatusChange(site, "UP", 200, 5*time.Millisecond)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if s.Status != "UP" {
|
if s.Status != "UP" {
|
||||||
@@ -283,7 +276,7 @@ func TestHandleStatusChange_DownStaysDown(t *testing.T) {
|
|||||||
site := models.Site{ID: 1, Name: "test", Status: "DOWN", MaxRetries: 2, FailureCount: 3}
|
site := models.Site{ID: 1, Name: "test", Status: "DOWN", MaxRetries: 2, FailureCount: 3}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
|
e.handleStatusChange(site, "DOWN", 0, 0)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if s.Status != "DOWN" {
|
if s.Status != "DOWN" {
|
||||||
@@ -302,7 +295,7 @@ func TestHandleStatusChange_SSLExpired(t *testing.T) {
|
|||||||
site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, AlertID: 1}
|
site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, AlertID: 1}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "SSL EXP", 0, 0, "SSL certificate expired")
|
e.handleStatusChange(site, "SSL EXP", 0, 0)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if s.Status != "SSL EXP" {
|
if s.Status != "SSL EXP" {
|
||||||
@@ -322,7 +315,7 @@ func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) {
|
|||||||
site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, AlertID: 1}
|
site := models.Site{ID: 1, Name: "test", Status: "UP", MaxRetries: 0, AlertID: 1}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
|
e.handleStatusChange(site, "DOWN", 0, 0)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if s.Status != "DOWN" {
|
if s.Status != "DOWN" {
|
||||||
@@ -353,7 +346,7 @@ func TestHandleStatusChange_RecoverySuppressedMaintenance(t *testing.T) {
|
|||||||
site := models.Site{ID: 1, Name: "test", Status: "DOWN", AlertID: 1}
|
site := models.Site{ID: 1, Name: "test", Status: "DOWN", AlertID: 1}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "UP", 200, 0, "")
|
e.handleStatusChange(site, "UP", 200, 0)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if s.Status != "UP" {
|
if s.Status != "UP" {
|
||||||
@@ -377,7 +370,7 @@ func TestHandleStatusChange_SSLWarning(t *testing.T) {
|
|||||||
}
|
}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "UP", 200, 0, "")
|
e.handleStatusChange(site, "UP", 200, 0)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if !s.SentSSLWarning {
|
if !s.SentSSLWarning {
|
||||||
@@ -400,7 +393,7 @@ func TestHandleStatusChange_SSLWarningNotRepeated(t *testing.T) {
|
|||||||
}
|
}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "UP", 200, 0, "")
|
e.handleStatusChange(site, "UP", 200, 0)
|
||||||
|
|
||||||
waitAsync()
|
waitAsync()
|
||||||
if len(ms.getAlertCallsSnapshot()) != 0 {
|
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||||
@@ -419,7 +412,7 @@ func TestHandleStatusChange_SSLWarningReset(t *testing.T) {
|
|||||||
}
|
}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "UP", 200, 0, "")
|
e.handleStatusChange(site, "UP", 200, 0)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if s.SentSSLWarning {
|
if s.SentSSLWarning {
|
||||||
@@ -440,7 +433,7 @@ func TestHandleStatusChange_SSLWarningSuppressedMaint(t *testing.T) {
|
|||||||
}
|
}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.handleStatusChange(site, "UP", 200, 0, "")
|
e.handleStatusChange(site, "UP", 200, 0)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if !s.SentSSLWarning {
|
if !s.SentSSLWarning {
|
||||||
@@ -459,7 +452,7 @@ func TestHandleStatusChange_InactiveEngine(t *testing.T) {
|
|||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
e.SetActive(false)
|
e.SetActive(false)
|
||||||
|
|
||||||
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
|
e.handleStatusChange(site, "DOWN", 0, 0)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if s.Status != "UP" {
|
if s.Status != "UP" {
|
||||||
@@ -541,7 +534,7 @@ func TestCheckPush_DeadlineMissed(t *testing.T) {
|
|||||||
site := models.Site{
|
site := models.Site{
|
||||||
ID: 1, Name: "push", Type: "push", Status: "UP",
|
ID: 1, Name: "push", Type: "push", Status: "UP",
|
||||||
Interval: 10, MaxRetries: 0,
|
Interval: 10, MaxRetries: 0,
|
||||||
LastCheck: time.Now().Add(-120 * time.Second),
|
LastCheck: time.Now().Add(-20 * time.Second),
|
||||||
}
|
}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
@@ -553,24 +546,6 @@ func TestCheckPush_DeadlineMissed(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckPush_OverdueBecomesLate(t *testing.T) {
|
|
||||||
ms := newMockStore()
|
|
||||||
e := newTestEngine(ms)
|
|
||||||
site := models.Site{
|
|
||||||
ID: 1, Name: "push", Type: "push", Status: "UP",
|
|
||||||
Interval: 300,
|
|
||||||
LastCheck: time.Now().Add(-310 * time.Second),
|
|
||||||
}
|
|
||||||
injectSite(e, site)
|
|
||||||
|
|
||||||
e.checkPush(site)
|
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
|
||||||
if s.Status != "LATE" {
|
|
||||||
t.Errorf("expected LATE when overdue but within grace, got %s", s.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckPush_WithinDeadline(t *testing.T) {
|
func TestCheckPush_WithinDeadline(t *testing.T) {
|
||||||
ms := newMockStore()
|
ms := newMockStore()
|
||||||
e := newTestEngine(ms)
|
e := newTestEngine(ms)
|
||||||
@@ -588,20 +563,20 @@ func TestCheckPush_WithinDeadline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckPush_PendingStaysPending(t *testing.T) {
|
func TestCheckPush_PendingToUp(t *testing.T) {
|
||||||
ms := newMockStore()
|
ms := newMockStore()
|
||||||
e := newTestEngine(ms)
|
e := newTestEngine(ms)
|
||||||
site := models.Site{
|
site := models.Site{
|
||||||
ID: 1, Name: "push", Type: "push", Status: "PENDING",
|
ID: 1, Name: "push", Type: "push", Status: "PENDING",
|
||||||
Interval: 60,
|
Interval: 60, LastCheck: time.Now(),
|
||||||
}
|
}
|
||||||
injectSite(e, site)
|
injectSite(e, site)
|
||||||
|
|
||||||
e.checkPush(site)
|
e.checkPush(site)
|
||||||
|
|
||||||
s, _ := getSite(e, 1)
|
s, _ := getSite(e, 1)
|
||||||
if s.Status != "PENDING" {
|
if s.Status != "UP" {
|
||||||
t.Errorf("expected PENDING to stay until first heartbeat, got %s", s.Status)
|
t.Errorf("expected UP, got %s", s.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,7 +991,7 @@ func TestConcurrent_HandleStatusChangeAndGetState(t *testing.T) {
|
|||||||
wg.Add(2)
|
wg.Add(2)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
e.handleStatusChange(site, "DOWN", 500, 0, "test error")
|
e.handleStatusChange(site, "DOWN", 500, 0)
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/importer"
|
"gitea.lerkolabs.com/lerko/uptop/internal/importer"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/metrics"
|
"gitea.lerkolabs.com/lerko/uptop/internal/metrics"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxRequestBody = 1 << 20
|
const maxRequestBody = 1 << 20
|
||||||
@@ -67,7 +67,6 @@ var statusTpl = template.Must(template.New("status").Parse(`
|
|||||||
.UP { background: #9ece6a; color: #1a1b26; }
|
.UP { background: #9ece6a; color: #1a1b26; }
|
||||||
.DOWN { background: #f7768e; color: #1a1b26; }
|
.DOWN { background: #f7768e; color: #1a1b26; }
|
||||||
.PENDING { background: #e0af68; color: #1a1b26; }
|
.PENDING { background: #e0af68; color: #1a1b26; }
|
||||||
.LATE { background: #e0af68; color: #1a1b26; }
|
|
||||||
.SSL-EXP { background: #e0af68; color: #1a1b26; }
|
.SSL-EXP { background: #e0af68; color: #1a1b26; }
|
||||||
.PAUSED { background: #565f89; color: #c0caf5; }
|
.PAUSED { background: #565f89; color: #c0caf5; }
|
||||||
.MAINT { background: #bb9af7; color: #1a1b26; }
|
.MAINT { background: #bb9af7; color: #1a1b26; }
|
||||||
@@ -404,10 +403,9 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
var req struct {
|
var req struct {
|
||||||
NodeID string `json:"node_id"`
|
NodeID string `json:"node_id"`
|
||||||
Results []struct {
|
Results []struct {
|
||||||
SiteID int `json:"site_id"`
|
SiteID int `json:"site_id"`
|
||||||
LatencyNs int64 `json:"latency_ns"`
|
LatencyNs int64 `json:"latency_ns"`
|
||||||
IsUp bool `json:"is_up"`
|
IsUp bool `json:"is_up"`
|
||||||
ErrorReason string `json:"error_reason"`
|
|
||||||
} `json:"results"`
|
} `json:"results"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -422,7 +420,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
if err := s.SaveCheckFromNode(result.SiteID, req.NodeID, result.LatencyNs, result.IsUp); err != nil {
|
if err := s.SaveCheckFromNode(result.SiteID, req.NodeID, result.LatencyNs, result.IsUp); err != nil {
|
||||||
log.Printf("Failed to save probe result: %v", err)
|
log.Printf("Failed to save probe result: %v", err)
|
||||||
}
|
}
|
||||||
eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp, result.ErrorReason)
|
eng.IngestProbeResult(req.NodeID, result.SiteID, result.LatencyNs, result.IsUp)
|
||||||
}
|
}
|
||||||
if err := s.UpdateNodeLastSeen(req.NodeID); err != nil {
|
if err := s.UpdateNodeLastSeen(req.NodeID); err != nil {
|
||||||
log.Printf("Failed to update node last seen: %v", err)
|
log.Printf("Failed to update node last seen: %v", err)
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
|
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mock Store ---
|
// --- Mock Store ---
|
||||||
@@ -65,24 +64,18 @@ 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) {
|
func (m *mockStore) SaveLog(string) error { return nil }
|
||||||
return nil, nil
|
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
}
|
|
||||||
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
|
|
||||||
func (m *mockStore) SaveLog(string) error { return 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) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
|
func (m *mockStore) AddMaintenanceWindow(models.MaintenanceWindow) error { return nil }
|
||||||
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
|
func (m *mockStore) EndMaintenanceWindow(int) error { return nil }
|
||||||
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
|
func (m *mockStore) DeleteMaintenanceWindow(int) error { return nil }
|
||||||
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
|
func (m *mockStore) IsMonitorInMaintenance(int) (bool, error) { return false, nil }
|
||||||
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
|
func (m *mockStore) GetPreference(string) (string, error) { return "", nil }
|
||||||
func (m *mockStore) SetPreference(string, string) error { return nil }
|
func (m *mockStore) SetPreference(string, string) error { return nil }
|
||||||
func (m *mockStore) SaveStateChange(int, string, string, string) error { return nil }
|
func (m *mockStore) Close() error { return nil }
|
||||||
func (m *mockStore) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
|
|
||||||
func (m *mockStore) Close() error { return nil }
|
|
||||||
|
|
||||||
func (m *mockStore) ExportData() (models.Backup, error) {
|
func (m *mockStore) ExportData() (models.Backup, error) {
|
||||||
return models.Backup{
|
return models.Backup{
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ 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 {
|
||||||
|
|||||||
@@ -72,23 +72,6 @@ func (d *PostgresDialect) CreateTablesSQL() []string {
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS state_changes (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
site_id INTEGER NOT NULL,
|
|
||||||
from_status TEXT NOT NULL,
|
|
||||||
to_status TEXT NOT NULL,
|
|
||||||
error_reason TEXT DEFAULT '',
|
|
||||||
changed_at TIMESTAMP DEFAULT NOW()
|
|
||||||
)`,
|
|
||||||
`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
|
|
||||||
)`,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,10 +97,6 @@ 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) {
|
||||||
|
|||||||
@@ -79,23 +79,6 @@ func (d *SQLiteDialect) CreateTablesSQL() []string {
|
|||||||
key TEXT PRIMARY KEY,
|
key TEXT PRIMARY KEY,
|
||||||
value TEXT NOT NULL
|
value TEXT NOT NULL
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS state_changes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
site_id INTEGER NOT NULL,
|
|
||||||
from_status TEXT NOT NULL,
|
|
||||||
to_status TEXT NOT NULL,
|
|
||||||
error_reason TEXT DEFAULT '',
|
|
||||||
changed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)`,
|
|
||||||
`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
|
|
||||||
)`,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,10 +104,6 @@ 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/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -347,29 +347,6 @@ func (s *SQLStore) DeleteUser(id int) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) SaveStateChange(siteID int, fromStatus, toStatus, errorReason string) error {
|
|
||||||
_, err := s.db.Exec(s.q("INSERT INTO state_changes (site_id, from_status, to_status, error_reason) VALUES (?, ?, ?, ?)"),
|
|
||||||
siteID, fromStatus, toStatus, errorReason)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) GetStateChanges(siteID int, limit int) ([]models.StateChange, error) {
|
|
||||||
rows, err := s.db.Query(s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? ORDER BY changed_at DESC LIMIT ?"), siteID, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var changes []models.StateChange
|
|
||||||
for rows.Next() {
|
|
||||||
var sc models.StateChange
|
|
||||||
if err := rows.Scan(&sc.ID, &sc.SiteID, &sc.FromStatus, &sc.ToStatus, &sc.ErrorReason, &sc.ChangedAt); err != nil {
|
|
||||||
return changes, err
|
|
||||||
}
|
|
||||||
changes = append(changes, sc)
|
|
||||||
}
|
|
||||||
return changes, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) SaveCheck(siteID int, latencyNs int64, isUp bool) error {
|
func (s *SQLStore) SaveCheck(siteID int, latencyNs int64, isUp bool) error {
|
||||||
return s.SaveCheckFromNode(siteID, "", latencyNs, isUp)
|
return s.SaveCheckFromNode(siteID, "", latencyNs, isUp)
|
||||||
}
|
}
|
||||||
@@ -430,37 +407,6 @@ 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/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
@@ -38,10 +38,6 @@ type Store interface {
|
|||||||
SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error
|
SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error
|
||||||
LoadAllHistory(limit int) (map[int][]models.CheckRecord, error)
|
LoadAllHistory(limit int) (map[int][]models.CheckRecord, error)
|
||||||
|
|
||||||
// State Changes
|
|
||||||
SaveStateChange(siteID int, fromStatus, toStatus, errorReason string) error
|
|
||||||
GetStateChanges(siteID int, limit int) ([]models.StateChange, error)
|
|
||||||
|
|
||||||
// Nodes
|
// Nodes
|
||||||
RegisterNode(node models.ProbeNode) error
|
RegisterNode(node models.ProbeNode) error
|
||||||
GetNode(id string) (models.ProbeNode, error)
|
GetNode(id string) (models.ProbeNode, error)
|
||||||
@@ -49,10 +45,6 @@ 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)
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"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"
|
||||||
@@ -116,122 +113,34 @@ func fmtAlertConfig(alert struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fmtAlertHealth(h monitor.AlertHealth) string {
|
|
||||||
if h.LastSendAt.IsZero() {
|
|
||||||
return subtleStyle.Render("●")
|
|
||||||
}
|
|
||||||
if h.LastSendOK {
|
|
||||||
return specialStyle.Render("●")
|
|
||||||
}
|
|
||||||
return dangerStyle.Render("●")
|
|
||||||
}
|
|
||||||
|
|
||||||
func fmtAlertLastSent(h monitor.AlertHealth) string {
|
|
||||||
if h.LastSendAt.IsZero() {
|
|
||||||
return subtleStyle.Render("never")
|
|
||||||
}
|
|
||||||
d := time.Since(h.LastSendAt)
|
|
||||||
if d < time.Minute {
|
|
||||||
return fmt.Sprintf("%ds ago", int(d.Seconds()))
|
|
||||||
}
|
|
||||||
if d < time.Hour {
|
|
||||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
|
||||||
}
|
|
||||||
if d < 24*time.Hour {
|
|
||||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) viewAlertsTab() string {
|
func (m Model) viewAlertsTab() string {
|
||||||
if len(m.alerts) == 0 {
|
if len(m.alerts) == 0 {
|
||||||
return "\n No alert channels configured. Press [n] to add one."
|
return "\n No alert channels configured. Press [n] to add one."
|
||||||
}
|
}
|
||||||
|
|
||||||
var headers []string
|
|
||||||
var widths []int
|
|
||||||
if m.isWide() {
|
|
||||||
headers = []string{"#", "", "NAME", "TYPE", "CONFIG", "LAST SENT"}
|
|
||||||
widths = []int{4, 3, 18, 12, 40, 12}
|
|
||||||
} else {
|
|
||||||
headers = []string{"#", "", "NAME", "TYPE", "CONFIG", "SENT"}
|
|
||||||
widths = []int{4, 3, 14, 10, 24, 8}
|
|
||||||
}
|
|
||||||
nameW := widths[2]
|
|
||||||
cfgW := widths[4]
|
|
||||||
|
|
||||||
return m.renderTable(
|
return m.renderTable(
|
||||||
headers,
|
[]string{"#", "NAME", "TYPE", "CONFIG"},
|
||||||
len(m.alerts),
|
len(m.alerts),
|
||||||
func(start, end int) [][]string {
|
func(start, end int) [][]string {
|
||||||
var rows [][]string
|
var rows [][]string
|
||||||
for i := start; i < end; i++ {
|
for i := start; i < end; i++ {
|
||||||
a := m.alerts[i]
|
a := m.alerts[i]
|
||||||
h := m.engine.GetAlertHealth(a.ID)
|
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
fmt.Sprintf("%d", i+1),
|
fmt.Sprintf("%d", i+1),
|
||||||
fmtAlertHealth(h),
|
m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, 15)),
|
||||||
m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)),
|
|
||||||
fmtAlertType(a.Type),
|
fmtAlertType(a.Type),
|
||||||
limitStr(fmtAlertConfig(struct {
|
fmtAlertConfig(struct {
|
||||||
Type string
|
Type string
|
||||||
Settings map[string]string
|
Settings map[string]string
|
||||||
}{a.Type, a.Settings}), cfgW-2),
|
}{a.Type, a.Settings}),
|
||||||
fmtAlertLastSent(h),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
},
|
},
|
||||||
widths, nil,
|
nil, nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewAlertDetailPanel() string {
|
|
||||||
if m.cursor >= len(m.alerts) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
a := m.alerts[m.cursor]
|
|
||||||
h := m.engine.GetAlertHealth(a.ID)
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
b.WriteString(subtleStyle.Render(" Alerts > ") + titleStyle.Render(a.Name) + "\n\n")
|
|
||||||
|
|
||||||
row := func(label, value string) {
|
|
||||||
fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value)
|
|
||||||
}
|
|
||||||
|
|
||||||
row("Type", fmtAlertType(a.Type))
|
|
||||||
|
|
||||||
if h.LastSendAt.IsZero() {
|
|
||||||
row("Health", subtleStyle.Render("never sent"))
|
|
||||||
} else if h.LastSendOK {
|
|
||||||
row("Health", specialStyle.Render("OK"))
|
|
||||||
} else {
|
|
||||||
row("Health", dangerStyle.Render("FAILED"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !h.LastSendAt.IsZero() {
|
|
||||||
row("Last Sent", h.LastSendAt.Format("2006-01-02 15:04:05")+" ("+fmtAlertLastSent(h)+")")
|
|
||||||
}
|
|
||||||
if h.SendCount > 0 {
|
|
||||||
row("Sends", fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount))
|
|
||||||
}
|
|
||||||
if h.LastError != "" {
|
|
||||||
row("Last Error", dangerStyle.Render(limitStr(h.LastError, 60)))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n" + subtleStyle.Render(" CONFIGURATION") + "\n")
|
|
||||||
for k, v := range a.Settings {
|
|
||||||
row(k, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [t] Test [q] Quit"))
|
|
||||||
|
|
||||||
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) initAlertHuhForm() tea.Cmd {
|
func (m *Model) initAlertHuhForm() tea.Cmd {
|
||||||
m.alertFormData = &alertFormData{
|
m.alertFormData = &alertFormData{
|
||||||
AlertType: "discord",
|
AlertType: "discord",
|
||||||
|
|||||||
@@ -5,83 +5,27 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type logSeverity int
|
func colorizeLog(line string) string {
|
||||||
|
|
||||||
const (
|
|
||||||
severityInfo logSeverity = iota
|
|
||||||
severityWarn
|
|
||||||
severityDown
|
|
||||||
severityUp
|
|
||||||
severitySystem
|
|
||||||
)
|
|
||||||
|
|
||||||
func classifyLog(line string) logSeverity {
|
|
||||||
lower := strings.ToLower(line)
|
lower := strings.ToLower(line)
|
||||||
switch {
|
switch {
|
||||||
case strings.Contains(lower, "confirmed down"),
|
case strings.Contains(lower, "confirmed down"),
|
||||||
strings.Contains(lower, "is down"),
|
strings.Contains(lower, "is down"),
|
||||||
strings.Contains(lower, "missed heartbeat"),
|
strings.Contains(lower, "missed heartbeat"),
|
||||||
strings.Contains(lower, "alert send failed"):
|
strings.Contains(lower, "failed check"),
|
||||||
return severityDown
|
strings.Contains(lower, "ssl warning"):
|
||||||
|
return dangerStyle.Render(line)
|
||||||
case strings.Contains(lower, "recovered"),
|
case strings.Contains(lower, "recovered"),
|
||||||
strings.Contains(lower, "is up"),
|
strings.Contains(lower, "is up"),
|
||||||
strings.Contains(lower, "recovery"),
|
strings.Contains(lower, "recovery"):
|
||||||
strings.Contains(lower, "first heartbeat"):
|
return specialStyle.Render(line)
|
||||||
return severityUp
|
|
||||||
case strings.Contains(lower, "failed check"),
|
|
||||||
strings.Contains(lower, "ssl warning"),
|
|
||||||
strings.Contains(lower, "overdue"),
|
|
||||||
strings.Contains(lower, "was late"):
|
|
||||||
return severityWarn
|
|
||||||
case strings.Contains(lower, "engine"),
|
case strings.Contains(lower, "engine"),
|
||||||
strings.Contains(lower, "cluster"),
|
strings.Contains(lower, "cluster"):
|
||||||
strings.Contains(lower, "loaded"),
|
return titleStyle.Render(line)
|
||||||
strings.Contains(lower, "paused"),
|
|
||||||
strings.Contains(lower, "resumed"):
|
|
||||||
return severitySystem
|
|
||||||
default:
|
default:
|
||||||
return severityInfo
|
return line
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func isImportantLog(sev logSeverity) bool {
|
|
||||||
return sev == severityDown || sev == severityUp || sev == severitySystem
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderLogTag(sev logSeverity) string {
|
|
||||||
switch sev {
|
|
||||||
case severityDown:
|
|
||||||
return dangerStyle.Render(" DOWN ")
|
|
||||||
case severityUp:
|
|
||||||
return specialStyle.Render(" UP ")
|
|
||||||
case severityWarn:
|
|
||||||
return warnStyle.Render(" WARN ")
|
|
||||||
case severitySystem:
|
|
||||||
return titleStyle.Render(" SYS ")
|
|
||||||
default:
|
|
||||||
return subtleStyle.Render(" info ")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderLogLine(line string) string {
|
|
||||||
sev := classifyLog(line)
|
|
||||||
tag := renderLogTag(sev)
|
|
||||||
|
|
||||||
ts := ""
|
|
||||||
msg := line
|
|
||||||
if len(line) > 10 && line[0] == '[' {
|
|
||||||
if idx := strings.Index(line, "]"); idx > 0 && idx < 12 {
|
|
||||||
ts = subtleStyle.Render(line[1:idx])
|
|
||||||
msg = strings.TrimSpace(line[idx+1:])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ts != "" {
|
|
||||||
return fmt.Sprintf(" %s %s %s", ts, tag, msg)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(" %s %s", tag, msg)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) viewLogsTab() string {
|
func (m Model) viewLogsTab() string {
|
||||||
content := m.logViewport.View()
|
content := m.logViewport.View()
|
||||||
if strings.TrimSpace(content) == "" || content == "Waiting for logs..." {
|
if strings.TrimSpace(content) == "" || content == "Waiting for logs..." {
|
||||||
@@ -89,34 +33,22 @@ func (m Model) viewLogsTab() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
lines := strings.Split(content, "\n")
|
lines := strings.Split(content, "\n")
|
||||||
var rendered []string
|
var colored []string
|
||||||
total := 0
|
|
||||||
shown := 0
|
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if strings.TrimSpace(line) == "" {
|
if line == "" {
|
||||||
|
colored = append(colored, line)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
total++
|
colored = append(colored, colorizeLog(line))
|
||||||
sev := classifyLog(line)
|
}
|
||||||
if m.logFilterImportant && !isImportantLog(sev) {
|
|
||||||
continue
|
count := 0
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.TrimSpace(l) != "" {
|
||||||
|
count++
|
||||||
}
|
}
|
||||||
shown++
|
|
||||||
rendered = append(rendered, renderLogLine(line))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
filterLabel := "All"
|
header := subtleStyle.Render(fmt.Sprintf(" %d entries [↑/↓] Scroll [PgUp/PgDn] Page", count))
|
||||||
if m.logFilterImportant {
|
return "\n" + header + "\n\n" + strings.Join(colored, "\n")
|
||||||
filterLabel = "Important"
|
|
||||||
}
|
|
||||||
|
|
||||||
header := subtleStyle.Render(fmt.Sprintf(
|
|
||||||
" %d entries [↑/↓] Scroll [PgUp/PgDn] Page [f] Filter: %s", shown, filterLabel))
|
|
||||||
|
|
||||||
if m.logFilterImportant && shown < total {
|
|
||||||
header += subtleStyle.Render(fmt.Sprintf(" (%d hidden)", total-shown))
|
|
||||||
}
|
|
||||||
|
|
||||||
return "\n" + header + "\n\n" + strings.Join(rendered, "\n")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -41,19 +40,19 @@ func fmtMaintType(t string) string {
|
|||||||
return maintStyle.Render("maintenance")
|
return maintStyle.Render("maintenance")
|
||||||
}
|
}
|
||||||
|
|
||||||
func fmtMaintMonitorW(monitorID int, sites []models.Site, maxW int) string {
|
func fmtMaintMonitor(monitorID int, sites []models.Site) string {
|
||||||
if monitorID == 0 {
|
if monitorID == 0 {
|
||||||
return "All"
|
return "All"
|
||||||
}
|
}
|
||||||
for _, s := range sites {
|
for _, s := range sites {
|
||||||
if s.ID == monitorID {
|
if s.ID == monitorID {
|
||||||
return limitStr(s.Name, maxW)
|
return limitStr(s.Name, 18)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("#%d", monitorID)
|
return fmt.Sprintf("#%d", monitorID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func fmtMaintTime(t time.Time, colW int) string {
|
func fmtMaintTime(t time.Time) string {
|
||||||
if t.IsZero() {
|
if t.IsZero() {
|
||||||
return subtleStyle.Render("—")
|
return subtleStyle.Render("—")
|
||||||
}
|
}
|
||||||
@@ -61,10 +60,7 @@ func fmtMaintTime(t time.Time, colW int) string {
|
|||||||
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
||||||
return t.Format("15:04")
|
return t.Format("15:04")
|
||||||
}
|
}
|
||||||
if colW >= 14 {
|
return t.Format("15:04 Jan 02")
|
||||||
return t.Format("15:04 Jan 02")
|
|
||||||
}
|
|
||||||
return t.Format("Jan 02")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) isMonitorInMaintenance(monitorID int) bool {
|
func (m Model) isMonitorInMaintenance(monitorID int) bool {
|
||||||
@@ -96,21 +92,8 @@ func (m Model) viewMaintTab() string {
|
|||||||
return "\n No maintenance windows or incidents. Press [n] to create one."
|
return "\n No maintenance windows or incidents. Press [n] to create one."
|
||||||
}
|
}
|
||||||
|
|
||||||
var headers []string
|
|
||||||
var widths []int
|
|
||||||
if m.isWide() {
|
|
||||||
headers = []string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"}
|
|
||||||
widths = []int{4, 24, 14, 22, 12, 16, 16}
|
|
||||||
} else {
|
|
||||||
headers = []string{"#", "TITLE", "TYPE", "MON", "ST", "START", "ENDS"}
|
|
||||||
widths = []int{4, 14, 13, 14, 11, 14, 14}
|
|
||||||
}
|
|
||||||
titleW := widths[1]
|
|
||||||
monW := widths[3]
|
|
||||||
timeW := widths[5]
|
|
||||||
|
|
||||||
return m.renderTable(
|
return m.renderTable(
|
||||||
headers,
|
[]string{"#", "TITLE", "TYPE", "MONITORS", "STATUS", "STARTED", "ENDS"},
|
||||||
len(m.maintenanceWindows),
|
len(m.maintenanceWindows),
|
||||||
func(start, end int) [][]string {
|
func(start, end int) [][]string {
|
||||||
var rows [][]string
|
var rows [][]string
|
||||||
@@ -119,17 +102,17 @@ func (m Model) viewMaintTab() string {
|
|||||||
mw := m.maintenanceWindows[i]
|
mw := m.maintenanceWindows[i]
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
strconv.Itoa(i + 1),
|
strconv.Itoa(i + 1),
|
||||||
m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, titleW-2)),
|
m.zones.Mark(fmt.Sprintf("maint-%d", i), limitStr(mw.Title, 24)),
|
||||||
fmtMaintType(mw.Type),
|
fmtMaintType(mw.Type),
|
||||||
fmtMaintMonitorW(mw.MonitorID, allSites, monW-2),
|
fmtMaintMonitor(mw.MonitorID, allSites),
|
||||||
fmtMaintStatus(mw),
|
fmtMaintStatus(mw),
|
||||||
fmtMaintTime(mw.StartTime, timeW),
|
fmtMaintTime(mw.StartTime),
|
||||||
fmtMaintTime(mw.EndTime, timeW),
|
fmtMaintTime(mw.EndTime),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
},
|
},
|
||||||
widths,
|
[]int{6, 0, 14, 20, 12, 16, 16},
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,25 +10,16 @@ func (m Model) viewNodesTab() string {
|
|||||||
return "\n No probe nodes connected."
|
return "\n No probe nodes connected."
|
||||||
}
|
}
|
||||||
|
|
||||||
var headers []string
|
colWidths := []int{0, 12, 20, 10, 8}
|
||||||
var widths []int
|
|
||||||
if m.isWide() {
|
|
||||||
headers = []string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"}
|
|
||||||
widths = []int{24, 14, 16, 12, 10}
|
|
||||||
} else {
|
|
||||||
headers = []string{"NAME", "REGION", "SEEN", "VER", "STATUS"}
|
|
||||||
widths = []int{16, 10, 10, 8, 8}
|
|
||||||
}
|
|
||||||
nameW := widths[0]
|
|
||||||
|
|
||||||
return m.renderTable(
|
return m.renderTable(
|
||||||
headers,
|
[]string{"NAME", "REGION", "LAST SEEN", "VERSION", "STATUS"},
|
||||||
len(m.nodes),
|
len(m.nodes),
|
||||||
func(start, end int) [][]string {
|
func(start, end int) [][]string {
|
||||||
var rows [][]string
|
var rows [][]string
|
||||||
for i := start; i < end; i++ {
|
for i := start; i < end; i++ {
|
||||||
node := m.nodes[i]
|
node := m.nodes[i]
|
||||||
name := limitStr(node.Name, nameW-2)
|
name := limitStr(node.Name, 20)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
name = node.ID
|
name = node.ID
|
||||||
}
|
}
|
||||||
@@ -46,7 +37,7 @@ func (m Model) viewNodesTab() string {
|
|||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
},
|
},
|
||||||
widths,
|
colWidths,
|
||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
@@ -60,18 +60,14 @@ type siteFormData struct {
|
|||||||
Regions string
|
Regions string
|
||||||
}
|
}
|
||||||
|
|
||||||
func latencySparkline(latencies []time.Duration, statuses []bool, width int) string {
|
func latencySparkline(latencies []time.Duration, width int) string {
|
||||||
if len(latencies) == 0 {
|
if len(latencies) == 0 {
|
||||||
return subtleStyle.Render(strings.Repeat("·", width))
|
return subtleStyle.Render(strings.Repeat("·", width))
|
||||||
}
|
}
|
||||||
|
|
||||||
samples := latencies
|
samples := latencies
|
||||||
sampledStatuses := statuses
|
|
||||||
if len(samples) > width {
|
if len(samples) > width {
|
||||||
samples = samples[len(samples)-width:]
|
samples = samples[len(samples)-width:]
|
||||||
if len(sampledStatuses) > width {
|
|
||||||
sampledStatuses = sampledStatuses[len(sampledStatuses)-width:]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
minL, maxL := samples[0], samples[0]
|
minL, maxL := samples[0], samples[0]
|
||||||
@@ -89,7 +85,7 @@ func latencySparkline(latencies []time.Duration, statuses []bool, width int) str
|
|||||||
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
sb.WriteString(subtleStyle.Render(strings.Repeat("·", remaining)))
|
||||||
}
|
}
|
||||||
spread := maxL - minL
|
spread := maxL - minL
|
||||||
for i, l := range samples {
|
for _, l := range samples {
|
||||||
idx := 0
|
idx := 0
|
||||||
if spread > 0 {
|
if spread > 0 {
|
||||||
idx = int(float64(l-minL) / float64(spread) * 7)
|
idx = int(float64(l-minL) / float64(spread) * 7)
|
||||||
@@ -98,18 +94,13 @@ func latencySparkline(latencies []time.Duration, statuses []bool, width int) str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ch := string(sparkChars[idx])
|
ch := string(sparkChars[idx])
|
||||||
isDown := i < len(sampledStatuses) && !sampledStatuses[i]
|
ms := l.Milliseconds()
|
||||||
if isDown {
|
if ms < 200 {
|
||||||
sb.WriteString(dangerStyle.Render(ch))
|
sb.WriteString(specialStyle.Render(ch))
|
||||||
|
} else if ms < 500 {
|
||||||
|
sb.WriteString(warnStyle.Render(ch))
|
||||||
} else {
|
} else {
|
||||||
ms := l.Milliseconds()
|
sb.WriteString(dangerStyle.Render(ch))
|
||||||
if ms < 200 {
|
|
||||||
sb.WriteString(specialStyle.Render(ch))
|
|
||||||
} else if ms < 500 {
|
|
||||||
sb.WriteString(warnStyle.Render(ch))
|
|
||||||
} else {
|
|
||||||
sb.WriteString(dangerStyle.Render(ch))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sb.String()
|
return sb.String()
|
||||||
@@ -311,8 +302,6 @@ func fmtStatus(status string, paused bool, inMaint bool) string {
|
|||||||
switch status {
|
switch status {
|
||||||
case "DOWN", "SSL EXP":
|
case "DOWN", "SSL EXP":
|
||||||
return dangerStyle.Render(status)
|
return dangerStyle.Render(status)
|
||||||
case "LATE":
|
|
||||||
return warnStyle.Render(status)
|
|
||||||
case "PENDING":
|
case "PENDING":
|
||||||
return subtleStyle.Render(status)
|
return subtleStyle.Render(status)
|
||||||
default:
|
default:
|
||||||
@@ -320,94 +309,28 @@ func fmtStatus(status string, paused bool, inMaint bool) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fmtDuration(d time.Duration) string {
|
func (m Model) dynamicWidths() (nameW, sparkW int) {
|
||||||
if d < time.Minute {
|
fixed := 6 + 10 + 10 + 8 + 8 + 7 + 9 // #, TYPE, STATUS, LATENCY, UPTIME, SSL, RETRY
|
||||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
overhead := 30 // cell padding + borders
|
||||||
}
|
avail := m.termWidth - chromePadH - 2 - fixed - overhead
|
||||||
if d < time.Hour {
|
if avail < 30 {
|
||||||
return fmt.Sprintf("%dm", int(d.Minutes()))
|
avail = 30
|
||||||
}
|
|
||||||
if d < 24*time.Hour {
|
|
||||||
h := int(d.Hours())
|
|
||||||
m := int(d.Minutes()) % 60
|
|
||||||
if m > 0 {
|
|
||||||
return fmt.Sprintf("%dh %dm", h, m)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dh", h)
|
|
||||||
}
|
|
||||||
days := int(d.Hours()) / 24
|
|
||||||
hours := int(d.Hours()) % 24
|
|
||||||
if hours > 0 {
|
|
||||||
return fmt.Sprintf("%dd %dh", days, hours)
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dd", days)
|
|
||||||
}
|
|
||||||
|
|
||||||
type tableLayout struct {
|
|
||||||
nameW, sparkW int
|
|
||||||
headers []string
|
|
||||||
colWidths []int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) computeLayout() tableLayout {
|
|
||||||
wide := m.isWide()
|
|
||||||
|
|
||||||
var fixed int
|
|
||||||
var headers []string
|
|
||||||
var widths []int
|
|
||||||
|
|
||||||
if wide {
|
|
||||||
// # NAME TYPE STATUS LATENCY UPTIME HISTORY SSL RETRIES
|
|
||||||
headers = []string{"#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRIES"}
|
|
||||||
widths = []int{4, 0, 10, 10, 10, 8, 0, 7, 9}
|
|
||||||
fixed = 4 + 10 + 10 + 10 + 8 + 7 + 9
|
|
||||||
} else {
|
|
||||||
// # NAME TYPE STATUS LAT UP% HISTORY SSL RT
|
|
||||||
headers = []string{"#", "NAME", "TYPE", "STATUS", "LAT", "UP%", "HISTORY", "SSL", "RT"}
|
|
||||||
widths = []int{4, 0, 8, 8, 7, 8, 0, 5, 5}
|
|
||||||
fixed = 4 + 8 + 8 + 7 + 8 + 5 + 5
|
|
||||||
}
|
|
||||||
|
|
||||||
numCols := len(headers)
|
|
||||||
borderOverhead := 2 + (numCols - 1)
|
|
||||||
avail := m.termWidth - chromePadH - 2 - borderOverhead - fixed
|
|
||||||
if avail < 20 {
|
|
||||||
avail = 20
|
|
||||||
}
|
|
||||||
|
|
||||||
maxName := 0
|
|
||||||
for _, s := range m.sites {
|
|
||||||
if n := len([]rune(s.Name)); n > maxName {
|
|
||||||
maxName = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
maxName += 4
|
|
||||||
|
|
||||||
nameW := avail / 2
|
|
||||||
if nameW > maxName {
|
|
||||||
nameW = maxName
|
|
||||||
}
|
}
|
||||||
|
nameW = avail / 2
|
||||||
|
sparkW = avail - nameW - 2 // -2 for spark column padding
|
||||||
if nameW < 13 {
|
if nameW < 13 {
|
||||||
nameW = 13
|
nameW = 13
|
||||||
}
|
}
|
||||||
if nameW > 40 {
|
if nameW > 40 {
|
||||||
nameW = 40
|
nameW = 40
|
||||||
}
|
}
|
||||||
|
|
||||||
sparkW := avail - nameW
|
|
||||||
if sparkW < 10 {
|
if sparkW < 10 {
|
||||||
sparkW = 10
|
sparkW = 10
|
||||||
}
|
}
|
||||||
|
if sparkW > 60 {
|
||||||
widths[1] = nameW
|
sparkW = 60
|
||||||
widths[6] = sparkW
|
|
||||||
|
|
||||||
return tableLayout{
|
|
||||||
nameW: nameW,
|
|
||||||
sparkW: sparkW,
|
|
||||||
headers: headers,
|
|
||||||
colWidths: widths,
|
|
||||||
}
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewSitesTab() string {
|
func (m Model) viewSitesTab() string {
|
||||||
@@ -425,16 +348,12 @@ func (m Model) viewSitesTab() string {
|
|||||||
return "\n" + welcome
|
return "\n" + welcome
|
||||||
}
|
}
|
||||||
|
|
||||||
layout := m.computeLayout()
|
nameW, sparkWidth := m.dynamicWidths()
|
||||||
nameW := layout.nameW
|
colWidths := []int{6, 0, 10, 10, 8, 8, sparkWidth + 2, 7, 9}
|
||||||
sparkWidth := layout.sparkW - 2
|
|
||||||
if sparkWidth < 8 {
|
|
||||||
sparkWidth = 8
|
|
||||||
}
|
|
||||||
|
|
||||||
var groupRows map[int]bool
|
var groupRows map[int]bool
|
||||||
return m.renderTable(
|
return m.renderTable(
|
||||||
layout.headers,
|
[]string{"#", "NAME", "TYPE", "STATUS", "LATENCY", "UPTIME", "HISTORY", "SSL", "RETRY"},
|
||||||
len(m.sites),
|
len(m.sites),
|
||||||
func(start, end int) [][]string {
|
func(start, end int) [][]string {
|
||||||
groupRows = make(map[int]bool)
|
groupRows = make(map[int]bool)
|
||||||
@@ -447,7 +366,7 @@ func (m Model) viewSitesTab() string {
|
|||||||
icon := typeIcon("group", m.collapsed[site.ID])
|
icon := typeIcon("group", m.collapsed[site.ID])
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
strconv.Itoa(i + 1),
|
strconv.Itoa(i + 1),
|
||||||
m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-4)),
|
m.zones.Mark(fmt.Sprintf("site-%d", i), icon+" "+limitStr(site.Name, nameW-2)),
|
||||||
"group",
|
"group",
|
||||||
fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)),
|
||||||
subtleStyle.Render("—"),
|
subtleStyle.Render("—"),
|
||||||
@@ -465,17 +384,9 @@ func (m Model) viewSitesTab() string {
|
|||||||
if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID {
|
if i+1 >= len(m.sites) || m.sites[i+1].ParentID != site.ParentID {
|
||||||
prefix = "└"
|
prefix = "└"
|
||||||
}
|
}
|
||||||
name = prefix + " " + limitStr(name, nameW-4)
|
name = prefix + " " + limitStr(name, nameW-2)
|
||||||
} else {
|
} else {
|
||||||
name = limitStr(name, nameW-2)
|
name = limitStr(name, nameW)
|
||||||
}
|
|
||||||
|
|
||||||
if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" {
|
|
||||||
nameLen := len([]rune(name))
|
|
||||||
errSpace := nameW - nameLen - 3
|
|
||||||
if errSpace > 10 {
|
|
||||||
name = name + " " + subtleStyle.Render(limitStr(site.LastError, errSpace))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
hist, _ := m.engine.GetHistory(site.ID)
|
hist, _ := m.engine.GetHistory(site.ID)
|
||||||
@@ -483,7 +394,7 @@ func (m Model) viewSitesTab() string {
|
|||||||
if site.Type == "push" {
|
if site.Type == "push" {
|
||||||
spark = heartbeatSparkline(hist.Statuses, sparkWidth)
|
spark = heartbeatSparkline(hist.Statuses, sparkWidth)
|
||||||
} else {
|
} else {
|
||||||
spark = latencySparkline(hist.Latencies, hist.Statuses, sparkWidth)
|
spark = latencySparkline(hist.Latencies, sparkWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
@@ -500,7 +411,7 @@ func (m Model) viewSitesTab() string {
|
|||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
},
|
},
|
||||||
layout.colWidths,
|
colWidths,
|
||||||
func(row, col int) *lipgloss.Style {
|
func(row, col int) *lipgloss.Style {
|
||||||
if groupRows[row] {
|
if groupRows[row] {
|
||||||
s := siteGroupStyle
|
s := siteGroupStyle
|
||||||
@@ -820,30 +731,7 @@ func (m Model) viewDetailPanel() string {
|
|||||||
fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value)
|
fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value)
|
||||||
}
|
}
|
||||||
|
|
||||||
section := func(label string) {
|
|
||||||
b.WriteString("\n" + subtleStyle.Render(" "+label) + "\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
|
row("Status", fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
|
||||||
|
|
||||||
if (site.Status == "DOWN" || site.Status == "SSL EXP" || site.Status == "LATE") && site.LastError != "" {
|
|
||||||
row("Error", dangerStyle.Render(limitStr(site.LastError, 60)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if site.Type == "http" && site.StatusCode > 0 {
|
|
||||||
row("HTTP Code", strconv.Itoa(site.StatusCode))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !site.StatusChangedAt.IsZero() {
|
|
||||||
dur := time.Since(site.StatusChangedAt)
|
|
||||||
row("State Since", site.StatusChangedAt.Format("2006-01-02 15:04:05")+" ("+fmtDuration(dur)+")")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !site.LastSuccessAt.IsZero() {
|
|
||||||
ago := time.Since(site.LastSuccessAt)
|
|
||||||
row("Last Success", site.LastSuccessAt.Format("15:04:05")+" ("+fmtDuration(ago)+" ago)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if m.isMonitorInMaintenance(site.ID) {
|
if m.isMonitorInMaintenance(site.ID) {
|
||||||
for _, mw := range m.maintenanceWindows {
|
for _, mw := range m.maintenanceWindows {
|
||||||
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
|
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
|
||||||
@@ -852,8 +740,6 @@ func (m Model) viewDetailPanel() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
section("ENDPOINT")
|
|
||||||
row("Type", site.Type)
|
row("Type", site.Type)
|
||||||
if site.URL != "" {
|
if site.URL != "" {
|
||||||
row("URL", site.URL)
|
row("URL", site.URL)
|
||||||
@@ -864,45 +750,31 @@ func (m Model) viewDetailPanel() string {
|
|||||||
if site.Port > 0 {
|
if site.Port > 0 {
|
||||||
row("Port", strconv.Itoa(site.Port))
|
row("Port", strconv.Itoa(site.Port))
|
||||||
}
|
}
|
||||||
|
|
||||||
section("TIMING")
|
|
||||||
row("Interval", fmt.Sprintf("%ds", site.Interval))
|
row("Interval", fmt.Sprintf("%ds", site.Interval))
|
||||||
if site.Timeout > 0 {
|
row("Timeout", fmt.Sprintf("%ds", site.Timeout))
|
||||||
row("Timeout", fmt.Sprintf("%ds", site.Timeout))
|
|
||||||
}
|
|
||||||
row("Latency", fmtLatency(site.Latency))
|
row("Latency", fmtLatency(site.Latency))
|
||||||
row("Uptime", fmtUptime(hist.Statuses))
|
row("Uptime", fmtUptime(hist.Statuses))
|
||||||
if !site.LastCheck.IsZero() {
|
|
||||||
row("Last Check", site.LastCheck.Format("15:04:05"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if site.Type == "http" {
|
if site.Type == "http" {
|
||||||
section("HTTP")
|
row("Method", site.Method)
|
||||||
if site.Method != "" && site.Method != "GET" {
|
row("Codes", site.AcceptedCodes)
|
||||||
row("Method", site.Method)
|
|
||||||
}
|
|
||||||
codes := site.AcceptedCodes
|
|
||||||
if codes == "" {
|
|
||||||
codes = "200-299"
|
|
||||||
}
|
|
||||||
row("Codes", codes)
|
|
||||||
row("SSL", fmtSSL(site))
|
row("SSL", fmtSSL(site))
|
||||||
if site.IgnoreTLS {
|
if site.IgnoreTLS {
|
||||||
row("TLS Verify", dangerStyle.Render("disabled"))
|
row("TLS Verify", dangerStyle.Render("disabled"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" {
|
if site.MaxRetries > 0 {
|
||||||
section("CONFIG")
|
row("Retries", fmtRetries(site))
|
||||||
if site.MaxRetries > 0 {
|
}
|
||||||
row("Retries", fmtRetries(site))
|
if site.Regions != "" {
|
||||||
}
|
row("Regions", site.Regions)
|
||||||
if site.Regions != "" {
|
}
|
||||||
row("Regions", site.Regions)
|
if site.Description != "" {
|
||||||
}
|
row("Description", site.Description)
|
||||||
if site.Description != "" {
|
}
|
||||||
row("Description", site.Description)
|
if !site.LastCheck.IsZero() {
|
||||||
}
|
row("Last Check", site.LastCheck.Format("15:04:05"))
|
||||||
}
|
}
|
||||||
|
|
||||||
probeResults := m.engine.GetProbeResults(site.ID)
|
probeResults := m.engine.GetProbeResults(site.ID)
|
||||||
@@ -915,30 +787,7 @@ func (m Model) viewDetailPanel() string {
|
|||||||
}
|
}
|
||||||
latency := time.Duration(result.LatencyNs).Milliseconds()
|
latency := time.Duration(result.LatencyNs).Milliseconds()
|
||||||
ago := time.Since(result.CheckedAt).Truncate(time.Second)
|
ago := time.Since(result.CheckedAt).Truncate(time.Second)
|
||||||
line := fmt.Sprintf(" %-14s %s %dms %s ago", nodeID, status, latency, ago)
|
fmt.Fprintf(&b, " %-14s %s %dms %s ago\n", nodeID, status, latency, ago)
|
||||||
if !result.IsUp && result.ErrorReason != "" {
|
|
||||||
line += " " + dangerStyle.Render(limitStr(result.ErrorReason, 30))
|
|
||||||
}
|
|
||||||
b.WriteString(line + "\n")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stateChanges := m.engine.GetStateChanges(site.ID, 5)
|
|
||||||
if len(stateChanges) > 0 {
|
|
||||||
b.WriteString("\n" + subtleStyle.Render(" STATE CHANGES") + "\n")
|
|
||||||
for _, sc := range stateChanges {
|
|
||||||
ago := fmtDuration(time.Since(sc.ChangedAt))
|
|
||||||
arrow := subtleStyle.Render(sc.FromStatus) + " → "
|
|
||||||
if sc.ToStatus == "UP" {
|
|
||||||
arrow += specialStyle.Render(sc.ToStatus)
|
|
||||||
} else {
|
|
||||||
arrow += dangerStyle.Render(sc.ToStatus)
|
|
||||||
}
|
|
||||||
line := fmt.Sprintf(" %s %s", arrow, subtleStyle.Render(ago+" ago"))
|
|
||||||
if sc.ErrorReason != "" && sc.ToStatus != "UP" {
|
|
||||||
line += " " + dangerStyle.Render(limitStr(sc.ErrorReason, 40))
|
|
||||||
}
|
|
||||||
b.WriteString(line + "\n")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,27 +807,20 @@ func (m Model) viewDetailPanel() string {
|
|||||||
up, len(hist.Statuses))
|
up, len(hist.Statuses))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
b.WriteString(" " + latencySparkline(hist.Latencies, hist.Statuses, sparkWidth))
|
b.WriteString(" " + latencySparkline(hist.Latencies, sparkWidth))
|
||||||
// Stats over successful checks only — a failed check is stored as 0ns latency
|
if len(hist.Latencies) > 0 {
|
||||||
// and would otherwise drag Min to 0ms and skew the average.
|
minL, maxL := hist.Latencies[0], hist.Latencies[0]
|
||||||
var minL, maxL, total time.Duration
|
var total time.Duration
|
||||||
count := 0
|
for _, l := range hist.Latencies {
|
||||||
for i, l := range hist.Latencies {
|
total += l
|
||||||
if i < len(hist.Statuses) && !hist.Statuses[i] {
|
if l < minL {
|
||||||
continue
|
minL = l
|
||||||
|
}
|
||||||
|
if l > maxL {
|
||||||
|
maxL = l
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if count == 0 {
|
avg := total / time.Duration(len(hist.Latencies))
|
||||||
minL, maxL = l, l
|
|
||||||
} else if l < minL {
|
|
||||||
minL = l
|
|
||||||
} else if l > maxL {
|
|
||||||
maxL = l
|
|
||||||
}
|
|
||||||
total += l
|
|
||||||
count++
|
|
||||||
}
|
|
||||||
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(),
|
||||||
|
|||||||
@@ -32,19 +32,8 @@ func (m Model) viewUsersTab() string {
|
|||||||
return "\n No users configured. Press [n] to add one."
|
return "\n No users configured. Press [n] to add one."
|
||||||
}
|
}
|
||||||
|
|
||||||
var headers []string
|
|
||||||
var widths []int
|
|
||||||
if m.isWide() {
|
|
||||||
headers = []string{"#", "USERNAME", "ROLE", "PUBLIC KEY"}
|
|
||||||
widths = []int{4, 18, 10, 50}
|
|
||||||
} else {
|
|
||||||
headers = []string{"#", "USER", "ROLE", "KEY"}
|
|
||||||
widths = []int{4, 14, 8, 30}
|
|
||||||
}
|
|
||||||
userW := widths[1]
|
|
||||||
|
|
||||||
return m.renderTable(
|
return m.renderTable(
|
||||||
headers,
|
[]string{"#", "USERNAME", "ROLE", "PUBLIC KEY"},
|
||||||
len(m.users),
|
len(m.users),
|
||||||
func(start, end int) [][]string {
|
func(start, end int) [][]string {
|
||||||
var rows [][]string
|
var rows [][]string
|
||||||
@@ -52,14 +41,14 @@ func (m Model) viewUsersTab() string {
|
|||||||
u := m.users[i]
|
u := m.users[i]
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
fmt.Sprintf("%d", i+1),
|
fmt.Sprintf("%d", i+1),
|
||||||
m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, userW-2)),
|
m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)),
|
||||||
fmtRole(u.Role),
|
fmtRole(u.Role),
|
||||||
fmtKey(u.PublicKey),
|
fmtKey(u.PublicKey),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
},
|
},
|
||||||
widths, nil,
|
nil, nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,6 @@ var (
|
|||||||
|
|
||||||
type StyleOverride func(row, col int) *lipgloss.Style
|
type StyleOverride func(row, col int) *lipgloss.Style
|
||||||
|
|
||||||
const wideBreakpoint = 120
|
|
||||||
|
|
||||||
func (m Model) isWide() bool {
|
|
||||||
return m.termWidth >= wideBreakpoint
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string {
|
func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string {
|
||||||
if items == 0 {
|
if items == 0 {
|
||||||
return ""
|
return ""
|
||||||
@@ -34,16 +28,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
|||||||
selectedVisual := m.cursor - m.tableOffset
|
selectedVisual := m.cursor - m.tableOffset
|
||||||
rows := buildRows(m.tableOffset, end)
|
rows := buildRows(m.tableOffset, end)
|
||||||
|
|
||||||
colTotal := 0
|
tableWidth := m.termWidth - chromePadH - 2
|
||||||
for _, w := range colWidths {
|
|
||||||
colTotal += w
|
|
||||||
}
|
|
||||||
borderOverhead := 2 + len(colWidths) - 1
|
|
||||||
tableWidth := colTotal + borderOverhead
|
|
||||||
maxWidth := m.termWidth - chromePadH - 2
|
|
||||||
if tableWidth > maxWidth {
|
|
||||||
tableWidth = maxWidth
|
|
||||||
}
|
|
||||||
if tableWidth < 40 {
|
if tableWidth < 40 {
|
||||||
tableWidth = 40
|
tableWidth = 40
|
||||||
}
|
}
|
||||||
@@ -56,11 +41,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
|||||||
Rows(rows...).
|
Rows(rows...).
|
||||||
StyleFunc(func(row, col int) lipgloss.Style {
|
StyleFunc(func(row, col int) lipgloss.Style {
|
||||||
if row == table.HeaderRow {
|
if row == table.HeaderRow {
|
||||||
h := tableHeaderStyle
|
return tableHeaderStyle
|
||||||
if col < len(colWidths) && colWidths[col] > 0 {
|
|
||||||
h = h.Width(colWidths[col]).MaxWidth(colWidths[col])
|
|
||||||
}
|
|
||||||
return h
|
|
||||||
}
|
}
|
||||||
isSelected := row == selectedVisual
|
isSelected := row == selectedVisual
|
||||||
if styleOverride != nil {
|
if styleOverride != nil {
|
||||||
@@ -70,7 +51,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
|||||||
style = tableSelectedStyle.Foreground(s.GetForeground())
|
style = tableSelectedStyle.Foreground(s.GetForeground())
|
||||||
}
|
}
|
||||||
if col < len(colWidths) && colWidths[col] > 0 {
|
if col < len(colWidths) && colWidths[col] > 0 {
|
||||||
style = style.Width(colWidths[col]).MaxWidth(colWidths[col])
|
style = style.Width(colWidths[col])
|
||||||
}
|
}
|
||||||
return style
|
return style
|
||||||
}
|
}
|
||||||
@@ -83,7 +64,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
|||||||
base = tableSelectedStyle
|
base = tableSelectedStyle
|
||||||
}
|
}
|
||||||
if col < len(colWidths) && colWidths[col] > 0 {
|
if col < len(colWidths) && colWidths[col] > 0 {
|
||||||
base = base.Width(colWidths[col]).MaxWidth(colWidths[col])
|
base = base.Width(colWidths[col])
|
||||||
}
|
}
|
||||||
return base
|
return base
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"gitea.lerkolabs.com/lerko/uptop/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
"gitea.lerkolabs.com/lerko/uptop/internal/monitor"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
"gitea.lerkolabs.com/lerko/uptop/internal/store"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -69,7 +68,6 @@ const (
|
|||||||
stateLogs
|
stateLogs
|
||||||
stateUsers
|
stateUsers
|
||||||
stateDetail
|
stateDetail
|
||||||
stateAlertDetail
|
|
||||||
stateFormSite
|
stateFormSite
|
||||||
stateFormAlert
|
stateFormAlert
|
||||||
stateFormUser
|
stateFormUser
|
||||||
@@ -94,10 +92,9 @@ type Model struct {
|
|||||||
userFormData *userFormData
|
userFormData *userFormData
|
||||||
maintFormData *maintFormData
|
maintFormData *maintFormData
|
||||||
|
|
||||||
logViewport viewport.Model
|
logViewport viewport.Model
|
||||||
logFilterImportant bool
|
isAdmin bool
|
||||||
isAdmin bool
|
zones *zone.Manager
|
||||||
zones *zone.Manager
|
|
||||||
|
|
||||||
deleteID int
|
deleteID int
|
||||||
deleteName string
|
deleteName string
|
||||||
@@ -123,10 +120,6 @@ 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 {
|
||||||
@@ -160,7 +153,6 @@ 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",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,14 +383,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
case stateAlertDetail:
|
|
||||||
switch msg.String() {
|
|
||||||
case "i", "esc":
|
|
||||||
m.state = stateDashboard
|
|
||||||
case "q":
|
|
||||||
return m, tea.Quit
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
case stateDashboard, stateLogs, stateUsers:
|
case stateDashboard, stateLogs, stateUsers:
|
||||||
switch msg.String() {
|
switch msg.String() {
|
||||||
case "q":
|
case "q":
|
||||||
@@ -408,11 +392,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.filterMode = true
|
m.filterMode = true
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
case "f":
|
|
||||||
if m.state == stateLogs {
|
|
||||||
m.logFilterImportant = !m.logFilterImportant
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
case "tab":
|
case "tab":
|
||||||
m.switchTab(m.currentTab + 1)
|
m.switchTab(m.currentTab + 1)
|
||||||
case "pgup", "pgdown":
|
case "pgup", "pgdown":
|
||||||
@@ -484,16 +463,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.state = stateFormUser
|
m.state = stateFormUser
|
||||||
return m, m.initUserHuhForm()
|
return m, m.initUserHuhForm()
|
||||||
}
|
}
|
||||||
case "t":
|
|
||||||
if m.currentTab == 1 && len(m.alerts) > 0 {
|
|
||||||
a := m.alerts[m.cursor]
|
|
||||||
go func() {
|
|
||||||
if err := m.engine.TestAlert(a.ID); err != nil {
|
|
||||||
m.engine.AddLog(fmt.Sprintf("Test alert failed (%s): %v", a.Name, err))
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
case " ":
|
case " ":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
|
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
|
||||||
gid := m.sites[m.cursor].ID
|
gid := m.sites[m.cursor].ID
|
||||||
@@ -512,8 +481,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case "i":
|
case "i":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
m.state = stateDetail
|
m.state = stateDetail
|
||||||
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
|
||||||
m.state = stateAlertDetail
|
|
||||||
}
|
}
|
||||||
case "x":
|
case "x":
|
||||||
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
||||||
@@ -760,6 +727,11 @@ 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") {
|
||||||
@@ -767,19 +739,6 @@ 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)
|
||||||
@@ -843,8 +802,6 @@ func (m Model) View() string {
|
|||||||
return ""
|
return ""
|
||||||
case stateDetail:
|
case stateDetail:
|
||||||
return m.viewDetailPanel()
|
return m.viewDetailPanel()
|
||||||
case stateAlertDetail:
|
|
||||||
return m.viewAlertDetailPanel()
|
|
||||||
default:
|
default:
|
||||||
return m.zones.Scan(m.viewDashboard())
|
return m.zones.Scan(m.viewDashboard())
|
||||||
}
|
}
|
||||||
@@ -854,20 +811,13 @@ func (m Model) viewDashboard() string {
|
|||||||
allSites := m.engine.GetAllSites()
|
allSites := m.engine.GetAllSites()
|
||||||
totalMonitors := 0
|
totalMonitors := 0
|
||||||
downCount := 0
|
downCount := 0
|
||||||
lateCount := 0
|
|
||||||
for _, s := range allSites {
|
for _, s := range allSites {
|
||||||
if s.Type == "group" {
|
if s.Type == "group" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
totalMonitors++
|
totalMonitors++
|
||||||
if s.Paused || m.isMonitorInMaintenance(s.ID) {
|
if !s.Paused && !m.isMonitorInMaintenance(s.ID) && (s.Status == "DOWN" || s.Status == "SSL EXP") {
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch s.Status {
|
|
||||||
case "DOWN", "SSL EXP":
|
|
||||||
downCount++
|
downCount++
|
||||||
case "LATE":
|
|
||||||
lateCount++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
offlineNodes := 0
|
offlineNodes := 0
|
||||||
@@ -880,8 +830,6 @@ func (m Model) viewDashboard() string {
|
|||||||
var sitesLabel string
|
var sitesLabel string
|
||||||
if downCount > 0 {
|
if downCount > 0 {
|
||||||
sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount)
|
sitesLabel = fmt.Sprintf("Sites (%d↓)", downCount)
|
||||||
} else if lateCount > 0 {
|
|
||||||
sitesLabel = fmt.Sprintf("Sites (%d⚠)", lateCount)
|
|
||||||
} else if totalMonitors > 0 {
|
} else if totalMonitors > 0 {
|
||||||
sitesLabel = fmt.Sprintf("Sites (%d)", totalMonitors)
|
sitesLabel = fmt.Sprintf("Sites (%d)", totalMonitors)
|
||||||
} else {
|
} else {
|
||||||
@@ -947,19 +895,14 @@ func (m Model) viewDashboard() string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
upCount := totalMonitors - downCount - lateCount
|
upCount := totalMonitors - downCount
|
||||||
var upStr string
|
var upStr string
|
||||||
if downCount > 0 {
|
if downCount > 0 {
|
||||||
upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
upStr = dangerStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
||||||
} else if lateCount > 0 {
|
|
||||||
upStr = warnStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
|
||||||
} else {
|
} else {
|
||||||
upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
upStr = specialStyle.Render(fmt.Sprintf("%d/%d UP", upCount, totalMonitors))
|
||||||
}
|
}
|
||||||
statusParts := []string{upStr}
|
statusParts := []string{upStr}
|
||||||
if lateCount > 0 {
|
|
||||||
statusParts = append(statusParts, warnStyle.Render(fmt.Sprintf("%d LATE", lateCount)))
|
|
||||||
}
|
|
||||||
if len(m.nodes) > 0 {
|
if len(m.nodes) > 0 {
|
||||||
online := 0
|
online := 0
|
||||||
for _, n := range m.nodes {
|
for _, n := range m.nodes {
|
||||||
@@ -967,11 +910,7 @@ func (m Model) viewDashboard() string {
|
|||||||
online++
|
online++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
probeLabel := "probes"
|
statusParts = append(statusParts, fmt.Sprintf("%d probes", online))
|
||||||
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(" · "))
|
||||||
|
|
||||||
@@ -984,10 +923,6 @@ func (m Model) viewDashboard() string {
|
|||||||
switch m.currentTab {
|
switch m.currentTab {
|
||||||
case 0:
|
case 0:
|
||||||
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit"
|
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit"
|
||||||
case 1:
|
|
||||||
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit"
|
|
||||||
case 2:
|
|
||||||
keys = "[f]Filter [T]Theme [Tab]Switch [q]Quit"
|
|
||||||
case 4:
|
case 4:
|
||||||
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
||||||
case 5:
|
case 5:
|
||||||
@@ -1014,12 +949,10 @@ func siteOrder(s models.Site) int {
|
|||||||
switch s.Status {
|
switch s.Status {
|
||||||
case "DOWN", "SSL EXP":
|
case "DOWN", "SSL EXP":
|
||||||
return 0
|
return 0
|
||||||
case "LATE":
|
|
||||||
return 1
|
|
||||||
case "PENDING":
|
case "PENDING":
|
||||||
return 3
|
|
||||||
default:
|
|
||||||
return 2
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 172 KiB |