1 Commits

Author SHA1 Message Date
lerko e36d9a7a26 chore: add TUI screenshots via VHS
CI / test (pull_request) Successful in 2m47s
CI / lint (pull_request) Successful in 1m21s
CI / vulncheck (pull_request) Successful in 57s
Monitors dashboard, detail panel, alerts, and logs views.
Generated with charmbracelet/vhs using real monitor data.
2026-05-27 19:03:03 -04:00
56 changed files with 378 additions and 1281 deletions
+9 -4
View File
@@ -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
+1 -8
View File
@@ -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
+5 -1
View File
@@ -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
@@ -37,3 +37,7 @@ tmp
*.local.json *.local.json
*.local.md *.local.md
# VHS — seed has personal URLs, tape is local workflow
vhs/seed.yaml
vhs/demo.tape
+2 -2
View File
@@ -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:
+30 -78
View File
@@ -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
+51 -82
View File
@@ -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
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

+8 -9
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
module gitea.lerkolabs.com/lerkolabs/uptop module gitea.lerkolabs.com/lerko/uptop
go 1.26.3 go 1.26.3
+1 -1
View File
@@ -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}
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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 {
+11 -17
View File
@@ -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 ---
+8 -10
View File
@@ -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 -2
View File
@@ -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"
) )
+2 -2
View File
@@ -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"
) )
+2 -2
View File
@@ -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 -1
View File
@@ -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"
) )
+1 -1
View File
@@ -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 -2
View File
@@ -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"
+11 -18
View File
@@ -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{
+8 -31
View File
@@ -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
+4 -5
View File
@@ -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) {
+16 -35
View File
@@ -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] + "..."
}
+1 -1
View File
@@ -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) {
+28 -216
View File
@@ -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
}
+27 -52
View File
@@ -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()
+9 -11
View File
@@ -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)
+11 -18
View File
@@ -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{
-1
View File
@@ -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 {
-21
View File
@@ -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) {
-21
View File
@@ -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
+1 -55
View File
@@ -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 -1
View File
@@ -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 -9
View File
@@ -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)
+5 -96
View File
@@ -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",
+21 -89
View File
@@ -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")
} }
+11 -28
View File
@@ -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,
) )
} }
+4 -13
View File
@@ -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,
) )
} }
+56 -214
View File
@@ -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(),
+3 -14
View File
@@ -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,
) )
} }
+4 -23
View File
@@ -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
}) })
+16 -83
View File
@@ -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
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB