Compare commits
1 Commits
main
..
d1bd66deb3
| Author | SHA1 | Date | |
|---|---|---|---|
| d1bd66deb3 |
@@ -1,10 +1,3 @@
|
|||||||
.git
|
.git
|
||||||
.ssh/
|
|
||||||
.gitea/
|
|
||||||
tmp/
|
tmp/
|
||||||
vendor/
|
vendor/
|
||||||
*.db
|
|
||||||
*.db-journal
|
|
||||||
*.local.json
|
|
||||||
*.local.md
|
|
||||||
*.local
|
|
||||||
|
|||||||
+4
-36
@@ -5,9 +5,6 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
env:
|
|
||||||
GO_VERSION: "1.26"
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -19,55 +16,26 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.24"
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/go/pkg/mod
|
|
||||||
~/.cache/go-build
|
|
||||||
key: go-${{ hashFiles('go.sum') }}
|
|
||||||
restore-keys: go-
|
|
||||||
|
|
||||||
- name: Install build tools
|
- name: Install build tools
|
||||||
run: apk add --no-cache gcc musl-dev
|
run: apk add --no-cache gcc musl-dev
|
||||||
|
|
||||||
- name: Download modules
|
- name: Vet
|
||||||
run: go mod download
|
run: go vet ./...
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: CGO_ENABLED=1 go test -race -timeout 120s ./...
|
run: CGO_ENABLED=1 go test -race -timeout 120s ./...
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: sh
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.24"
|
||||||
|
|
||||||
- uses: golangci/golangci-lint-action@v7
|
- uses: golangci/golangci-lint-action@v7
|
||||||
with:
|
with:
|
||||||
version: v2.11.2
|
version: v2.11.2
|
||||||
|
|
||||||
vulncheck:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: sh
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.26"
|
|
||||||
|
|
||||||
- name: Install govulncheck
|
|
||||||
run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
|
||||||
|
|
||||||
- name: Run govulncheck
|
|
||||||
run: govulncheck ./...
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
name: Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "[0-9]*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
release:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
shell: sh
|
|
||||||
steps:
|
|
||||||
- name: Install build tools
|
|
||||||
run: apk add --no-cache git gcc musl-dev
|
|
||||||
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- uses: actions/setup-go@v5
|
|
||||||
with:
|
|
||||||
go-version: "1.26"
|
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
~/go/pkg/mod
|
|
||||||
~/.cache/go-build
|
|
||||||
key: release-go-${{ hashFiles('go.sum') }}
|
|
||||||
restore-keys: release-go-
|
|
||||||
|
|
||||||
- name: Run GoReleaser
|
|
||||||
uses: goreleaser/goreleaser-action@v7
|
|
||||||
with:
|
|
||||||
distribution: goreleaser
|
|
||||||
version: "~> v2"
|
|
||||||
args: release --clean
|
|
||||||
env:
|
|
||||||
GORELEASER_FORCE_TOKEN: gitea
|
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
|
||||||
GITEA_API_URL: http://gitea:3000/api/v1
|
|
||||||
|
|
||||||
docker:
|
|
||||||
runs-on: docker-builder
|
|
||||||
needs: [release]
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
push: true
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
tags: |
|
|
||||||
lerkolabs/uptop:${{ github.ref_name }}
|
|
||||||
lerkolabs/uptop:latest
|
|
||||||
build-args: |
|
|
||||||
VERSION=${{ github.ref_name }}
|
|
||||||
COMMIT=${{ github.sha }}
|
|
||||||
BUILD_DATE=${{ github.event.head_commit.timestamp }}
|
|
||||||
|
|
||||||
- name: Update Docker Hub description
|
|
||||||
uses: peter-evans/dockerhub-description@v4
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
repository: lerkolabs/uptop
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Publish Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push_to_registry:
|
||||||
|
name: Build and Push Docker Image
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Extract metadata (tags, labels)
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ${{ secrets.DOCKERHUB_USERNAME }}/go-upkeep
|
||||||
|
tags: |
|
||||||
|
# This turns git tag "v1.0.0" into docker tag "1.0.0"
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
# This updates the "latest" tag to this version
|
||||||
|
type=raw,value=latest
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
+5
-2
@@ -26,8 +26,8 @@ go.work
|
|||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/go
|
# End of https://www.toptal.com/developers/gitignore/api/go
|
||||||
|
|
||||||
/uptop
|
/goupkeep
|
||||||
uptop.db*
|
upkeep.db
|
||||||
|
|
||||||
.ssh
|
.ssh
|
||||||
|
|
||||||
@@ -35,5 +35,8 @@ authorized_keys
|
|||||||
|
|
||||||
tmp
|
tmp
|
||||||
|
|
||||||
|
# Old repo
|
||||||
|
/go-upkeep/
|
||||||
|
|
||||||
*.local.json
|
*.local.json
|
||||||
*.local.md
|
*.local.md
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
version: 2
|
|
||||||
|
|
||||||
gitea_urls:
|
|
||||||
api: "{{ if index .Env \"GITEA_API_URL\" }}{{ .Env.GITEA_API_URL }}{{ else }}https://gitea.lerkolabs.com/api/v1{{ end }}"
|
|
||||||
download: https://gitea.lerkolabs.com
|
|
||||||
|
|
||||||
release:
|
|
||||||
gitea:
|
|
||||||
owner: lerkolabs
|
|
||||||
name: uptop
|
|
||||||
|
|
||||||
builds:
|
|
||||||
- main: ./cmd/uptop/main.go
|
|
||||||
binary: uptop
|
|
||||||
env:
|
|
||||||
- CGO_ENABLED=1
|
|
||||||
goos:
|
|
||||||
- linux
|
|
||||||
goarch:
|
|
||||||
- amd64
|
|
||||||
ldflags:
|
|
||||||
- -s -w
|
|
||||||
- -X main.version={{ .Version }}
|
|
||||||
- -X main.commit={{ .Commit }}
|
|
||||||
- -X main.date={{ .Date }}
|
|
||||||
flags:
|
|
||||||
- -trimpath
|
|
||||||
|
|
||||||
archives:
|
|
||||||
- formats: [tar.gz]
|
|
||||||
name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}"
|
|
||||||
|
|
||||||
checksum:
|
|
||||||
name_template: checksums.txt
|
|
||||||
|
|
||||||
changelog:
|
|
||||||
sort: asc
|
|
||||||
filters:
|
|
||||||
exclude:
|
|
||||||
- "^docs:"
|
|
||||||
- "^chore:"
|
|
||||||
- "^style:"
|
|
||||||
+30
-78
@@ -1,94 +1,46 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [2026.05.5] — 2026-05-29
|
## [2026.05.2] — 2026-05-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Error reason display when monitors go DOWN (#33)
|
- Comprehensive test suite (94 tests across monitor, server, cluster)
|
||||||
- Push monitor lifecycle — PENDING, LATE, DOWN states (#34)
|
- golangci-lint config with CI enforcement
|
||||||
- Logs tab overhaul — severity tags, filtering, recovery durations (#35)
|
- Gitea Actions CI pipeline (test + lint)
|
||||||
- Alert channel health indicator and test alerts (#36)
|
- Graceful shutdown for HTTP and SSH servers
|
||||||
- TUI screenshots in `assets/` (#32)
|
- Context-aware alert delivery with timeout
|
||||||
- CI status badge in README
|
- Request size limits on all POST endpoints
|
||||||
|
- Constant-time secret comparison
|
||||||
|
- Check interval jitter to prevent thundering herd
|
||||||
|
- `--version` flag with build metadata injection
|
||||||
|
|
||||||
### Changed
|
### Fixed
|
||||||
- Visual polish — detail sections, column headers, alert detail (#37)
|
- Silent JSON unmarshal failures in alert settings
|
||||||
- README rewritten with hero image, badges, collapsible install sections (#32)
|
- Panic on crypto/rand failure replaced with error return
|
||||||
- Changelog rewritten to match actual CalVer tag history
|
- Alert delivery errors now logged instead of swallowed
|
||||||
- Migrated to `lerkolabs` org namespace (#38)
|
- log.Fatalf in goroutines replaced with log.Printf
|
||||||
- Docker-compose files moved to `deploy/`
|
- Deprecated LineUp/LineDown API calls
|
||||||
|
|
||||||
## [2026.05.4] — 2026-05-27
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- SSH user seeding from `UPTOP_ADMIN_KEY` env var and `UPTOP_KEYS` file (#31)
|
|
||||||
- GoReleaser for binary releases
|
|
||||||
- govulncheck in CI pipeline
|
|
||||||
- Multi-arch Docker builds (amd64 + arm64)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- CI overhaul — Go 1.26, build caching, streamlined pipeline (#30)
|
|
||||||
- Bumped golang.org/x/crypto v0.47.0 → v0.52.0
|
|
||||||
- Bumped Alpine 3.21 → 3.23
|
|
||||||
|
|
||||||
### Security
|
### Security
|
||||||
- Phase 1: SSRF protection, input validation, safe dial (#26)
|
- Cluster secret compared with crypto/subtle (timing-safe)
|
||||||
- Phase 2: TLS hardening, auth bypass fixes, rate limiting (#27)
|
- http.MaxBytesReader on all JSON endpoints
|
||||||
- Phase 3: Graceful degradation, connection limits, timeout enforcement (#28)
|
- ReadHeaderTimeout added to HTTP server
|
||||||
- Phase 4: Code quality, error handling, linter fixes (#29)
|
|
||||||
|
|
||||||
## [2026.05.3] — 2026-05-25
|
## [2026.05.1] — 2026-05-14
|
||||||
|
|
||||||
### Added
|
|
||||||
- Theme system with 5 dark palettes — Default, Dracula, Nord, Tokyo Night, Gruvbox (#24)
|
|
||||||
- `--version` flag with build metadata injection
|
|
||||||
- Gitea Actions CI pipeline — test + lint (#20)
|
|
||||||
- golangci-lint configuration
|
|
||||||
- Comprehensive test suite — 94 tests across monitor, server, cluster (#19)
|
|
||||||
- CONTRIBUTING.md and SECURITY.md
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Renamed project from go-upkeep to uptop (#25)
|
|
||||||
- Updated LICENSE with dual copyright for independent fork
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Form validators scoped to relevant monitor types (#23)
|
|
||||||
- Graceful shutdown for HTTP, SSH servers and database (#19)
|
|
||||||
- Constant-time secret comparison, request size limits (#19)
|
|
||||||
- Check interval jitter to prevent thundering herd (#19)
|
|
||||||
- TUI visual polish — zebra striping, group icons, sparkline stats (#18)
|
|
||||||
|
|
||||||
## [2026.05.2] — 2026-05-22
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Incident management and maintenance windows (#17)
|
|
||||||
- Production docker-compose.yml
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Viewport sizing and dynamic chrome calculation (#16)
|
|
||||||
- Form height constrained to terminal with resize forwarding
|
|
||||||
- Maintenance'd monitors excluded from down count and pulse
|
|
||||||
- Group status correctly skips children in maintenance
|
|
||||||
|
|
||||||
## [2026.05.1] — 2026-05-16
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Distributed probing with leader + probe nodes
|
- Distributed probing with leader + probe nodes
|
||||||
- Config-as-code — YAML apply/export with dry-run and prune
|
- Config-as-code (YAML apply/export with dry-run, prune)
|
||||||
- TUI polish — status bar, tab badges, detail panel, modals
|
- TUI visual polish (zebra striping, sparklines, breadcrumbs)
|
||||||
- DOWN-first sort, health pulse, site filter
|
- Incident management and maintenance windows
|
||||||
- Type icons in sites table
|
- 9 alert providers (Discord, Slack, Email, Ntfy, Telegram, PagerDuty, Pushover, Gotify, Webhook)
|
||||||
- Sparkline history graphs
|
|
||||||
- Persistent state — uptime, status, latency, and logs survive restarts
|
|
||||||
- Push token stripping from /status/json response
|
|
||||||
|
|
||||||
## [2026.04.1] — 2026-04-01
|
## [2026.04.1] — Initial independent fork
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- SSH-accessible TUI built on Bubble Tea + Wish
|
- SSH-accessible TUI (Bubble Tea + Wish)
|
||||||
- 6 check types — HTTP, Push, Ping, Port, DNS, Group
|
- 6 check types (HTTP, Push, Ping, Port, DNS, Group)
|
||||||
- 9 alert providers — Discord, Slack, Email, Ntfy, Telegram, PagerDuty, Pushover, Gotify, Webhook
|
|
||||||
- SQLite and PostgreSQL support
|
- SQLite and PostgreSQL support
|
||||||
- HA clustering with automatic failover
|
- HA clustering with automatic failover
|
||||||
- Prometheus /metrics endpoint
|
- Prometheus metrics endpoint
|
||||||
- Public status page (HTML + JSON)
|
- Public status page
|
||||||
- Uptime Kuma backup import
|
- Uptime Kuma import
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
## Development
|
## Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run cmd/uptop/main.go -demo # starts with sample data
|
go run cmd/goupkeep/main.go -demo # starts with sample data
|
||||||
ssh -p 23234 localhost # connect to TUI
|
ssh -p 23234 localhost # connect to TUI
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+11
-14
@@ -1,34 +1,31 @@
|
|||||||
# --- Stage 1: Builder ---
|
# --- Stage 1: Builder ---
|
||||||
FROM golang:1.26-alpine3.23 AS builder
|
FROM golang:alpine AS builder
|
||||||
RUN apk add --no-cache gcc musl-dev
|
RUN apk add --no-cache gcc musl-dev
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
RUN go mod download
|
||||||
go mod download
|
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV CGO_ENABLED=1
|
ENV CGO_ENABLED=1
|
||||||
ARG VERSION=dev
|
ARG VERSION=dev
|
||||||
ARG COMMIT=none
|
ARG COMMIT=none
|
||||||
ARG BUILD_DATE=unknown
|
ARG BUILD_DATE=unknown
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
RUN go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o go-upkeep ./cmd/goupkeep/main.go
|
||||||
--mount=type=cache,target=/root/.cache/go-build \
|
|
||||||
go build -trimpath -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} -X main.date=${BUILD_DATE}" -o uptop ./cmd/uptop/main.go
|
|
||||||
|
|
||||||
# --- Stage 2: Runner ---
|
# --- Stage 2: Runner ---
|
||||||
FROM alpine:3.23
|
FROM alpine:latest
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache ca-certificates && apk upgrade --no-cache
|
RUN apk add --no-cache ca-certificates openssh-client
|
||||||
RUN mkdir /data
|
RUN mkdir /data
|
||||||
|
|
||||||
COPY --from=builder /app/uptop .
|
COPY --from=builder /app/go-upkeep .
|
||||||
|
|
||||||
# Set Default Configuration via ENV
|
# Set Default Configuration via ENV
|
||||||
# Docker users can override these in docker-compose.yml
|
# Docker users can override these in docker-compose.yml
|
||||||
ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true
|
ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true
|
||||||
ENV UPTOP_DB_TYPE=sqlite
|
ENV UPKEEP_DB_TYPE=sqlite
|
||||||
ENV UPTOP_DB_DSN=/data/uptop.db
|
ENV UPKEEP_DB_DSN=/data/upkeep.db
|
||||||
ENV UPTOP_KEYS=/data/authorized_keys
|
ENV UPKEEP_KEYS=/data/authorized_keys
|
||||||
ENV UPTOP_PORT=23234
|
ENV UPKEEP_PORT=23234
|
||||||
|
|
||||||
EXPOSE 23234
|
EXPOSE 23234
|
||||||
CMD ["./uptop"]
|
CMD ["./go-upkeep"]
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2026 Roman Dvořák
|
Copyright (c) 2024 Roman Dvořák
|
||||||
Copyright (c) 2026 Tyler Koenig
|
Copyright (c) 2026 Tyler Koenig
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
|||||||
@@ -1,139 +1,108 @@
|
|||||||
<div align="center">
|
# Go-Upkeep
|
||||||
<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
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run cmd/uptop/main.go
|
go run cmd/goupkeep/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/goupkeep/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/uptime/cmd/goupkeep@latest
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker pull lerko/go-upkeep:latest
|
||||||
|
docker run -p 23234:23234 -p 8080:8080 -v ./data:/data lerko/go-upkeep
|
||||||
|
```
|
||||||
|
|
||||||
|
### Binary
|
||||||
|
|
||||||
|
Download from [Releases](https://gitea.lerkolabs.com/lerko/uptime/releases).
|
||||||
|
|
||||||
## Config as code
|
## Config as code
|
||||||
|
|
||||||
Export your current monitors:
|
Export your current monitors:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uptop export -o monitors.yaml
|
goupkeep export -o monitors.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
Apply a config file:
|
Apply a config file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uptop apply -f monitors.yaml
|
goupkeep apply -f monitors.yaml
|
||||||
uptop apply -f monitors.yaml --dry-run # see what would change
|
goupkeep apply -f monitors.yaml --dry-run # see what would change
|
||||||
uptop apply -f monitors.yaml --prune # delete anything not in the YAML
|
goupkeep 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:
|
||||||
|
- UPKEEP_DB_TYPE=sqlite
|
||||||
|
- UPKEEP_DB_DSN=/data/upkeep.db
|
||||||
|
- UPKEEP_STATUS_ENABLED=true
|
||||||
|
- UPKEEP_CLUSTER_SECRET=change-me
|
||||||
|
```
|
||||||
|
|
||||||
|
First run: attach to the container (`docker attach go-upkeep`), 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 |
|
| `UPKEEP_PORT` | `23234` | SSH server port |
|
||||||
| `UPTOP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
|
| `UPKEEP_HTTP_PORT` | `8080` | HTTP server port (status page, push, metrics) |
|
||||||
| `UPTOP_DB_TYPE` | `sqlite` | `sqlite` or `postgres` |
|
| `UPKEEP_DB_TYPE` | `sqlite` | `sqlite` or `postgres` |
|
||||||
| `UPTOP_DB_DSN` | `uptop.db` | Database path or connection string |
|
| `UPKEEP_DB_DSN` | `upkeep.db` | Database path or connection string |
|
||||||
| `UPTOP_STATUS_ENABLED` | `false` | Enable public status page |
|
| `UPKEEP_STATUS_ENABLED` | `false` | Enable public status page |
|
||||||
| `UPTOP_STATUS_TITLE` | `System Status` | Status page title |
|
| `UPKEEP_STATUS_TITLE` | `System Status` | Status page title |
|
||||||
| `UPTOP_CLUSTER_MODE` | `leader` | `leader` or `follower` |
|
| `UPKEEP_CLUSTER_MODE` | `leader` | `leader` or `follower` |
|
||||||
| `UPTOP_PEER_URL` | | Leader URL for follower nodes |
|
| `UPKEEP_PEER_URL` | | Leader URL for follower nodes |
|
||||||
| `UPTOP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
|
| `UPKEEP_CLUSTER_SECRET` | | Shared key for cluster + API auth |
|
||||||
| `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
|
| `UPKEEP_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
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 84 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 78 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 206 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 232 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 253 KiB |
@@ -1,31 +1,25 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/internal/cluster"
|
||||||
|
"go-upkeep/internal/config"
|
||||||
|
"go-upkeep/internal/importer"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
|
"go-upkeep/internal/monitor"
|
||||||
|
"go-upkeep/internal/server"
|
||||||
|
"go-upkeep/internal/store"
|
||||||
|
"go-upkeep/internal/tui"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/cluster"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/config"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/importer"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/server"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/tui"
|
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/ssh"
|
"github.com/charmbracelet/ssh"
|
||||||
"github.com/charmbracelet/wish"
|
"github.com/charmbracelet/wish"
|
||||||
@@ -53,9 +47,6 @@ func main() {
|
|||||||
case "version", "--version", "-v":
|
case "version", "--version", "-v":
|
||||||
printVersion()
|
printVersion()
|
||||||
return
|
return
|
||||||
case "migrate-secrets":
|
|
||||||
runMigrateSecrets(os.Args[2:])
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
runServe(os.Args[1:])
|
runServe(os.Args[1:])
|
||||||
@@ -63,9 +54,9 @@ func main() {
|
|||||||
|
|
||||||
func printVersion() {
|
func printVersion() {
|
||||||
if version == "dev" {
|
if version == "dev" {
|
||||||
fmt.Println("uptop dev")
|
fmt.Println("go-upkeep dev")
|
||||||
} else {
|
} else {
|
||||||
fmt.Printf("uptop %s (%s, %s)\n", version, commit, date)
|
fmt.Printf("go-upkeep %s (%s, %s)\n", version, commit, date)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,42 +67,23 @@ func envOrDefault(key, fallback string) string {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
func redactDSN(dsn string) string {
|
|
||||||
u, err := url.Parse(dsn)
|
|
||||||
if err != nil {
|
|
||||||
return "***"
|
|
||||||
}
|
|
||||||
u.User = nil
|
|
||||||
return u.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
func openStore(dbType, dsn string) store.Store {
|
func openStore(dbType, dsn string) store.Store {
|
||||||
var ss *store.SQLStore
|
var s store.Store
|
||||||
var err error
|
var err error
|
||||||
if dbType == "postgres" {
|
if dbType == "postgres" {
|
||||||
ss, err = store.NewPostgresStore(dsn)
|
s, err = store.NewPostgresStore(dsn)
|
||||||
} else {
|
} else {
|
||||||
ss, err = store.NewSQLiteStore(dsn)
|
s, err = store.NewSQLiteStore(dsn)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "database error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "database error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" {
|
if err := s.Init(); err != nil {
|
||||||
enc, err := store.NewEncryptor(encKey)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "encryption key error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
ss.SetEncryptor(enc)
|
|
||||||
} else {
|
|
||||||
fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.")
|
|
||||||
}
|
|
||||||
if err := ss.Init(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return ss
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
func runApply(args []string) {
|
func runApply(args []string) {
|
||||||
@@ -119,8 +91,8 @@ func runApply(args []string) {
|
|||||||
filePath := fs.String("f", "", "Path to YAML config file (required)")
|
filePath := fs.String("f", "", "Path to YAML config file (required)")
|
||||||
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
|
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
|
||||||
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
|
prune := fs.Bool("prune", false, "Delete monitors/alerts not in YAML")
|
||||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
|
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
|
||||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN")
|
||||||
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
||||||
|
|
||||||
if *filePath == "" {
|
if *filePath == "" {
|
||||||
@@ -152,8 +124,8 @@ func runApply(args []string) {
|
|||||||
func runExport(args []string) {
|
func runExport(args []string) {
|
||||||
fs := flag.NewFlagSet("export", flag.ExitOnError)
|
fs := flag.NewFlagSet("export", flag.ExitOnError)
|
||||||
outPath := fs.String("o", "-", "Output file path (- for stdout)")
|
outPath := fs.String("o", "-", "Output file path (- for stdout)")
|
||||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
|
dbType := fs.String("db-type", envOrDefault("UPKEEP_DB_TYPE", "sqlite"), "Database type")
|
||||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
dsn := fs.String("dsn", envOrDefault("UPKEEP_DB_DSN", "upkeep.db"), "Database DSN")
|
||||||
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
||||||
|
|
||||||
s := openStore(*dbType, *dsn)
|
s := openStore(*dbType, *dsn)
|
||||||
@@ -170,60 +142,10 @@ func runExport(args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func runMigrateSecrets(args []string) {
|
|
||||||
fs := flag.NewFlagSet("migrate-secrets", flag.ExitOnError)
|
|
||||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type")
|
|
||||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
|
||||||
_ = fs.Parse(args)
|
|
||||||
|
|
||||||
encKey := os.Getenv("UPTOP_ENCRYPTION_KEY")
|
|
||||||
if encKey == "" {
|
|
||||||
fmt.Fprintln(os.Stderr, "error: UPTOP_ENCRYPTION_KEY must be set")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
enc, err := store.NewEncryptor(encKey)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
var ss *store.SQLStore
|
|
||||||
if *dbType == "postgres" {
|
|
||||||
ss, err = store.NewPostgresStore(*dsn)
|
|
||||||
} else {
|
|
||||||
ss, err = store.NewSQLiteStore(*dsn)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "database error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err := ss.Init(); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
alerts, err := ss.GetAllAlerts()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error loading alerts: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
ss.SetEncryptor(enc)
|
|
||||||
migrated := 0
|
|
||||||
for _, a := range alerts {
|
|
||||||
if err := ss.UpdateAlert(a.ID, a.Name, a.Type, a.Settings); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "error migrating alert %q: %v\n", a.Name, err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
migrated++
|
|
||||||
}
|
|
||||||
fmt.Printf("Migrated %d alert(s) to encrypted storage.\n", migrated)
|
|
||||||
}
|
|
||||||
|
|
||||||
func runServe(args []string) {
|
func runServe(args []string) {
|
||||||
portVal := 23234
|
portVal := 23234
|
||||||
dbType := "sqlite"
|
dbType := "sqlite"
|
||||||
dbDSN := "uptop.db"
|
dbDSN := "upkeep.db"
|
||||||
httpPort := 8080
|
httpPort := 8080
|
||||||
enableStatus := false
|
enableStatus := false
|
||||||
statusTitle := "System Status"
|
statusTitle := "System Status"
|
||||||
@@ -231,50 +153,50 @@ func runServe(args []string) {
|
|||||||
clusterPeer := ""
|
clusterPeer := ""
|
||||||
clusterKey := ""
|
clusterKey := ""
|
||||||
|
|
||||||
if v := os.Getenv("UPTOP_PORT"); v != "" {
|
if v := os.Getenv("UPKEEP_PORT"); v != "" {
|
||||||
if p, err := strconv.Atoi(v); err == nil {
|
if p, err := strconv.Atoi(v); err == nil {
|
||||||
portVal = p
|
portVal = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v := os.Getenv("UPTOP_DB_TYPE"); v != "" {
|
if v := os.Getenv("UPKEEP_DB_TYPE"); v != "" {
|
||||||
dbType = v
|
dbType = v
|
||||||
}
|
}
|
||||||
if v := os.Getenv("UPTOP_DB_DSN"); v != "" {
|
if v := os.Getenv("UPKEEP_DB_DSN"); v != "" {
|
||||||
dbDSN = v
|
dbDSN = v
|
||||||
}
|
}
|
||||||
if v := os.Getenv("UPTOP_HTTP_PORT"); v != "" {
|
if v := os.Getenv("UPKEEP_HTTP_PORT"); v != "" {
|
||||||
if p, err := strconv.Atoi(v); err == nil {
|
if p, err := strconv.Atoi(v); err == nil {
|
||||||
httpPort = p
|
httpPort = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if v := os.Getenv("UPTOP_STATUS_ENABLED"); v == "true" {
|
if v := os.Getenv("UPKEEP_STATUS_ENABLED"); v == "true" {
|
||||||
enableStatus = true
|
enableStatus = true
|
||||||
}
|
}
|
||||||
if v := os.Getenv("UPTOP_STATUS_TITLE"); v != "" {
|
if v := os.Getenv("UPKEEP_STATUS_TITLE"); v != "" {
|
||||||
statusTitle = v
|
statusTitle = v
|
||||||
}
|
}
|
||||||
if v := os.Getenv("UPTOP_CLUSTER_MODE"); v != "" {
|
if v := os.Getenv("UPKEEP_CLUSTER_MODE"); v != "" {
|
||||||
clusterMode = v
|
clusterMode = v
|
||||||
}
|
}
|
||||||
if v := os.Getenv("UPTOP_PEER_URL"); v != "" {
|
if v := os.Getenv("UPKEEP_PEER_URL"); v != "" {
|
||||||
clusterPeer = v
|
clusterPeer = v
|
||||||
}
|
}
|
||||||
if v := os.Getenv("UPTOP_CLUSTER_SECRET"); v != "" {
|
if v := os.Getenv("UPKEEP_CLUSTER_SECRET"); v != "" {
|
||||||
clusterKey = v
|
clusterKey = v
|
||||||
}
|
}
|
||||||
|
|
||||||
nodeID := os.Getenv("UPTOP_NODE_ID")
|
nodeID := os.Getenv("UPKEEP_NODE_ID")
|
||||||
nodeName := os.Getenv("UPTOP_NODE_NAME")
|
nodeName := os.Getenv("UPKEEP_NODE_NAME")
|
||||||
nodeRegion := os.Getenv("UPTOP_NODE_REGION")
|
nodeRegion := os.Getenv("UPKEEP_NODE_REGION")
|
||||||
aggStrategy := os.Getenv("UPTOP_AGG_STRATEGY")
|
aggStrategy := os.Getenv("UPKEEP_AGG_STRATEGY")
|
||||||
|
|
||||||
if clusterMode == "probe" {
|
if clusterMode == "probe" {
|
||||||
if nodeID == "" {
|
if nodeID == "" {
|
||||||
fmt.Fprintln(os.Stderr, "UPTOP_NODE_ID is required for probe mode")
|
fmt.Fprintln(os.Stderr, "UPKEEP_NODE_ID is required for probe mode")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if clusterPeer == "" {
|
if clusterPeer == "" {
|
||||||
fmt.Fprintln(os.Stderr, "UPTOP_PEER_URL is required for probe mode")
|
fmt.Fprintln(os.Stderr, "UPKEEP_PEER_URL is required for probe mode")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,11 +211,6 @@ func runServe(args []string) {
|
|||||||
cancel()
|
cancel()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
probeAllowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
|
|
||||||
if probeAllowPrivate {
|
|
||||||
fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := cluster.RunProbe(ctx, cluster.ProbeConfig{
|
if err := cluster.RunProbe(ctx, cluster.ProbeConfig{
|
||||||
NodeID: nodeID,
|
NodeID: nodeID,
|
||||||
NodeName: nodeName,
|
NodeName: nodeName,
|
||||||
@@ -301,7 +218,6 @@ func runServe(args []string) {
|
|||||||
LeaderURL: clusterPeer,
|
LeaderURL: clusterPeer,
|
||||||
SharedKey: clusterKey,
|
SharedKey: clusterKey,
|
||||||
Interval: 30,
|
Interval: 30,
|
||||||
AllowPrivateTargets: probeAllowPrivate,
|
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Probe error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Probe error: %v\n", err)
|
||||||
}
|
}
|
||||||
@@ -316,64 +232,45 @@ func runServe(args []string) {
|
|||||||
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
|
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
|
||||||
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
||||||
|
|
||||||
var ss *store.SQLStore
|
var s store.Store
|
||||||
var dbErr error
|
var dbErr error
|
||||||
if *flagDBType == "postgres" {
|
if *flagDBType == "postgres" {
|
||||||
ss, dbErr = store.NewPostgresStore(*flagDSN)
|
s, dbErr = store.NewPostgresStore(*flagDSN)
|
||||||
fmt.Printf("Using PostgreSQL: %s\n", redactDSN(*flagDSN))
|
fmt.Printf("Using PostgreSQL: %s\n", *flagDSN)
|
||||||
} else {
|
} else {
|
||||||
ss, dbErr = store.NewSQLiteStore(*flagDSN)
|
s, dbErr = store.NewSQLiteStore(*flagDSN)
|
||||||
fmt.Printf("Using SQLite: %s\n", *flagDSN)
|
fmt.Printf("Using SQLite: %s\n", *flagDSN)
|
||||||
}
|
}
|
||||||
if dbErr != nil {
|
if dbErr != nil {
|
||||||
fmt.Fprintf(os.Stderr, "database connection error: %v\n", dbErr)
|
fmt.Printf("Database connection error: %v\n", dbErr)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
defer ss.Close()
|
defer s.Close()
|
||||||
|
|
||||||
if encKey := os.Getenv("UPTOP_ENCRYPTION_KEY"); encKey != "" {
|
|
||||||
enc, err := store.NewEncryptor(encKey)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "encryption key error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
ss.SetEncryptor(enc)
|
|
||||||
} else {
|
|
||||||
fmt.Println("WARNING: No UPTOP_ENCRYPTION_KEY set. Alert credentials stored unencrypted.")
|
|
||||||
}
|
|
||||||
|
|
||||||
var s store.Store = ss
|
|
||||||
if err := s.Init(); err != nil {
|
if err := s.Init(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "database init error: %v\n", err)
|
fmt.Printf("Database init error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if *demo {
|
if *demo {
|
||||||
seedDemoData(s)
|
seedDemoData(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
seedKeysFromEnv(s)
|
|
||||||
|
|
||||||
if *importKuma != "" {
|
if *importKuma != "" {
|
||||||
kb, err := importer.LoadKumaFile(*importKuma)
|
kb, err := importer.LoadKumaFile(*importKuma)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "kuma import error: %v\n", err)
|
fmt.Printf("Kuma import error: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
backup := importer.ConvertKuma(kb)
|
backup := importer.ConvertKuma(kb)
|
||||||
if err := s.ImportData(backup); err != nil {
|
if err := s.ImportData(backup); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "import failed: %v\n", err)
|
fmt.Printf("Import failed: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version)
|
fmt.Printf("Imported %d monitors and %d alerts from Uptime Kuma v%s\n", len(backup.Sites), len(backup.Alerts), kb.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
allowPrivate := os.Getenv("UPTOP_ALLOW_PRIVATE_TARGETS") == "true"
|
eng := monitor.NewEngine(s)
|
||||||
if allowPrivate {
|
if os.Getenv("UPKEEP_INSECURE_SKIP_VERIFY") == "true" {
|
||||||
fmt.Println("WARNING: Private target blocking disabled. Monitor URLs can reach internal networks.")
|
|
||||||
}
|
|
||||||
|
|
||||||
eng := monitor.NewEngineWithOpts(s, allowPrivate)
|
|
||||||
if os.Getenv("UPTOP_INSECURE_SKIP_VERIFY") == "true" {
|
|
||||||
eng.SetInsecureSkipVerify(true)
|
eng.SetInsecureSkipVerify(true)
|
||||||
}
|
}
|
||||||
if aggStrategy != "" {
|
if aggStrategy != "" {
|
||||||
@@ -385,22 +282,13 @@ 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")
|
|
||||||
tlsKey := os.Getenv("UPTOP_TLS_KEY")
|
|
||||||
|
|
||||||
httpSrv := server.Start(server.ServerConfig{
|
httpSrv := server.Start(server.ServerConfig{
|
||||||
Port: httpPort,
|
Port: httpPort,
|
||||||
EnableStatus: enableStatus,
|
EnableStatus: enableStatus,
|
||||||
Title: statusTitle,
|
Title: statusTitle,
|
||||||
ClusterKey: clusterKey,
|
ClusterKey: clusterKey,
|
||||||
TLSCert: tlsCert,
|
|
||||||
TLSKey: tlsKey,
|
|
||||||
ClusterMode: clusterMode,
|
|
||||||
MetricsPublic: os.Getenv("UPTOP_METRICS_PUBLIC") == "true",
|
|
||||||
CORSOrigin: os.Getenv("UPTOP_CORS_ORIGIN"),
|
|
||||||
}, s, eng)
|
}, s, eng)
|
||||||
|
|
||||||
cluster.Start(ctx, cluster.Config{
|
cluster.Start(ctx, cluster.Config{
|
||||||
@@ -409,16 +297,15 @@ func runServe(args []string) {
|
|||||||
SharedKey: clusterKey,
|
SharedKey: clusterKey,
|
||||||
}, eng)
|
}, eng)
|
||||||
|
|
||||||
kc := newKeyCache(s)
|
sshSrv := startSSHServer(*port, s, eng)
|
||||||
sshSrv := startSSHServer(*port, s, eng, kc)
|
|
||||||
|
|
||||||
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
|
||||||
p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
p := tea.NewProgram(tui.InitialModel(true, s, eng), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
fmt.Printf("Error: %v\n", err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("uptop running in HEADLESS mode")
|
fmt.Println("Go-Upkeep running in HEADLESS mode")
|
||||||
done := make(chan os.Signal, 1)
|
done := make(chan os.Signal, 1)
|
||||||
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-done
|
<-done
|
||||||
@@ -440,12 +327,12 @@ func runServe(args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache) *ssh.Server {
|
func startSSHServer(port int, db store.Store, eng *monitor.Engine) *ssh.Server {
|
||||||
s, err := wish.NewServer(
|
s, err := wish.NewServer(
|
||||||
wish.WithAddress(fmt.Sprintf(":%d", port)),
|
wish.WithAddress(fmt.Sprintf(":%d", port)),
|
||||||
wish.WithHostKeyPath(envOrDefault("UPTOP_SSH_HOST_KEY", ".ssh/id_ed25519")),
|
wish.WithHostKeyPath(".ssh/id_ed25519"),
|
||||||
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
|
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||||
return kc.IsAllowed(key)
|
return isKeyAllowed(db, key)
|
||||||
}),
|
}),
|
||||||
wish.WithMiddleware(
|
wish.WithMiddleware(
|
||||||
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||||
@@ -454,7 +341,7 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache)
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "SSH server error: %v\n", err)
|
fmt.Printf("SSH server error: %v\n", err)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
go func() {
|
go func() {
|
||||||
@@ -514,133 +401,19 @@ func seedDemoData(s store.Store) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type keyCache struct {
|
func isKeyAllowed(db store.Store, incomingKey ssh.PublicKey) bool {
|
||||||
mu sync.RWMutex
|
users, err := db.GetAllUsers()
|
||||||
keys []ssh.PublicKey
|
|
||||||
updated time.Time
|
|
||||||
ttl time.Duration
|
|
||||||
db store.Store
|
|
||||||
}
|
|
||||||
|
|
||||||
func newKeyCache(db store.Store) *keyCache {
|
|
||||||
return &keyCache{db: db, ttl: 30 * time.Second}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *keyCache) refresh() {
|
|
||||||
users, err := c.db.GetAllUsers()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
keys := make([]ssh.PublicKey, 0, len(users))
|
|
||||||
for _, u := range users {
|
for _, u := range users {
|
||||||
k, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey))
|
allowedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(u.PublicKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
keys = append(keys, k)
|
if ssh.KeysEqual(allowedKey, incomingKey) {
|
||||||
}
|
|
||||||
c.mu.Lock()
|
|
||||||
c.keys = keys
|
|
||||||
c.updated = time.Now()
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *keyCache) Invalidate() {
|
|
||||||
c.mu.Lock()
|
|
||||||
c.updated = time.Time{}
|
|
||||||
c.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool {
|
|
||||||
c.mu.RLock()
|
|
||||||
stale := time.Since(c.updated) > c.ttl
|
|
||||||
c.mu.RUnlock()
|
|
||||||
|
|
||||||
if stale {
|
|
||||||
c.refresh()
|
|
||||||
}
|
|
||||||
|
|
||||||
c.mu.RLock()
|
|
||||||
defer c.mu.RUnlock()
|
|
||||||
for _, k := range c.keys {
|
|
||||||
if ssh.KeysEqual(k, incomingKey) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func seedKeysFromEnv(s store.Store) {
|
|
||||||
var keys []string
|
|
||||||
|
|
||||||
if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" {
|
|
||||||
keys = append(keys, strings.TrimSpace(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
if path := os.Getenv("UPTOP_KEYS"); path != "" {
|
|
||||||
f, err := os.Open(filepath.Clean(path))
|
|
||||||
if err == nil {
|
|
||||||
scanner := bufio.NewScanner(f)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keys = append(keys, line)
|
|
||||||
}
|
|
||||||
_ = f.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(keys) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
existing, err := s.GetAllUsers()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: could not check existing users: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
existingKeys := make(map[string]bool)
|
|
||||||
for _, u := range existing {
|
|
||||||
existingKeys[u.PublicKey] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
added := 0
|
|
||||||
for i, key := range keys {
|
|
||||||
if existingKeys[key] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
username := usernameFromKey(key, i, len(existing)+added)
|
|
||||||
if err := s.AddUser(username, key, "admin"); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "warning: failed to seed user %q: %v\n", username, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fmt.Printf("Seeded admin user %q from %s\n", username, seedSource(i, len(keys), os.Getenv("UPTOP_ADMIN_KEY") != ""))
|
|
||||||
added++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func usernameFromKey(key string, index, totalExisting int) string {
|
|
||||||
parts := strings.Fields(key)
|
|
||||||
if len(parts) >= 3 {
|
|
||||||
comment := parts[2]
|
|
||||||
if at := strings.Index(comment, "@"); at > 0 {
|
|
||||||
return comment[:at]
|
|
||||||
}
|
|
||||||
return comment
|
|
||||||
}
|
|
||||||
if index == 0 && totalExisting == 0 {
|
|
||||||
return "admin"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("user-%d", totalExisting+1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func seedSource(index, total int, hasEnvKey bool) string {
|
|
||||||
if hasEnvKey && index == 0 {
|
|
||||||
return "UPTOP_ADMIN_KEY"
|
|
||||||
}
|
|
||||||
return "UPTOP_KEYS"
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
services:
|
|
||||||
leader:
|
|
||||||
build: .
|
|
||||||
environment:
|
|
||||||
- UPTOP_CLUSTER_MODE=leader
|
|
||||||
- UPTOP_CLUSTER_SECRET=changeme
|
|
||||||
- UPTOP_AGG_STRATEGY=any-down
|
|
||||||
- UPTOP_STATUS_ENABLED=true
|
|
||||||
ports:
|
|
||||||
- "8080:8080"
|
|
||||||
- "23234:23234"
|
|
||||||
|
|
||||||
probe-us-east:
|
|
||||||
build: .
|
|
||||||
environment:
|
|
||||||
- UPTOP_CLUSTER_MODE=probe
|
|
||||||
- UPTOP_NODE_ID=us-east-1
|
|
||||||
- UPTOP_NODE_NAME=US East Probe
|
|
||||||
- UPTOP_NODE_REGION=us-east
|
|
||||||
- UPTOP_PEER_URL=http://leader:8080
|
|
||||||
- UPTOP_CLUSTER_SECRET=changeme
|
|
||||||
depends_on:
|
|
||||||
- leader
|
|
||||||
|
|
||||||
probe-eu-west:
|
|
||||||
build: .
|
|
||||||
environment:
|
|
||||||
- UPTOP_CLUSTER_MODE=probe
|
|
||||||
- UPTOP_NODE_ID=eu-west-1
|
|
||||||
- UPTOP_NODE_NAME=EU West Probe
|
|
||||||
- UPTOP_NODE_REGION=eu-west
|
|
||||||
- UPTOP_PEER_URL=http://leader:8080
|
|
||||||
- UPTOP_CLUSTER_SECRET=changeme
|
|
||||||
depends_on:
|
|
||||||
- leader
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: uptop
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "23234:23234"
|
|
||||||
- "8080:8080"
|
|
||||||
environment:
|
|
||||||
- UPTOP_DB_TYPE=sqlite
|
|
||||||
- UPTOP_DB_DSN=/data/uptop.db
|
|
||||||
- UPTOP_HTTP_PORT=8080
|
|
||||||
- UPTOP_STATUS_ENABLED=true
|
|
||||||
- UPTOP_STATUS_TITLE=System Status
|
|
||||||
# SSH access: add your public key via env var or authorized_keys file
|
|
||||||
# - UPTOP_ADMIN_KEY=ssh-ed25519 AAAA... you@host
|
|
||||||
volumes:
|
|
||||||
- ./data:/data
|
|
||||||
@@ -4,21 +4,21 @@ services:
|
|||||||
# -------------------------
|
# -------------------------
|
||||||
leader:
|
leader:
|
||||||
build: .
|
build: .
|
||||||
container_name: uptop-leader
|
container_name: upkeep-leader
|
||||||
ports:
|
ports:
|
||||||
- "23234:23234" # SSH
|
- "23234:23234" # SSH
|
||||||
- "8080:8080" # HTTP
|
- "8080:8080" # HTTP
|
||||||
environment:
|
environment:
|
||||||
- UPTOP_DB_TYPE=postgres
|
- UPKEEP_DB_TYPE=postgres
|
||||||
# Note: Port 5432 is correct here because we are talking INSIDE the network
|
# Note: Port 5432 is correct here because we are talking INSIDE the network
|
||||||
- UPTOP_DB_DSN=postgres://devuser:devpass@leader-db:5432/uptop_dev?sslmode=disable
|
- UPKEEP_DB_DSN=postgres://devuser:devpass@leader-db:5432/upkeep_dev?sslmode=disable
|
||||||
- UPTOP_HTTP_PORT=8080
|
- UPKEEP_HTTP_PORT=8080
|
||||||
- UPTOP_STATUS_ENABLED=true
|
- UPKEEP_STATUS_ENABLED=true
|
||||||
- UPTOP_STATUS_TITLE=Leader Node
|
- UPKEEP_STATUS_TITLE=Leader Node
|
||||||
|
|
||||||
# Cluster Config
|
# Cluster Config
|
||||||
- UPTOP_CLUSTER_MODE=leader
|
- UPKEEP_CLUSTER_MODE=leader
|
||||||
- UPTOP_CLUSTER_SECRET=mysecret
|
- UPKEEP_CLUSTER_SECRET=mysecret
|
||||||
depends_on:
|
depends_on:
|
||||||
- leader-db
|
- leader-db
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
@@ -26,11 +26,11 @@ services:
|
|||||||
|
|
||||||
leader-db:
|
leader-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: uptop-leader-db
|
container_name: upkeep-leader-db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: devuser
|
POSTGRES_USER: devuser
|
||||||
POSTGRES_PASSWORD: devpass
|
POSTGRES_PASSWORD: devpass
|
||||||
POSTGRES_DB: uptop_dev
|
POSTGRES_DB: upkeep_dev
|
||||||
volumes:
|
volumes:
|
||||||
- ./tmp/leader-data:/var/lib/postgresql/data
|
- ./tmp/leader-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
@@ -39,23 +39,23 @@ services:
|
|||||||
# -------------------------
|
# -------------------------
|
||||||
follower:
|
follower:
|
||||||
build: .
|
build: .
|
||||||
container_name: uptop-follower
|
container_name: upkeep-follower
|
||||||
ports:
|
ports:
|
||||||
- "23233:23234" # SSH (Mapped to different host port)
|
- "23233:23234" # SSH (Mapped to different host port)
|
||||||
- "8081:8080" # HTTP (Mapped to different host port)
|
- "8081:8080" # HTTP (Mapped to different host port)
|
||||||
environment:
|
environment:
|
||||||
- UPTOP_DB_TYPE=postgres
|
- UPKEEP_DB_TYPE=postgres
|
||||||
# Connects to its OWN database
|
# Connects to its OWN database
|
||||||
- UPTOP_DB_DSN=postgres://devuser:devpass@follower-db:5432/uptop_dev?sslmode=disable
|
- UPKEEP_DB_DSN=postgres://devuser:devpass@follower-db:5432/upkeep_dev?sslmode=disable
|
||||||
- UPTOP_HTTP_PORT=8080
|
- UPKEEP_HTTP_PORT=8080
|
||||||
- UPTOP_STATUS_ENABLED=true
|
- UPKEEP_STATUS_ENABLED=true
|
||||||
- UPTOP_STATUS_TITLE=Follower Node
|
- UPKEEP_STATUS_TITLE=Follower Node
|
||||||
|
|
||||||
# Cluster Config
|
# Cluster Config
|
||||||
- UPTOP_CLUSTER_MODE=follower
|
- UPKEEP_CLUSTER_MODE=follower
|
||||||
- UPTOP_CLUSTER_SECRET=mysecret
|
- UPKEEP_CLUSTER_SECRET=mysecret
|
||||||
# IMPORTANT: Uses the Service Name "leader" to connect internally
|
# IMPORTANT: Uses the Service Name "leader" to connect internally
|
||||||
- UPTOP_PEER_URL=http://leader:8080
|
- UPKEEP_PEER_URL=http://leader:8080
|
||||||
depends_on:
|
depends_on:
|
||||||
- follower-db
|
- follower-db
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
@@ -63,10 +63,10 @@ services:
|
|||||||
|
|
||||||
follower-db:
|
follower-db:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: uptop-follower-db
|
container_name: upkeep-follower-db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: devuser
|
POSTGRES_USER: devuser
|
||||||
POSTGRES_PASSWORD: devpass
|
POSTGRES_PASSWORD: devpass
|
||||||
POSTGRES_DB: uptop_dev
|
POSTGRES_DB: upkeep_dev
|
||||||
volumes:
|
volumes:
|
||||||
- ./tmp/follower-data:/var/lib/postgresql/data
|
- ./tmp/follower-data:/var/lib/postgresql/data
|
||||||
@@ -4,19 +4,19 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: uptop-dev
|
container_name: upkeep-dev
|
||||||
ports:
|
ports:
|
||||||
- "23234:23234" # SSH Access
|
- "23234:23234" # SSH Access
|
||||||
- "8080:8080" # HTTP (Push Monitors + Status Page)
|
- "8080:8080" # HTTP (Push Monitors + Status Page)
|
||||||
environment:
|
environment:
|
||||||
# --- Database Configuration (Postgres) ---
|
# --- Database Configuration (Postgres) ---
|
||||||
- UPTOP_DB_TYPE=postgres
|
- UPKEEP_DB_TYPE=postgres
|
||||||
- UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable
|
- UPKEEP_DB_DSN=postgres://devuser:devpass@postgres:5432/upkeep_dev?sslmode=disable
|
||||||
|
|
||||||
# --- Web Server Configuration (Phase 4) ---
|
# --- Web Server Configuration (Phase 4) ---
|
||||||
- UPTOP_HTTP_PORT=8080
|
- UPKEEP_HTTP_PORT=8080
|
||||||
- UPTOP_STATUS_ENABLED=true
|
- UPKEEP_STATUS_ENABLED=true
|
||||||
- UPTOP_STATUS_TITLE=Dev Infrastructure Status
|
- UPKEEP_STATUS_TITLE=Dev Infrastructure Status
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
stdin_open: true # Required for 'docker attach' (Local Admin Console)
|
stdin_open: true # Required for 'docker attach' (Local Admin Console)
|
||||||
@@ -25,11 +25,11 @@ services:
|
|||||||
# The Database
|
# The Database
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:15-alpine
|
image: postgres:15-alpine
|
||||||
container_name: uptop-postgres
|
container_name: upkeep-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: devuser
|
POSTGRES_USER: devuser
|
||||||
POSTGRES_PASSWORD: devpass
|
POSTGRES_PASSWORD: devpass
|
||||||
POSTGRES_DB: uptop_dev
|
POSTGRES_DB: upkeep_dev
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432" # Expose for external DB tools (DBeaver, etc.)
|
- "5432:5432" # Expose for external DB tools (DBeaver, etc.)
|
||||||
volumes:
|
volumes:
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
leader:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
- UPKEEP_CLUSTER_MODE=leader
|
||||||
|
- UPKEEP_CLUSTER_SECRET=changeme
|
||||||
|
- UPKEEP_AGG_STRATEGY=any-down
|
||||||
|
- UPKEEP_STATUS_ENABLED=true
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
- "23234:23234"
|
||||||
|
|
||||||
|
probe-us-east:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
- UPKEEP_CLUSTER_MODE=probe
|
||||||
|
- UPKEEP_NODE_ID=us-east-1
|
||||||
|
- UPKEEP_NODE_NAME=US East Probe
|
||||||
|
- UPKEEP_NODE_REGION=us-east
|
||||||
|
- UPKEEP_PEER_URL=http://leader:8080
|
||||||
|
- UPKEEP_CLUSTER_SECRET=changeme
|
||||||
|
depends_on:
|
||||||
|
- leader
|
||||||
|
|
||||||
|
probe-eu-west:
|
||||||
|
build: .
|
||||||
|
environment:
|
||||||
|
- UPKEEP_CLUSTER_MODE=probe
|
||||||
|
- UPKEEP_NODE_ID=eu-west-1
|
||||||
|
- UPKEEP_NODE_NAME=EU West Probe
|
||||||
|
- UPKEEP_NODE_REGION=eu-west
|
||||||
|
- UPKEEP_PEER_URL=http://leader:8080
|
||||||
|
- UPKEEP_CLUSTER_SECRET=changeme
|
||||||
|
depends_on:
|
||||||
|
- leader
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: upkeep
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "23234:23234"
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- UPKEEP_DB_TYPE=sqlite
|
||||||
|
- UPKEEP_DB_DSN=/data/upkeep.db
|
||||||
|
- UPKEEP_HTTP_PORT=8080
|
||||||
|
- UPKEEP_STATUS_ENABLED=true
|
||||||
|
- UPKEEP_STATUS_TITLE=System Status
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
+13
-13
@@ -7,13 +7,13 @@ Define your monitors and alerts in a YAML file. Version control them, copy them
|
|||||||
Export what you already have:
|
Export what you already have:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uptop export -o monitors.yaml
|
goupkeep export -o monitors.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
That gives you a working file you can edit and re-apply:
|
That gives you a working file you can edit and re-apply:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uptop apply -f monitors.yaml
|
goupkeep apply -f monitors.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it. Apply only creates or updates — it won't delete anything unless you tell it to.
|
That's it. Apply only creates or updates — it won't delete anything unless you tell it to.
|
||||||
@@ -184,34 +184,34 @@ All 9 providers work in the YAML. The `settings` map is different per type.
|
|||||||
|
|
||||||
**Export current state:**
|
**Export current state:**
|
||||||
```bash
|
```bash
|
||||||
uptop export -o monitors.yaml # to a file
|
goupkeep export -o monitors.yaml # to a file
|
||||||
uptop export # to stdout
|
goupkeep export # to stdout
|
||||||
```
|
```
|
||||||
|
|
||||||
**Apply a config:**
|
**Apply a config:**
|
||||||
```bash
|
```bash
|
||||||
uptop apply -f monitors.yaml
|
goupkeep apply -f monitors.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
**See what would change first:**
|
**See what would change first:**
|
||||||
```bash
|
```bash
|
||||||
uptop apply -f monitors.yaml --dry-run
|
goupkeep apply -f monitors.yaml --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
**Delete monitors not in the YAML:**
|
**Delete monitors not in the YAML:**
|
||||||
```bash
|
```bash
|
||||||
uptop apply -f monitors.yaml --prune
|
goupkeep apply -f monitors.yaml --prune
|
||||||
```
|
```
|
||||||
|
|
||||||
Without `--prune`, apply never deletes anything. It only creates and updates.
|
Without `--prune`, apply never deletes anything. It only creates and updates.
|
||||||
|
|
||||||
**Pointing at a different database:**
|
**Pointing at a different database:**
|
||||||
```bash
|
```bash
|
||||||
uptop export -db-type postgres -dsn "host=localhost dbname=uptop sslmode=disable"
|
goupkeep export -db-type postgres -dsn "host=localhost dbname=upkeep sslmode=disable"
|
||||||
uptop apply -f monitors.yaml -db-type postgres -dsn "..."
|
goupkeep apply -f monitors.yaml -db-type postgres -dsn "..."
|
||||||
```
|
```
|
||||||
|
|
||||||
Both commands respect the `UPTOP_DB_TYPE` and `UPTOP_DB_DSN` environment variables too.
|
Both commands respect the `UPKEEP_DB_TYPE` and `UPKEEP_DB_DSN` environment variables too.
|
||||||
|
|
||||||
## How apply works
|
## How apply works
|
||||||
|
|
||||||
@@ -230,15 +230,15 @@ If something fails mid-apply, just fix the issue and run it again. It picks up w
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# set up your monitors in the TUI first, then export
|
# set up your monitors in the TUI first, then export
|
||||||
uptop export -o monitors.yaml
|
goupkeep export -o monitors.yaml
|
||||||
|
|
||||||
# commit it
|
# commit it
|
||||||
git add monitors.yaml && git commit -m "add monitor config"
|
git add monitors.yaml && git commit -m "add monitor config"
|
||||||
|
|
||||||
# deploy to another instance
|
# deploy to another instance
|
||||||
scp monitors.yaml prod-server:
|
scp monitors.yaml prod-server:
|
||||||
ssh prod-server uptop apply -f monitors.yaml
|
ssh prod-server goupkeep apply -f monitors.yaml
|
||||||
|
|
||||||
# or just keep it as a backup you can restore from
|
# or just keep it as a backup you can restore from
|
||||||
uptop apply -f monitors.yaml
|
goupkeep apply -f monitors.yaml
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module gitea.lerkolabs.com/lerkolabs/uptop
|
module go-upkeep
|
||||||
|
|
||||||
go 1.26.3
|
go 1.24.4
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||||
@@ -16,7 +16,6 @@ require (
|
|||||||
github.com/mattn/go-sqlite3 v1.14.33
|
github.com/mattn/go-sqlite3 v1.14.33
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
github.com/prometheus-community/pro-bing v0.8.0
|
github.com/prometheus-community/pro-bing v0.8.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -50,12 +49,13 @@ require (
|
|||||||
github.com/muesli/termenv v0.16.0 // indirect
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/crypto v0.52.0 // indirect
|
golang.org/x/crypto v0.47.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||||
golang.org/x/mod v0.35.0 // indirect
|
golang.org/x/mod v0.31.0 // indirect
|
||||||
golang.org/x/net v0.55.0 // indirect
|
golang.org/x/net v0.49.0 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.37.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
golang.org/x/tools v0.44.0 // indirect
|
golang.org/x/tools v0.40.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -101,27 +101,26 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
|
|||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
|
||||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
+7
-26
@@ -5,13 +5,12 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var alertClient = &http.Client{Timeout: 10 * time.Second}
|
var alertClient = &http.Client{Timeout: 10 * time.Second}
|
||||||
@@ -25,7 +24,6 @@ type PayloadFunc func(title, message string) ([]byte, error)
|
|||||||
type HTTPProvider struct {
|
type HTTPProvider struct {
|
||||||
URL string
|
URL string
|
||||||
Payload PayloadFunc
|
Payload PayloadFunc
|
||||||
Headers map[string]string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *HTTPProvider) Send(ctx context.Context, title, message string) error {
|
func (h *HTTPProvider) Send(ctx context.Context, title, message string) error {
|
||||||
@@ -38,9 +36,6 @@ func (h *HTTPProvider) Send(ctx context.Context, title, message string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
for k, v := range h.Headers {
|
|
||||||
req.Header.Set(k, v)
|
|
||||||
}
|
|
||||||
resp, err := alertClient.Do(req)
|
resp, err := alertClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -81,7 +76,7 @@ func pagerdutyPayload(routingKey, severity string) PayloadFunc {
|
|||||||
"event_action": "trigger",
|
"event_action": "trigger",
|
||||||
"payload": map[string]string{
|
"payload": map[string]string{
|
||||||
"summary": fmt.Sprintf("%s: %s", title, message),
|
"summary": fmt.Sprintf("%s: %s", title, message),
|
||||||
"source": "uptop",
|
"source": "go-upkeep",
|
||||||
"severity": severity,
|
"severity": severity,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -169,9 +164,8 @@ func GetProvider(cfg models.AlertConfig) Provider {
|
|||||||
}
|
}
|
||||||
serverURL := strings.TrimRight(cfg.Settings["url"], "/")
|
serverURL := strings.TrimRight(cfg.Settings["url"], "/")
|
||||||
return &HTTPProvider{
|
return &HTTPProvider{
|
||||||
URL: serverURL + "/message",
|
URL: fmt.Sprintf("%s/message?token=%s", serverURL, cfg.Settings["token"]),
|
||||||
Payload: gotifyPayload(priority),
|
Payload: gotifyPayload(priority),
|
||||||
Headers: map[string]string{"X-Gotify-Key": cfg.Settings["token"]},
|
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
@@ -182,12 +176,6 @@ type EmailProvider struct {
|
|||||||
Host, Port, User, Pass, To, From string
|
Host, Port, User, Pass, To, From string
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeHeader(s string) string {
|
|
||||||
s = strings.ReplaceAll(s, "\r", "")
|
|
||||||
s = strings.ReplaceAll(s, "\n", "")
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
|
func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@@ -195,18 +183,11 @@ func (e *EmailProvider) Send(ctx context.Context, title, message string) error {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
|
auth := smtp.PlainAuth("", e.User, e.Pass, e.Host)
|
||||||
to := sanitizeHeader(e.To)
|
msg := []byte("To: " + e.To + "\r\n" +
|
||||||
from := sanitizeHeader(e.From)
|
"Subject: Go-Upkeep: " + title + "\r\n" +
|
||||||
subject := sanitizeHeader(title)
|
|
||||||
body := strings.ReplaceAll(message, "\r", "")
|
|
||||||
msg := []byte("From: " + from + "\r\n" +
|
|
||||||
"To: " + to + "\r\n" +
|
|
||||||
"Subject: uptop: " + subject + "\r\n" +
|
|
||||||
"MIME-Version: 1.0\r\n" +
|
|
||||||
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
||||||
"\r\n" +
|
"\r\n" +
|
||||||
body + "\r\n")
|
message + "\r\n")
|
||||||
return smtp.SendMail(e.Host+":"+e.Port, auth, from, []string{to}, msg)
|
return smtp.SendMail(e.Host+":"+e.Port, auth, e.From, []string{e.To}, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
type NtfyProvider struct {
|
type NtfyProvider struct {
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ package alert
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestHTTPProviderDiscord(t *testing.T) {
|
func TestHTTPProviderDiscord(t *testing.T) {
|
||||||
@@ -213,20 +212,3 @@ func TestGetProviderUnknown(t *testing.T) {
|
|||||||
t.Error("expected nil for unknown provider type")
|
t.Error("expected nil for unknown provider type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSanitizeHeader(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input, want string
|
|
||||||
}{
|
|
||||||
{"normal subject", "normal subject"},
|
|
||||||
{"inject\r\nBcc: evil@bad.com", "injectBcc: evil@bad.com"},
|
|
||||||
{"has\nnewline", "hasnewline"},
|
|
||||||
{"has\rcarriage", "hascarriage"},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := sanitizeHeader(tt.input)
|
|
||||||
if got != tt.want {
|
|
||||||
t.Errorf("sanitizeHeader(%q) = %q, want %q", tt.input, got, tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ package cluster
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/internal/monitor"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -58,8 +57,8 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
|
|||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
isLeaderHealthy := false
|
isLeaderHealthy := false
|
||||||
|
|
||||||
if err == nil {
|
if err == nil && resp.StatusCode == 200 {
|
||||||
isLeaderHealthy = resp.StatusCode == 200
|
isLeaderHealthy = true
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,14 @@ package cluster
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
|
"go-upkeep/internal/monitor"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mock Store (minimal, for monitor.NewEngine) ---
|
// --- Mock Store (minimal, for monitor.NewEngine) ---
|
||||||
@@ -53,10 +52,6 @@ func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return models.Pr
|
|||||||
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
||||||
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
||||||
func (m *mockStore) DeleteNode(string) error { return nil }
|
func (m *mockStore) DeleteNode(string) error { return nil }
|
||||||
func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
|
|
||||||
func (m *mockStore) SaveLog(string) error { return nil }
|
func (m *mockStore) SaveLog(string) error { return nil }
|
||||||
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||||
@@ -71,8 +66,6 @@ func (m *mockStore) DeleteMaintenanceWindow(int) error { re
|
|||||||
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) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
|
|
||||||
func (m *mockStore) Close() error { return nil }
|
func (m *mockStore) Close() error { return nil }
|
||||||
|
|
||||||
// --- Cluster Start Tests ---
|
// --- Cluster Start Tests ---
|
||||||
@@ -302,7 +295,7 @@ func TestProbeExecuteChecks(t *testing.T) {
|
|||||||
|
|
||||||
strict := &http.Client{}
|
strict := &http.Client{}
|
||||||
insecure := &http.Client{}
|
insecure := &http.Client{}
|
||||||
results := probeExecuteChecks(context.Background(), sites, strict, insecure, true)
|
results := probeExecuteChecks(context.Background(), sites, strict, insecure)
|
||||||
|
|
||||||
if len(results) != 2 {
|
if len(results) != 2 {
|
||||||
t.Fatalf("expected 2 results, got %d", len(results))
|
t.Fatalf("expected 2 results, got %d", len(results))
|
||||||
@@ -336,7 +329,7 @@ func TestProbeExecuteChecks_Concurrency(t *testing.T) {
|
|||||||
sites = append(sites, models.Site{ID: i + 1, Type: "http", URL: srv.URL})
|
sites = append(sites, models.Site{ID: i + 1, Type: "http", URL: srv.URL})
|
||||||
}
|
}
|
||||||
|
|
||||||
results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{}, true)
|
results := probeExecuteChecks(context.Background(), sites, &http.Client{}, &http.Client{})
|
||||||
if len(results) != 20 {
|
if len(results) != 20 {
|
||||||
t.Errorf("expected 20 results, got %d", len(results))
|
t.Errorf("expected 20 results, got %d", len(results))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
|
"go-upkeep/internal/monitor"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProbeConfig struct {
|
type ProbeConfig struct {
|
||||||
@@ -23,7 +21,6 @@ type ProbeConfig struct {
|
|||||||
LeaderURL string
|
LeaderURL string
|
||||||
SharedKey string
|
SharedKey string
|
||||||
Interval int
|
Interval int
|
||||||
AllowPrivateTargets bool
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
||||||
@@ -32,18 +29,11 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apiClient := &http.Client{Timeout: 10 * time.Second}
|
apiClient := &http.Client{Timeout: 10 * time.Second}
|
||||||
dial := monitor.SafeDialContext(cfg.AllowPrivateTargets)
|
|
||||||
strictClient := &http.Client{
|
strictClient := &http.Client{
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
|
|
||||||
DialContext: dial,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
insecureClient := &http.Client{
|
insecureClient := &http.Client{
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // intentional for IgnoreTLS sites
|
|
||||||
DialContext: dial,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := probeRegister(ctx, apiClient, cfg); err != nil {
|
if err := probeRegister(ctx, apiClient, cfg); err != nil {
|
||||||
@@ -69,7 +59,7 @@ func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
results := probeExecuteChecks(ctx, sites, strictClient, insecureClient, cfg.AllowPrivateTargets)
|
results := probeExecuteChecks(ctx, sites, strictClient, insecureClient)
|
||||||
|
|
||||||
if len(results) > 0 {
|
if len(results) > 0 {
|
||||||
if err := probeReportResults(ctx, apiClient, cfg, results); err != nil {
|
if err := probeReportResults(ctx, apiClient, cfg, results); err != nil {
|
||||||
@@ -103,8 +93,7 @@ func probeRegister(ctx context.Context, client *http.Client, cfg ProbeConfig) er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeConfig) ([]models.Site, error) {
|
func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeConfig) ([]models.Site, error) {
|
||||||
assignURL := cfg.LeaderURL + "/api/probe/assignments?" + url.Values{"node_id": {cfg.NodeID}}.Encode()
|
req, err := http.NewRequestWithContext(ctx, "GET", cfg.LeaderURL+"/api/probe/assignments?node_id="+cfg.NodeID, nil)
|
||||||
req, err := http.NewRequestWithContext(ctx, "GET", assignURL, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -130,10 +119,9 @@ 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) []probeResultItem {
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var results []probeResultItem
|
var results []probeResultItem
|
||||||
sem := make(chan struct{}, 10)
|
sem := make(chan struct{}, 10)
|
||||||
@@ -152,13 +140,12 @@ loop:
|
|||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
|
|
||||||
cr := monitor.RunCheck(s, strict, insecure, false, allowPrivate)
|
cr := monitor.RunCheck(s, strict, insecure, false)
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
results = append(results, probeResultItem{
|
results = append(results, probeResultItem{
|
||||||
SiteID: s.ID,
|
SiteID: s.ID,
|
||||||
LatencyNs: cr.LatencyNs,
|
LatencyNs: cr.LatencyNs,
|
||||||
IsUp: cr.Status == "UP",
|
IsUp: cr.Status == "UP",
|
||||||
ErrorReason: cr.ErrorReason,
|
|
||||||
})
|
})
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}(site)
|
}(site)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"go-upkeep/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
"go-upkeep/internal/store"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"go-upkeep/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
"go-upkeep/internal/store"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
|
"go-upkeep/internal/store"
|
||||||
"os"
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -143,7 +142,7 @@ func WriteFile(f *File, path string) error {
|
|||||||
_, err = os.Stdout.Write(data)
|
_, err = os.Stdout.Write(data)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, data, 0600)
|
return os.WriteFile(path, data, 0644) //nolint:gosec // config files should be group-readable
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadFile(path string) (*File, error) {
|
func LoadFile(path string) (*File, error) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"go-upkeep/internal/models"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package importer
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"go-upkeep/internal/models"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package metrics
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"go-upkeep/internal/models"
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
"go-upkeep/internal/monitor"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,74 +16,74 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
|
|||||||
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|
||||||
writeHelp(&b, "uptop_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).")
|
writeHelp(&b, "upkeep_monitor_up", "gauge", "Whether the monitor is up (1) or down (0).")
|
||||||
for _, s := range sites {
|
for _, s := range sites {
|
||||||
val := 0
|
val := 0
|
||||||
if s.Status == "UP" {
|
if s.Status == "UP" {
|
||||||
val = 1
|
val = 1
|
||||||
}
|
}
|
||||||
writeGauge(&b, "uptop_monitor_up", labels(s), float64(val))
|
writeGauge(&b, "upkeep_monitor_up", labels(s), float64(val))
|
||||||
}
|
}
|
||||||
|
|
||||||
writeHelp(&b, "uptop_monitor_latency_seconds", "gauge", "Last check latency in seconds.")
|
writeHelp(&b, "upkeep_monitor_latency_seconds", "gauge", "Last check latency in seconds.")
|
||||||
for _, s := range sites {
|
for _, s := range sites {
|
||||||
writeGauge(&b, "uptop_monitor_latency_seconds", labels(s), s.Latency.Seconds())
|
writeGauge(&b, "upkeep_monitor_latency_seconds", labels(s), s.Latency.Seconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
writeHelp(&b, "uptop_monitor_status_code", "gauge", "HTTP response status code of the last check.")
|
writeHelp(&b, "upkeep_monitor_status_code", "gauge", "HTTP response status code of the last check.")
|
||||||
for _, s := range sites {
|
for _, s := range sites {
|
||||||
if s.Type != "http" {
|
if s.Type != "http" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
writeGauge(&b, "uptop_monitor_status_code", labels(s), float64(s.StatusCode))
|
writeGauge(&b, "upkeep_monitor_status_code", labels(s), float64(s.StatusCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
writeHelp(&b, "uptop_monitor_check_timestamp_seconds", "gauge", "Unix timestamp of the last check.")
|
writeHelp(&b, "upkeep_monitor_check_timestamp_seconds", "gauge", "Unix timestamp of the last check.")
|
||||||
for _, s := range sites {
|
for _, s := range sites {
|
||||||
if s.LastCheck.IsZero() {
|
if s.LastCheck.IsZero() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
writeGauge(&b, "uptop_monitor_check_timestamp_seconds", labels(s), float64(s.LastCheck.Unix()))
|
writeGauge(&b, "upkeep_monitor_check_timestamp_seconds", labels(s), float64(s.LastCheck.Unix()))
|
||||||
}
|
}
|
||||||
|
|
||||||
writeHelp(&b, "uptop_monitor_paused", "gauge", "Whether the monitor is paused (1) or active (0).")
|
writeHelp(&b, "upkeep_monitor_paused", "gauge", "Whether the monitor is paused (1) or active (0).")
|
||||||
for _, s := range sites {
|
for _, s := range sites {
|
||||||
val := 0
|
val := 0
|
||||||
if s.Paused {
|
if s.Paused {
|
||||||
val = 1
|
val = 1
|
||||||
}
|
}
|
||||||
writeGauge(&b, "uptop_monitor_paused", labels(s), float64(val))
|
writeGauge(&b, "upkeep_monitor_paused", labels(s), float64(val))
|
||||||
}
|
}
|
||||||
|
|
||||||
writeHelp(&b, "uptop_monitor_maintenance", "gauge", "Whether the monitor is in a maintenance window (1) or not (0).")
|
writeHelp(&b, "upkeep_monitor_maintenance", "gauge", "Whether the monitor is in a maintenance window (1) or not (0).")
|
||||||
for _, s := range sites {
|
for _, s := range sites {
|
||||||
val := 0
|
val := 0
|
||||||
if eng.GetDisplayStatus(s) == "MAINT" {
|
if eng.GetDisplayStatus(s) == "MAINT" {
|
||||||
val = 1
|
val = 1
|
||||||
}
|
}
|
||||||
writeGauge(&b, "uptop_monitor_maintenance", labels(s), float64(val))
|
writeGauge(&b, "upkeep_monitor_maintenance", labels(s), float64(val))
|
||||||
}
|
}
|
||||||
|
|
||||||
writeHelp(&b, "uptop_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.")
|
writeHelp(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", "gauge", "Unix timestamp when the SSL certificate expires.")
|
||||||
for _, s := range sites {
|
for _, s := range sites {
|
||||||
if !s.HasSSL || s.CertExpiry.IsZero() {
|
if !s.HasSSL || s.CertExpiry.IsZero() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
writeGauge(&b, "uptop_monitor_cert_expiry_timestamp_seconds", labels(s), float64(s.CertExpiry.Unix()))
|
writeGauge(&b, "upkeep_monitor_cert_expiry_timestamp_seconds", labels(s), float64(s.CertExpiry.Unix()))
|
||||||
}
|
}
|
||||||
|
|
||||||
writeHelp(&b, "uptop_monitor_checks_total", "counter", "Total number of checks performed.")
|
writeHelp(&b, "upkeep_monitor_checks_total", "counter", "Total number of checks performed.")
|
||||||
writeHelp(&b, "uptop_monitor_checks_up_total", "counter", "Total number of successful checks.")
|
writeHelp(&b, "upkeep_monitor_checks_up_total", "counter", "Total number of successful checks.")
|
||||||
for _, s := range sites {
|
for _, s := range sites {
|
||||||
h, ok := eng.GetHistory(s.ID)
|
h, ok := eng.GetHistory(s.ID)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
writeGauge(&b, "uptop_monitor_checks_total", labels(s), float64(h.TotalChecks))
|
writeGauge(&b, "upkeep_monitor_checks_total", labels(s), float64(h.TotalChecks))
|
||||||
writeGauge(&b, "uptop_monitor_checks_up_total", labels(s), float64(h.UpChecks))
|
writeGauge(&b, "upkeep_monitor_checks_up_total", labels(s), float64(h.UpChecks))
|
||||||
}
|
}
|
||||||
|
|
||||||
writeHelp(&b, "uptop_probe_up", "gauge", "Whether a probe node is online (1) or offline (0) based on last-seen time.")
|
writeHelp(&b, "upkeep_probe_up", "gauge", "Whether a probe node is online (1) or offline (0) based on last-seen time.")
|
||||||
for _, site := range sites {
|
for _, site := range sites {
|
||||||
probeResults := eng.GetProbeResults(site.ID)
|
probeResults := eng.GetProbeResults(site.ID)
|
||||||
for nodeID, result := range probeResults {
|
for nodeID, result := range probeResults {
|
||||||
@@ -92,7 +92,7 @@ func Handler(eng *monitor.Engine) http.HandlerFunc {
|
|||||||
val = 1
|
val = 1
|
||||||
}
|
}
|
||||||
nodeLabels := fmt.Sprintf(`id="%d",name="%s",node="%s"`, site.ID, escapeLabelValue(site.Name), escapeLabelValue(nodeID))
|
nodeLabels := fmt.Sprintf(`id="%d",name="%s",node="%s"`, site.ID, escapeLabelValue(site.Name), escapeLabelValue(nodeID))
|
||||||
writeGauge(&b, "uptop_probe_up", nodeLabels, float64(val))
|
writeGauge(&b, "upkeep_probe_up", nodeLabels, float64(val))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ package metrics
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
|
"go-upkeep/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,10 +50,6 @@ func (m *mockStore) GetNode(string) (models.ProbeNode, error) { return m
|
|||||||
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
||||||
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
||||||
func (m *mockStore) DeleteNode(string) error { return nil }
|
func (m *mockStore) DeleteNode(string) error { return nil }
|
||||||
func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
|
|
||||||
func (m *mockStore) SaveLog(string) error { return nil }
|
func (m *mockStore) SaveLog(string) error { return nil }
|
||||||
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetActiveMaintenanceWindows() ([]models.MaintenanceWindow, error) {
|
||||||
@@ -69,8 +64,6 @@ func (m *mockStore) DeleteMaintenanceWindow(int) error { re
|
|||||||
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) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
|
|
||||||
func (m *mockStore) Close() error { return nil }
|
func (m *mockStore) Close() error { return nil }
|
||||||
|
|
||||||
func TestMetricsHandler(t *testing.T) {
|
func TestMetricsHandler(t *testing.T) {
|
||||||
@@ -101,13 +94,13 @@ func TestMetricsHandler(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expected := []string{
|
expected := []string{
|
||||||
"# HELP uptop_monitor_up",
|
"# HELP upkeep_monitor_up",
|
||||||
"# TYPE uptop_monitor_up gauge",
|
"# TYPE upkeep_monitor_up gauge",
|
||||||
`uptop_monitor_up{id="1",name="Example",type="http"}`,
|
`upkeep_monitor_up{id="1",name="Example",type="http"}`,
|
||||||
`uptop_monitor_up{id="2",name="DNS Check",type="dns"}`,
|
`upkeep_monitor_up{id="2",name="DNS Check",type="dns"}`,
|
||||||
"# HELP uptop_monitor_latency_seconds",
|
"# HELP upkeep_monitor_latency_seconds",
|
||||||
"# HELP uptop_monitor_paused",
|
"# HELP upkeep_monitor_paused",
|
||||||
"# HELP uptop_monitor_checks_total",
|
"# HELP upkeep_monitor_checks_total",
|
||||||
}
|
}
|
||||||
for _, s := range expected {
|
for _, s := range expected {
|
||||||
if !strings.Contains(body, s) {
|
if !strings.Contains(body, s) {
|
||||||
|
|||||||
@@ -35,18 +35,6 @@ type Site struct {
|
|||||||
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
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ type NodeResult struct {
|
|||||||
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) {
|
||||||
|
|||||||
+10
-48
@@ -2,15 +2,13 @@ package monitor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"go-upkeep/internal/models"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
probing "github.com/prometheus-community/pro-bing"
|
probing "github.com/prometheus-community/pro-bing"
|
||||||
)
|
)
|
||||||
@@ -22,28 +20,9 @@ type CheckResult struct {
|
|||||||
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) CheckResult {
|
||||||
private := len(allowPrivate) > 0 && allowPrivate[0]
|
|
||||||
|
|
||||||
if site.Type != "http" && site.Type != "dns" && !private {
|
|
||||||
host := site.Hostname
|
|
||||||
if host == "" {
|
|
||||||
host = site.URL
|
|
||||||
}
|
|
||||||
if host != "" {
|
|
||||||
if ips, err := net.LookupIP(host); err == nil {
|
|
||||||
for _, ip := range ips {
|
|
||||||
if isPrivateIP(ip) {
|
|
||||||
return CheckResult{SiteID: site.ID, Status: "DOWN", ErrorReason: "target resolves to private IP"}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch site.Type {
|
switch site.Type {
|
||||||
case "http":
|
case "http":
|
||||||
return runHTTPCheck(site, strict, insecure, globalInsecure)
|
return runHTTPCheck(site, strict, insecure, globalInsecure)
|
||||||
@@ -54,7 +33,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 +49,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 +69,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 +76,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 +84,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 +98,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 +108,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 +129,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 +180,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 +216,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] + "..."
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,14 +2,13 @@ package monitor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strconv"
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRunCheck_HTTP_Success(t *testing.T) {
|
func TestRunCheck_HTTP_Success(t *testing.T) {
|
||||||
@@ -133,7 +132,7 @@ func TestRunCheck_Port_Open(t *testing.T) {
|
|||||||
port, _ := strconv.Atoi(portStr)
|
port, _ := strconv.Atoi(portStr)
|
||||||
|
|
||||||
site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
|
site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
|
||||||
result := RunCheck(site, nil, nil, false, true)
|
result := RunCheck(site, nil, nil, false)
|
||||||
|
|
||||||
if result.Status != "UP" {
|
if result.Status != "UP" {
|
||||||
t.Errorf("expected UP, got %s", result.Status)
|
t.Errorf("expected UP, got %s", result.Status)
|
||||||
@@ -153,28 +152,10 @@ func TestRunCheck_Port_Closed(t *testing.T) {
|
|||||||
ln.Close()
|
ln.Close()
|
||||||
|
|
||||||
site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1}
|
site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1}
|
||||||
result := RunCheck(site, nil, nil, false, true)
|
|
||||||
|
|
||||||
if result.Status != "DOWN" {
|
|
||||||
t.Errorf("expected DOWN, got %s", result.Status)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) {
|
|
||||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer ln.Close()
|
|
||||||
|
|
||||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
|
||||||
port, _ := strconv.Atoi(portStr)
|
|
||||||
|
|
||||||
site := models.Site{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
|
|
||||||
result := RunCheck(site, nil, nil, false)
|
result := RunCheck(site, nil, nil, false)
|
||||||
|
|
||||||
if result.Status != "DOWN" {
|
if result.Status != "DOWN" {
|
||||||
t.Errorf("expected DOWN when private targets blocked, got %s", result.Status)
|
t.Errorf("expected DOWN, got %s", result.Status)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-258
@@ -4,33 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/internal/alert"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
|
"go-upkeep/internal/store"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/alert"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
maxLogEntries = 100
|
|
||||||
pollInterval = 5 * time.Second
|
|
||||||
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,47 +32,26 @@ 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
|
|
||||||
strictClient *http.Client
|
strictClient *http.Client
|
||||||
insecureClient *http.Client
|
insecureClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEngine(s store.Store) *Engine {
|
func NewEngine(s store.Store) *Engine {
|
||||||
return newEngine(s, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEngineWithOpts(s store.Store, allowPrivateTargets bool) *Engine {
|
|
||||||
return newEngine(s, allowPrivateTargets)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
|
|
||||||
dial := SafeDialContext(allowPrivateTargets)
|
|
||||||
return &Engine{
|
return &Engine{
|
||||||
liveState: make(map[int]models.Site),
|
liveState: make(map[int]models.Site),
|
||||||
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,
|
|
||||||
db: s,
|
db: s,
|
||||||
strictClient: &http.Client{
|
strictClient: &http.Client{
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: false}},
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: false},
|
|
||||||
DialContext: dial,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
insecureClient: &http.Client{
|
insecureClient: &http.Client{
|
||||||
Transport: &http.Transport{
|
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, //nolint:gosec // intentional for IgnoreTLS sites
|
||||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // intentional for IgnoreTLS sites
|
|
||||||
DialContext: dial,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,36 +60,14 @@ func (e *Engine) SetInsecureSkipVerify(skip bool) {
|
|||||||
e.insecureSkipVerify = skip
|
e.insecureSkipVerify = skip
|
||||||
}
|
}
|
||||||
|
|
||||||
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
|
|
||||||
|
|
||||||
func sanitizeLog(s string) string {
|
|
||||||
s = ansiRe.ReplaceAllString(s, "")
|
|
||||||
s = strings.ReplaceAll(s, "\n", "\\n")
|
|
||||||
s = strings.ReplaceAll(s, "\r", "")
|
|
||||||
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()
|
||||||
ts := time.Now().Format("15:04:05")
|
ts := time.Now().Format("15:04:05")
|
||||||
entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg))
|
entry := fmt.Sprintf("[%s] %s", ts, msg)
|
||||||
e.logStore = append([]string{entry}, e.logStore...)
|
e.logStore = append([]string{entry}, e.logStore...)
|
||||||
if len(e.logStore) > maxLogEntries {
|
if len(e.logStore) > 100 {
|
||||||
e.logStore = e.logStore[:maxLogEntries]
|
e.logStore = e.logStore[:100]
|
||||||
}
|
}
|
||||||
go func() { _ = e.db.SaveLog(entry) }()
|
go func() { _ = e.db.SaveLog(entry) }()
|
||||||
}
|
}
|
||||||
@@ -146,26 +85,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 +150,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,7 +192,7 @@ func (e *Engine) Start(ctx context.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
|
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
|
||||||
select {
|
select {
|
||||||
case <-time.After(pollInterval):
|
case <-time.After(5 * time.Second):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -307,6 +205,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"
|
||||||
@@ -325,7 +226,7 @@ func (e *Engine) Start(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-time.After(pollInterval):
|
case <-time.After(5 * time.Second):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -346,9 +247,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)
|
||||||
}
|
}
|
||||||
@@ -398,7 +296,7 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
|
|||||||
|
|
||||||
if !e.IsActive() {
|
if !e.IsActive() {
|
||||||
select {
|
select {
|
||||||
case <-time.After(pollInterval):
|
case <-time.After(5 * time.Second):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -414,7 +312,7 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
|
|||||||
|
|
||||||
if site.Paused {
|
if site.Paused {
|
||||||
select {
|
select {
|
||||||
case <-time.After(pollInterval):
|
case <-time.After(5 * time.Second):
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -422,8 +320,8 @@ func (e *Engine) monitorRoutine(ctx context.Context, id int) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interval := site.Interval
|
interval := site.Interval
|
||||||
if interval < minCheckInterval {
|
if interval < 5 {
|
||||||
interval = minCheckInterval
|
interval = 5
|
||||||
}
|
}
|
||||||
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
|
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
|
||||||
select {
|
select {
|
||||||
@@ -453,68 +351,39 @@ func (e *Engine) checkByID(id int) {
|
|||||||
case "group":
|
case "group":
|
||||||
e.checkGroup(site)
|
e.checkGroup(site)
|
||||||
default:
|
default:
|
||||||
result := RunCheck(site, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets)
|
result := RunCheck(site, e.strictClient, e.insecureClient, e.insecureSkipVerify)
|
||||||
updatedSite := site
|
updatedSite := site
|
||||||
updatedSite.HasSSL = result.HasSSL
|
updatedSite.HasSSL = result.HasSSL
|
||||||
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(5 * time.Second)
|
||||||
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: %s", site.Name, errorReason))
|
|
||||||
} else {
|
|
||||||
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", site.Name))
|
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 +395,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 +419,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 +432,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 +453,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,7 +518,7 @@ 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)
|
||||||
@@ -743,7 +528,6 @@ func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, i
|
|||||||
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 +552,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 +565,3 @@ func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
|
|||||||
}
|
}
|
||||||
return cp
|
return cp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *Engine) GetStateChanges(siteID int, limit int) []models.StateChange {
|
|
||||||
changes, err := e.db.GetStateChanges(siteID, limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return changes
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,11 +2,10 @@ package monitor
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mock Store ---
|
// --- Mock Store ---
|
||||||
@@ -63,10 +62,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -78,8 +73,6 @@ func (m *mockStore) EndMaintenanceWindow(int) error { re
|
|||||||
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) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
|
|
||||||
func (m *mockStore) Close() error { return nil }
|
func (m *mockStore) Close() error { return nil }
|
||||||
|
|
||||||
func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) {
|
func (m *mockStore) GetAllAlerts() ([]models.AlertConfig, error) {
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -1,68 +0,0 @@
|
|||||||
package monitor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var privateRanges []*net.IPNet
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
cidrs := []string{
|
|
||||||
"127.0.0.0/8",
|
|
||||||
"::1/128",
|
|
||||||
"10.0.0.0/8",
|
|
||||||
"172.16.0.0/12",
|
|
||||||
"192.168.0.0/16",
|
|
||||||
"169.254.0.0/16",
|
|
||||||
"fe80::/10",
|
|
||||||
"fc00::/7",
|
|
||||||
}
|
|
||||||
for _, cidr := range cidrs {
|
|
||||||
_, network, _ := net.ParseCIDR(cidr)
|
|
||||||
privateRanges = append(privateRanges, network)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func isPrivateIP(ip net.IP) bool {
|
|
||||||
for _, network := range privateRanges {
|
|
||||||
if network.Contains(ip) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func SafeDialContext(allowPrivate bool) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
||||||
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
||||||
host, port, err := net.SplitHostPort(addr)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if !allowPrivate {
|
|
||||||
for _, ip := range ips {
|
|
||||||
if isPrivateIP(ip.IP) {
|
|
||||||
return nil, fmt.Errorf("blocked: %s resolves to private address %s", host, ip.IP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
|
||||||
for _, ip := range ips {
|
|
||||||
target := net.JoinHostPort(ip.IP.String(), port)
|
|
||||||
conn, err := dialer.DialContext(ctx, network, target)
|
|
||||||
if err == nil {
|
|
||||||
return conn, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("failed to connect to %s", addr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
package monitor
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestIsPrivateIP(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
ip string
|
|
||||||
private bool
|
|
||||||
}{
|
|
||||||
{"127.0.0.1", true},
|
|
||||||
{"10.0.0.1", true},
|
|
||||||
{"172.16.0.1", true},
|
|
||||||
{"192.168.1.1", true},
|
|
||||||
{"169.254.169.254", true},
|
|
||||||
{"::1", true},
|
|
||||||
{"8.8.8.8", false},
|
|
||||||
{"1.1.1.1", false},
|
|
||||||
{"93.184.216.34", false},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
ip := net.ParseIP(tt.ip)
|
|
||||||
got := isPrivateIP(ip)
|
|
||||||
if got != tt.private {
|
|
||||||
t.Errorf("isPrivateIP(%s) = %v, want %v", tt.ip, got, tt.private)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSafeDialContext_BlocksPrivate(t *testing.T) {
|
|
||||||
dial := SafeDialContext(false)
|
|
||||||
_, err := dial(t.Context(), "tcp", "127.0.0.1:80")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error dialing loopback with private blocking enabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSafeDialContext_AllowsPrivate(t *testing.T) {
|
|
||||||
dial := SafeDialContext(true)
|
|
||||||
_, err := dial(t.Context(), "tcp", "127.0.0.1:80")
|
|
||||||
// Will fail to connect (nothing listening) but should NOT be blocked
|
|
||||||
if err != nil && err.Error() == "blocked: 127.0.0.1 resolves to private address 127.0.0.1" {
|
|
||||||
t.Error("should not block private IPs when allowPrivate is true")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type visitor struct {
|
|
||||||
tokens float64
|
|
||||||
lastSeen time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
type RateLimiter struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
visitors map[string]*visitor
|
|
||||||
rate float64
|
|
||||||
burst float64
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRateLimiter(requestsPerMinute int) *RateLimiter {
|
|
||||||
rl := &RateLimiter{
|
|
||||||
visitors: make(map[string]*visitor),
|
|
||||||
rate: float64(requestsPerMinute) / 60.0,
|
|
||||||
burst: float64(requestsPerMinute),
|
|
||||||
}
|
|
||||||
go rl.cleanup()
|
|
||||||
return rl
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rl *RateLimiter) Allow(ip string) bool {
|
|
||||||
rl.mu.Lock()
|
|
||||||
defer rl.mu.Unlock()
|
|
||||||
|
|
||||||
v, exists := rl.visitors[ip]
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
if !exists {
|
|
||||||
rl.visitors[ip] = &visitor{tokens: rl.burst - 1, lastSeen: now}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
elapsed := now.Sub(v.lastSeen).Seconds()
|
|
||||||
v.tokens += elapsed * rl.rate
|
|
||||||
if v.tokens > rl.burst {
|
|
||||||
v.tokens = rl.burst
|
|
||||||
}
|
|
||||||
v.lastSeen = now
|
|
||||||
|
|
||||||
if v.tokens < 1 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
v.tokens--
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (rl *RateLimiter) cleanup() {
|
|
||||||
for {
|
|
||||||
time.Sleep(5 * time.Minute)
|
|
||||||
rl.mu.Lock()
|
|
||||||
cutoff := time.Now().Add(-10 * time.Minute)
|
|
||||||
for ip, v := range rl.visitors {
|
|
||||||
if v.lastSeen.Before(cutoff) {
|
|
||||||
delete(rl.visitors, ip)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rl.mu.Unlock()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func clientIP(r *http.Request) string {
|
|
||||||
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
|
|
||||||
return fwd
|
|
||||||
}
|
|
||||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
|
||||||
if err != nil {
|
|
||||||
return r.RemoteAddr
|
|
||||||
}
|
|
||||||
return host
|
|
||||||
}
|
|
||||||
|
|
||||||
func RateLimit(limiter *RateLimiter, next http.HandlerFunc) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if !limiter.Allow(clientIP(r)) {
|
|
||||||
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
next(w, r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+34
-173
@@ -4,51 +4,23 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/internal/importer"
|
||||||
|
"go-upkeep/internal/metrics"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
|
"go-upkeep/internal/monitor"
|
||||||
|
"go-upkeep/internal/store"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/importer"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/metrics"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxRequestBody = 1 << 20
|
|
||||||
|
|
||||||
func checkSecret(got, want string) bool {
|
func checkSecret(got, want string) bool {
|
||||||
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
func extractBearerToken(r *http.Request) string {
|
|
||||||
auth := r.Header.Get("Authorization")
|
|
||||||
if strings.HasPrefix(auth, "Bearer ") {
|
|
||||||
return strings.TrimPrefix(auth, "Bearer ")
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var sensitiveKeys = map[string]bool{
|
|
||||||
"pass": true, "password": true, "token": true,
|
|
||||||
"routing_key": true, "user": true, "username": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
func redactSettings(settings map[string]string) map[string]string {
|
|
||||||
redacted := make(map[string]string, len(settings))
|
|
||||||
for k, v := range settings {
|
|
||||||
if sensitiveKeys[k] && v != "" {
|
|
||||||
redacted[k] = "***REDACTED***"
|
|
||||||
} else {
|
|
||||||
redacted[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return redacted
|
|
||||||
}
|
|
||||||
|
|
||||||
var statusTpl = template.Must(template.New("status").Parse(`
|
var statusTpl = template.Must(template.New("status").Parse(`
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
@@ -67,7 +39,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; }
|
||||||
@@ -88,7 +59,7 @@ var statusTpl = template.Must(template.New("status").Parse(`
|
|||||||
<div id="summary" class="summary"></div>
|
<div id="summary" class="summary"></div>
|
||||||
<div id="stale" class="stale-bar"></div>
|
<div id="stale" class="stale-bar"></div>
|
||||||
<div id="cards"></div>
|
<div id="cards"></div>
|
||||||
<div style="text-align: center; margin-top: 40px; color: #565f89; font-size: 0.8em;">Powered by uptop</div>
|
<div style="text-align: center; margin-top: 40px; color: #565f89; font-size: 0.8em;">Powered by Go-Upkeep</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
var lastUpdate = null;
|
var lastUpdate = null;
|
||||||
@@ -185,39 +156,18 @@ type ServerConfig struct {
|
|||||||
Port int
|
Port int
|
||||||
EnableStatus bool
|
EnableStatus bool
|
||||||
Title string
|
Title string
|
||||||
ClusterKey string
|
ClusterKey string // Shared Secret for Security
|
||||||
TLSCert string
|
|
||||||
TLSKey string
|
|
||||||
ClusterMode string
|
|
||||||
MetricsPublic bool
|
|
||||||
CORSOrigin string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
||||||
if cfg.ClusterKey == "" {
|
if cfg.ClusterKey == "" {
|
||||||
fmt.Println("WARNING: No UPTOP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
|
fmt.Println("WARNING: No UPKEEP_CLUSTER_SECRET set. Cluster API endpoints are unauthenticated.")
|
||||||
}
|
}
|
||||||
|
|
||||||
pushRL := NewRateLimiter(60)
|
|
||||||
probeRL := NewRateLimiter(30)
|
|
||||||
backupRL := NewRateLimiter(10)
|
|
||||||
statusRL := NewRateLimiter(120)
|
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// 1. Push Heartbeat
|
// 1. Push Heartbeat
|
||||||
mux.HandleFunc("/api/push", RateLimit(pushRL, func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/push", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
token := r.URL.Query().Get("token")
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
token := extractBearerToken(r)
|
|
||||||
if token == "" {
|
|
||||||
if qt := r.URL.Query().Get("token"); qt != "" {
|
|
||||||
token = qt
|
|
||||||
log.Printf("DEPRECATED: push token in query string — use Authorization: Bearer header instead")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if token == "" {
|
if token == "" {
|
||||||
http.Error(w, "Missing token", http.StatusBadRequest)
|
http.Error(w, "Missing token", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -228,14 +178,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
} else {
|
} else {
|
||||||
http.Error(w, "Invalid Token", http.StatusNotFound)
|
http.Error(w, "Invalid Token", http.StatusNotFound)
|
||||||
}
|
}
|
||||||
}))
|
})
|
||||||
|
|
||||||
// 2. Health Check (For Cluster Follower)
|
// 2. Health Check (For Cluster Follower)
|
||||||
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
if cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@@ -245,9 +191,9 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 3. Config Export
|
// 3. Config Export
|
||||||
mux.HandleFunc("/api/backup/export", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/backup/export", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
||||||
http.Error(w, "Unauthorized: UPTOP_CLUSTER_SECRET required", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized: UPKEEP_CLUSTER_SECRET required", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := s.ExportData()
|
data, err := s.ExportData()
|
||||||
@@ -256,16 +202,11 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
http.Error(w, "Export failed", http.StatusInternalServerError)
|
http.Error(w, "Export failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if r.URL.Query().Get("redact_secrets") != "false" {
|
|
||||||
for i := range data.Alerts {
|
|
||||||
data.Alerts[i].Settings = redactSettings(data.Alerts[i].Settings)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = json.NewEncoder(w).Encode(data) //nolint:errcheck
|
_ = json.NewEncoder(w).Encode(data) //nolint:errcheck
|
||||||
}))
|
})
|
||||||
|
|
||||||
// 4. Config Import
|
// 4. Config Import
|
||||||
mux.HandleFunc("/api/backup/import", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/backup/import", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -274,7 +215,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||||
var data models.Backup
|
var data models.Backup
|
||||||
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
|
||||||
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
http.Error(w, "Invalid JSON", http.StatusBadRequest)
|
||||||
@@ -286,10 +227,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte("Import Successful"))
|
_, _ = w.Write([]byte("Import Successful"))
|
||||||
}))
|
})
|
||||||
|
|
||||||
// 5. Kuma Import
|
// 5. Kuma Import
|
||||||
mux.HandleFunc("/api/import/kuma", RateLimit(backupRL, func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/import/kuma", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -298,7 +239,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||||
var kb importer.KumaBackup
|
var kb importer.KumaBackup
|
||||||
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&kb); err != nil {
|
||||||
log.Printf("Invalid Kuma JSON: %v", err)
|
log.Printf("Invalid Kuma JSON: %v", err)
|
||||||
@@ -312,10 +253,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version)
|
fmt.Fprintf(w, "Imported %d monitors, %d alerts from Kuma v%s", len(backup.Sites), len(backup.Alerts), kb.Version)
|
||||||
}))
|
})
|
||||||
|
|
||||||
// 6. Probe Registration
|
// 6. Probe Registration
|
||||||
mux.HandleFunc("/api/probe/register", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/probe/register", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -324,7 +265,7 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||||
var req struct {
|
var req struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -347,14 +288,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
|
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
|
||||||
}))
|
})
|
||||||
|
|
||||||
// 7. Probe Assignment Fetch
|
// 7. Probe Assignment Fetch
|
||||||
mux.HandleFunc("/api/probe/assignments", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/probe/assignments", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
if cfg.ClusterKey == "" || !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
@@ -388,10 +325,10 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) //nolint:errcheck
|
_ = json.NewEncoder(w).Encode(map[string][]models.Site{"sites": assigned}) //nolint:errcheck
|
||||||
}))
|
})
|
||||||
|
|
||||||
// 8. Probe Result Submission
|
// 8. Probe Result Submission
|
||||||
mux.HandleFunc("/api/probe/results", RateLimit(probeRL, func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/api/probe/results", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != "POST" {
|
if r.Method != "POST" {
|
||||||
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
http.Error(w, "POST required", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
@@ -400,14 +337,13 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxRequestBody)
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||||
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,33 +358,21 @@ 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)
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
|
_ = json.NewEncoder(w).Encode(map[string]bool{"ok": true}) //nolint:errcheck
|
||||||
}))
|
})
|
||||||
|
|
||||||
// 9. Prometheus Metrics
|
// 9. Prometheus Metrics
|
||||||
mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/metrics", metrics.Handler(eng))
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !cfg.MetricsPublic && cfg.ClusterKey != "" {
|
|
||||||
if !checkSecret(r.Header.Get("X-Upkeep-Secret"), cfg.ClusterKey) {
|
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
metrics.Handler(eng)(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 10. Status Page
|
// 10. Status Page
|
||||||
if cfg.EnableStatus {
|
if cfg.EnableStatus {
|
||||||
mux.HandleFunc("/status", RateLimit(statusRL, func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) }))
|
mux.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { renderStatusPage(w, cfg.Title, eng) })
|
||||||
mux.HandleFunc("/status/json", RateLimit(statusRL, func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/status/json", func(w http.ResponseWriter, r *http.Request) {
|
||||||
state := eng.GetLiveState()
|
state := eng.GetLiveState()
|
||||||
activeWindows, _ := s.GetActiveMaintenanceWindows()
|
activeWindows, _ := s.GetActiveMaintenanceWindows()
|
||||||
maintSet := make(map[int]bool)
|
maintSet := make(map[int]bool)
|
||||||
@@ -470,85 +394,22 @@ func Start(cfg ServerConfig, s store.Store, eng *monitor.Engine) *http.Server {
|
|||||||
}
|
}
|
||||||
state[id] = site
|
state[id] = site
|
||||||
}
|
}
|
||||||
if cfg.CORSOrigin != "" {
|
|
||||||
w.Header().Set("Access-Control-Allow-Origin", cfg.CORSOrigin)
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
_ = json.NewEncoder(w).Encode(state) //nolint:errcheck
|
_ = json.NewEncoder(w).Encode(state) //nolint:errcheck
|
||||||
}))
|
})
|
||||||
}
|
|
||||||
|
|
||||||
if cfg.ClusterMode != "" && cfg.ClusterMode != "leader" && cfg.TLSCert == "" {
|
|
||||||
fmt.Println("WARNING: Cluster mode active without TLS. Secrets transmitted in cleartext.")
|
|
||||||
}
|
|
||||||
|
|
||||||
handler := loggingMiddleware(securityHeadersMiddleware(mux))
|
|
||||||
if cfg.TLSCert != "" {
|
|
||||||
handler = hstsMiddleware(handler)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%d", cfg.Port)
|
addr := fmt.Sprintf(":%d", cfg.Port)
|
||||||
srv := &http.Server{
|
srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second}
|
||||||
Addr: addr,
|
|
||||||
Handler: handler,
|
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
|
||||||
ReadTimeout: 30 * time.Second,
|
|
||||||
WriteTimeout: 60 * time.Second,
|
|
||||||
IdleTimeout: 120 * time.Second,
|
|
||||||
}
|
|
||||||
go func() {
|
go func() {
|
||||||
if cfg.TLSCert != "" && cfg.TLSKey != "" {
|
|
||||||
fmt.Printf("HTTPS Server listening on %s\n", addr)
|
|
||||||
if err := srv.ListenAndServeTLS(cfg.TLSCert, cfg.TLSKey); err != nil && err != http.ErrServerClosed {
|
|
||||||
log.Printf("HTTPS server error: %v", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
fmt.Printf("HTTP Server listening on %s\n", addr)
|
fmt.Printf("HTTP Server listening on %s\n", addr)
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Printf("HTTP server error: %v", err)
|
log.Printf("HTTP server error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
return srv
|
return srv
|
||||||
}
|
}
|
||||||
|
|
||||||
type statusWriter struct {
|
|
||||||
http.ResponseWriter
|
|
||||||
code int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *statusWriter) WriteHeader(code int) {
|
|
||||||
w.code = code
|
|
||||||
w.ResponseWriter.WriteHeader(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loggingMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
start := time.Now()
|
|
||||||
sw := &statusWriter{ResponseWriter: w, code: 200}
|
|
||||||
next.ServeHTTP(sw, r)
|
|
||||||
path := strings.ReplaceAll(strings.ReplaceAll(r.URL.Path, "\n", ""), "\r", "")
|
|
||||||
log.Printf("%s %s %d %s %s", r.Method, path, sw.code, time.Since(start).Round(time.Millisecond), clientIP(r)) //nolint:gosec // path sanitized above
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func securityHeadersMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
||||||
w.Header().Set("X-Frame-Options", "DENY")
|
|
||||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
||||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'")
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func hstsMiddleware(next http.Handler) http.Handler {
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Strict-Transport-Security", "max-age=63072000; includeSubDomains")
|
|
||||||
next.ServeHTTP(w, r)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) {
|
func renderStatusPage(w http.ResponseWriter, title string, eng *monitor.Engine) {
|
||||||
sites := eng.GetAllSites()
|
sites := eng.GetAllSites()
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
|
"go-upkeep/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,10 +64,6 @@ func (m *mockStore) AddAlertReturningID(string, string, map[string]string) (int,
|
|||||||
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
func (m *mockStore) GetAllNodes() ([]models.ProbeNode, error) { return nil, nil }
|
||||||
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
func (m *mockStore) UpdateNodeLastSeen(string) error { return nil }
|
||||||
func (m *mockStore) DeleteNode(string) error { return nil }
|
func (m *mockStore) DeleteNode(string) error { return nil }
|
||||||
func (m *mockStore) LoadAlertHealth() (map[int]models.AlertHealthRecord, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *mockStore) SaveAlertHealth(models.AlertHealthRecord) error { return nil }
|
|
||||||
func (m *mockStore) SaveLog(string) error { return nil }
|
func (m *mockStore) SaveLog(string) error { return nil }
|
||||||
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
func (m *mockStore) LoadLogs(int) ([]string, error) { return nil, nil }
|
||||||
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
func (m *mockStore) GetAllMaintenanceWindows(int) ([]models.MaintenanceWindow, error) {
|
||||||
@@ -80,8 +75,6 @@ func (m *mockStore) DeleteMaintenanceWindow(int) error { re
|
|||||||
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) GetStateChanges(int, int) ([]models.StateChange, error) { return nil, nil }
|
|
||||||
func (m *mockStore) Close() error { return nil }
|
func (m *mockStore) Close() error { return nil }
|
||||||
|
|
||||||
func (m *mockStore) ExportData() (models.Backup, error) {
|
func (m *mockStore) ExportData() (models.Backup, error) {
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const encryptedPrefix = "enc:"
|
|
||||||
|
|
||||||
type Encryptor struct {
|
|
||||||
gcm cipher.AEAD
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewEncryptor(hexKey string) (*Encryptor, error) {
|
|
||||||
key, err := hex.DecodeString(hexKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid encryption key: must be hex-encoded: %w", err)
|
|
||||||
}
|
|
||||||
if len(key) != 32 {
|
|
||||||
return nil, fmt.Errorf("invalid encryption key: must be 32 bytes (64 hex chars), got %d bytes", len(key))
|
|
||||||
}
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create cipher: %w", err)
|
|
||||||
}
|
|
||||||
gcm, err := cipher.NewGCM(block)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create GCM: %w", err)
|
|
||||||
}
|
|
||||||
return &Encryptor{gcm: gcm}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Encryptor) Encrypt(plaintext string) (string, error) {
|
|
||||||
nonce := make([]byte, e.gcm.NonceSize())
|
|
||||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
|
||||||
return "", fmt.Errorf("generate nonce: %w", err)
|
|
||||||
}
|
|
||||||
ciphertext := e.gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
|
||||||
return encryptedPrefix + base64.StdEncoding.EncodeToString(ciphertext), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *Encryptor) Decrypt(data string) (string, error) {
|
|
||||||
if !strings.HasPrefix(data, encryptedPrefix) {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(data, encryptedPrefix))
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("decode base64: %w", err)
|
|
||||||
}
|
|
||||||
nonceSize := e.gcm.NonceSize()
|
|
||||||
if len(raw) < nonceSize {
|
|
||||||
return "", fmt.Errorf("ciphertext too short")
|
|
||||||
}
|
|
||||||
nonce, ciphertext := raw[:nonceSize], raw[nonceSize:]
|
|
||||||
plaintext, err := e.gcm.Open(nil, nonce, ciphertext, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("decrypt: %w", err)
|
|
||||||
}
|
|
||||||
return string(plaintext), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func IsEncrypted(data string) bool {
|
|
||||||
return strings.HasPrefix(data, encryptedPrefix)
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package store
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/hex"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func testKey() string {
|
|
||||||
key := make([]byte, 32)
|
|
||||||
for i := range key {
|
|
||||||
key[i] = byte(i)
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEncryptorRoundTrip(t *testing.T) {
|
|
||||||
enc, err := NewEncryptor(testKey())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
original := `{"host":"smtp.example.com","pass":"s3cret"}`
|
|
||||||
encrypted, err := enc.Encrypt(original)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !IsEncrypted(encrypted) {
|
|
||||||
t.Error("expected encrypted prefix")
|
|
||||||
}
|
|
||||||
if encrypted == original {
|
|
||||||
t.Error("encrypted should differ from original")
|
|
||||||
}
|
|
||||||
|
|
||||||
decrypted, err := enc.Decrypt(encrypted)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if decrypted != original {
|
|
||||||
t.Errorf("got %q, want %q", decrypted, original)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEncryptorDecryptPlaintext(t *testing.T) {
|
|
||||||
enc, err := NewEncryptor(testKey())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
plain := `{"url":"https://hooks.slack.com/test"}`
|
|
||||||
result, err := enc.Decrypt(plain)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if result != plain {
|
|
||||||
t.Errorf("plaintext passthrough failed: got %q", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEncryptorBadKey(t *testing.T) {
|
|
||||||
_, err := NewEncryptor("tooshort")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for short key")
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = NewEncryptor("not-hex-at-all-but-long-enough-to-be-64-chars-if-we-keep-going!!")
|
|
||||||
if err == nil {
|
|
||||||
t.Error("expected error for non-hex key")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEncryptorUniqueCiphertexts(t *testing.T) {
|
|
||||||
enc, err := NewEncryptor(testKey())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
a, _ := enc.Encrypt("same")
|
|
||||||
b, _ := enc.Encrypt("same")
|
|
||||||
if a == b {
|
|
||||||
t.Error("two encryptions of same plaintext should produce different ciphertexts")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import "database/sql"
|
||||||
"database/sql"
|
|
||||||
"strconv"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Dialect interface {
|
type Dialect interface {
|
||||||
DriverName() string
|
DriverName() string
|
||||||
@@ -14,9 +11,10 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rewritePlaceholders converts ? markers to $1, $2, etc. for Postgres.
|
||||||
|
// For SQLite (or any dialect not needing rewrite), returns the input unchanged.
|
||||||
func rewritePlaceholders(query string, dollarStyle bool) string {
|
func rewritePlaceholders(query string, dollarStyle bool) string {
|
||||||
if !dollarStyle {
|
if !dollarStyle {
|
||||||
return query
|
return query
|
||||||
@@ -27,7 +25,10 @@ func rewritePlaceholders(query string, dollarStyle bool) string {
|
|||||||
if query[i] == '?' {
|
if query[i] == '?' {
|
||||||
n++
|
n++
|
||||||
buf = append(buf, '$')
|
buf = append(buf, '$')
|
||||||
buf = append(buf, []byte(strconv.Itoa(n))...)
|
if n >= 10 {
|
||||||
|
buf = append(buf, byte('0'+n/10))
|
||||||
|
}
|
||||||
|
buf = append(buf, byte('0'+n%10))
|
||||||
} else {
|
} else {
|
||||||
buf = append(buf, query[i])
|
buf = append(buf, query[i])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -10,14 +10,7 @@ import (
|
|||||||
type SQLiteDialect struct{}
|
type SQLiteDialect struct{}
|
||||||
|
|
||||||
func NewSQLiteStore(path string) (*SQLStore, error) {
|
func NewSQLiteStore(path string) (*SQLStore, error) {
|
||||||
s, err := NewSQLStore("sqlite3", path, &SQLiteDialect{})
|
return NewSQLStore("sqlite3", path, &SQLiteDialect{})
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if _, err := s.db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
|
||||||
log.Printf("WAL mode failed: %v", err)
|
|
||||||
}
|
|
||||||
return s, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *SQLiteDialect) DriverName() string { return "sqlite3" }
|
func (d *SQLiteDialect) DriverName() string { return "sqlite3" }
|
||||||
@@ -79,23 +72,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 +97,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
|
||||||
|
|||||||
+32
-171
@@ -6,24 +6,15 @@ import (
|
|||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"go-upkeep/internal/models"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
maxCheckHistory = 1000
|
|
||||||
checkHistoryPruneAt = 1100
|
|
||||||
maxMaintenanceExport = 1000
|
|
||||||
maxRequestBody = 1 << 20
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type SQLStore struct {
|
type SQLStore struct {
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
dialect Dialect
|
dialect Dialect
|
||||||
dollar bool
|
dollar bool
|
||||||
encryptor *Encryptor
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
|
func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
|
||||||
@@ -31,31 +22,10 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
db.SetMaxOpenConns(25)
|
|
||||||
db.SetMaxIdleConns(5)
|
|
||||||
db.SetConnMaxLifetime(5 * time.Minute)
|
|
||||||
_, isDollar := dialect.(*PostgresDialect)
|
_, isDollar := dialect.(*PostgresDialect)
|
||||||
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
|
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) SetEncryptor(enc *Encryptor) {
|
|
||||||
s.encryptor = enc
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) encryptSettings(jsonStr string) (string, error) {
|
|
||||||
if s.encryptor == nil {
|
|
||||||
return jsonStr, nil
|
|
||||||
}
|
|
||||||
return s.encryptor.Encrypt(jsonStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) decryptSettings(data string) (string, error) {
|
|
||||||
if s.encryptor == nil {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
return s.encryptor.Decrypt(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) q(query string) string {
|
func (s *SQLStore) q(query string) string {
|
||||||
return rewritePlaceholders(query, s.dollar)
|
return rewritePlaceholders(query, s.dollar)
|
||||||
}
|
}
|
||||||
@@ -80,11 +50,7 @@ func (s *SQLStore) Init() error {
|
|||||||
}
|
}
|
||||||
for _, m := range s.dialect.MigrationsSQL() {
|
for _, m := range s.dialect.MigrationsSQL() {
|
||||||
if _, err := s.db.Exec(m); err != nil {
|
if _, err := s.db.Exec(m); err != nil {
|
||||||
errMsg := err.Error()
|
log.Printf("migration error: %v", err)
|
||||||
if strings.Contains(errMsg, "already exists") || strings.Contains(errMsg, "duplicate column") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
return fmt.Errorf("migration failed: %w", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -174,82 +140,39 @@ func (s *SQLStore) GetSiteByName(name string) (models.Site, error) {
|
|||||||
return st, err
|
return st, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) unmarshalSettings(raw string) (map[string]string, error) {
|
|
||||||
decrypted, err := s.decryptSettings(raw)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("decrypt settings: %w", err)
|
|
||||||
}
|
|
||||||
var m map[string]string
|
|
||||||
if err := json.Unmarshal([]byte(decrypted), &m); err != nil {
|
|
||||||
return nil, fmt.Errorf("unmarshal settings: %w", err)
|
|
||||||
}
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) marshalSettings(settings map[string]string) (string, error) {
|
|
||||||
jsonBytes, err := json.Marshal(settings)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return s.encryptSettings(string(jsonBytes))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SQLStore) GetAlertByName(name string) (models.AlertConfig, error) {
|
func (s *SQLStore) GetAlertByName(name string) (models.AlertConfig, error) {
|
||||||
var a models.AlertConfig
|
var a models.AlertConfig
|
||||||
var settingsRaw string
|
var settingsJSON string
|
||||||
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
|
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil {
|
||||||
if err != nil {
|
return a, fmt.Errorf("unmarshal alert settings: %w", err)
|
||||||
return a, fmt.Errorf("alert %q: %w", name, err)
|
|
||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) AddSiteReturningID(site models.Site) (int, error) {
|
func (s *SQLStore) AddSiteReturningID(site models.Site) (int, error) {
|
||||||
token := ""
|
if err := s.AddSite(site); err != nil {
|
||||||
if site.Type == "push" {
|
return 0, err
|
||||||
var err error
|
|
||||||
token, err = generateToken()
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("generate push token: %w", err)
|
|
||||||
}
|
}
|
||||||
}
|
created, err := s.GetSiteByName(site.Name)
|
||||||
if s.dollar {
|
|
||||||
var id int
|
|
||||||
err := s.db.QueryRow(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id"),
|
|
||||||
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
|
|
||||||
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions).Scan(&id)
|
|
||||||
return id, err
|
|
||||||
}
|
|
||||||
result, err := s.db.Exec(s.q("INSERT INTO sites (name, url, type, token, interval, alert_id, check_ssl, threshold, max_retries, hostname, port, timeout, method, description, parent_id, accepted_codes, dns_resolve_type, dns_server, ignore_tls, paused, regions) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
|
|
||||||
site.Name, site.URL, site.Type, token, site.Interval, site.AlertID, site.CheckSSL, site.ExpiryThreshold, site.MaxRetries,
|
|
||||||
site.Hostname, site.Port, site.Timeout, site.Method, site.Description, site.ParentID, site.AcceptedCodes, site.DNSResolveType, site.DNSServer, site.IgnoreTLS, site.Paused, site.Regions)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
id, err := result.LastInsertId()
|
return created.ID, nil
|
||||||
return int(id), err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) AddAlertReturningID(name, aType string, settings map[string]string) (int, error) {
|
func (s *SQLStore) AddAlertReturningID(name, aType string, settings map[string]string) (int, error) {
|
||||||
stored, err := s.marshalSettings(settings)
|
if err := s.AddAlert(name, aType, settings); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
created, err := s.GetAlertByName(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
if s.dollar {
|
return created.ID, nil
|
||||||
var id int
|
|
||||||
err := s.db.QueryRow(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?) RETURNING id"), name, aType, stored).Scan(&id)
|
|
||||||
return id, err
|
|
||||||
}
|
|
||||||
result, err := s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
id, err := result.LastInsertId()
|
|
||||||
return int(id), err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
|
func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
|
||||||
@@ -261,13 +184,12 @@ func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
|
|||||||
var alerts []models.AlertConfig
|
var alerts []models.AlertConfig
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var a models.AlertConfig
|
var a models.AlertConfig
|
||||||
var settingsRaw string
|
var settingsJSON string
|
||||||
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil {
|
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsJSON); err != nil {
|
||||||
return alerts, err
|
return alerts, err
|
||||||
}
|
}
|
||||||
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil {
|
||||||
if err != nil {
|
return alerts, fmt.Errorf("unmarshal alert settings for %q: %w", a.Name, err)
|
||||||
return alerts, fmt.Errorf("alert %q: %w", a.Name, err)
|
|
||||||
}
|
}
|
||||||
alerts = append(alerts, a)
|
alerts = append(alerts, a)
|
||||||
}
|
}
|
||||||
@@ -276,33 +198,32 @@ func (s *SQLStore) GetAllAlerts() ([]models.AlertConfig, error) {
|
|||||||
|
|
||||||
func (s *SQLStore) GetAlert(id int) (models.AlertConfig, error) {
|
func (s *SQLStore) GetAlert(id int) (models.AlertConfig, error) {
|
||||||
var a models.AlertConfig
|
var a models.AlertConfig
|
||||||
var settingsRaw string
|
var settingsJSON string
|
||||||
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
|
err := s.db.QueryRow(s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsJSON)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return a, err
|
return a, err
|
||||||
}
|
}
|
||||||
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
if err := json.Unmarshal([]byte(settingsJSON), &a.Settings); err != nil {
|
||||||
if err != nil {
|
return a, fmt.Errorf("unmarshal alert settings: %w", err)
|
||||||
return a, fmt.Errorf("alert %d: %w", id, err)
|
|
||||||
}
|
}
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) error {
|
func (s *SQLStore) AddAlert(name, aType string, settings map[string]string) error {
|
||||||
stored, err := s.marshalSettings(settings)
|
jsonBytes, err := json.Marshal(settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
|
_, err = s.db.Exec(s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, string(jsonBytes))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) error {
|
func (s *SQLStore) UpdateAlert(id int, name, aType string, settings map[string]string) error {
|
||||||
stored, err := s.marshalSettings(settings)
|
jsonBytes, err := json.Marshal(settings)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
|
_, err = s.db.Exec(s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, string(jsonBytes), id)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -347,29 +268,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)
|
||||||
}
|
}
|
||||||
@@ -379,16 +277,10 @@ func (s *SQLStore) SaveCheckFromNode(siteID int, nodeID string, latencyNs int64,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var count int
|
_, err = s.db.Exec(s.q(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN (
|
||||||
_ = s.db.QueryRow(s.q("SELECT COUNT(*) FROM check_history WHERE site_id = ?"), siteID).Scan(&count)
|
SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT 1000
|
||||||
if count > checkHistoryPruneAt {
|
)`), siteID, siteID)
|
||||||
pruneQuery := fmt.Sprintf(`DELETE FROM check_history WHERE site_id = ? AND id NOT IN (
|
|
||||||
SELECT id FROM check_history WHERE site_id = ? ORDER BY checked_at DESC LIMIT %d
|
|
||||||
)`, maxCheckHistory)
|
|
||||||
_, err = s.db.Exec(s.q(pruneQuery), siteID, siteID)
|
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SQLStore) RegisterNode(node models.ProbeNode) error {
|
func (s *SQLStore) RegisterNode(node models.ProbeNode) error {
|
||||||
@@ -430,37 +322,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 {
|
||||||
@@ -632,7 +493,7 @@ func (s *SQLStore) ExportData() (models.Backup, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return models.Backup{}, err
|
return models.Backup{}, err
|
||||||
}
|
}
|
||||||
windows, err := s.GetAllMaintenanceWindows(maxMaintenanceExport)
|
windows, err := s.GetAllMaintenanceWindows(1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return models.Backup{}, err
|
return models.Backup{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"go-upkeep/internal/models"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
"go-upkeep/internal/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Store interface {
|
type Store interface {
|
||||||
@@ -38,10 +38,6 @@ type Store interface {
|
|||||||
SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error
|
SaveCheckFromNode(siteID int, nodeID string, latencyNs int64, isUp bool) error
|
||||||
LoadAllHistory(limit int) (map[int][]models.CheckRecord, error)
|
LoadAllHistory(limit int) (map[int][]models.CheckRecord, error)
|
||||||
|
|
||||||
// State Changes
|
|
||||||
SaveStateChange(siteID int, fromStatus, toStatus, errorReason string) error
|
|
||||||
GetStateChanges(siteID int, limit int) ([]models.StateChange, error)
|
|
||||||
|
|
||||||
// Nodes
|
// Nodes
|
||||||
RegisterNode(node models.ProbeNode) error
|
RegisterNode(node models.ProbeNode) error
|
||||||
GetNode(id string) (models.ProbeNode, error)
|
GetNode(id string) (models.ProbeNode, error)
|
||||||
@@ -49,10 +45,6 @@ type Store interface {
|
|||||||
UpdateNodeLastSeen(id string) error
|
UpdateNodeLastSeen(id string) error
|
||||||
DeleteNode(id string) error
|
DeleteNode(id string) error
|
||||||
|
|
||||||
// Alert Health
|
|
||||||
LoadAlertHealth() (map[int]models.AlertHealthRecord, error)
|
|
||||||
SaveAlertHealth(h models.AlertHealthRecord) error
|
|
||||||
|
|
||||||
// Logs
|
// Logs
|
||||||
SaveLog(message string) error
|
SaveLog(message string) error
|
||||||
LoadLogs(limit int) ([]string, error)
|
LoadLogs(limit int) ([]string, error)
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/huh"
|
"github.com/charmbracelet/huh"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -116,122 +113,34 @@ func fmtAlertConfig(alert struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func fmtAlertHealth(h monitor.AlertHealth) string {
|
|
||||||
if h.LastSendAt.IsZero() {
|
|
||||||
return subtleStyle.Render("●")
|
|
||||||
}
|
|
||||||
if h.LastSendOK {
|
|
||||||
return specialStyle.Render("●")
|
|
||||||
}
|
|
||||||
return dangerStyle.Render("●")
|
|
||||||
}
|
|
||||||
|
|
||||||
func fmtAlertLastSent(h monitor.AlertHealth) string {
|
|
||||||
if h.LastSendAt.IsZero() {
|
|
||||||
return subtleStyle.Render("never")
|
|
||||||
}
|
|
||||||
d := time.Since(h.LastSendAt)
|
|
||||||
if d < time.Minute {
|
|
||||||
return fmt.Sprintf("%ds ago", int(d.Seconds()))
|
|
||||||
}
|
|
||||||
if d < time.Hour {
|
|
||||||
return fmt.Sprintf("%dm ago", int(d.Minutes()))
|
|
||||||
}
|
|
||||||
if d < 24*time.Hour {
|
|
||||||
return fmt.Sprintf("%dh ago", int(d.Hours()))
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("%dd ago", int(d.Hours())/24)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m Model) viewAlertsTab() string {
|
func (m Model) viewAlertsTab() string {
|
||||||
if len(m.alerts) == 0 {
|
if len(m.alerts) == 0 {
|
||||||
return "\n No alert channels configured. Press [n] to add one."
|
return "\n No alert channels configured. Press [n] to add one."
|
||||||
}
|
}
|
||||||
|
|
||||||
var headers []string
|
|
||||||
var widths []int
|
|
||||||
if m.isWide() {
|
|
||||||
headers = []string{"#", "", "NAME", "TYPE", "CONFIG", "LAST SENT"}
|
|
||||||
widths = []int{4, 3, 18, 12, 40, 12}
|
|
||||||
} else {
|
|
||||||
headers = []string{"#", "", "NAME", "TYPE", "CONFIG", "SENT"}
|
|
||||||
widths = []int{4, 3, 14, 10, 24, 8}
|
|
||||||
}
|
|
||||||
nameW := widths[2]
|
|
||||||
cfgW := widths[4]
|
|
||||||
|
|
||||||
return m.renderTable(
|
return m.renderTable(
|
||||||
headers,
|
[]string{"#", "NAME", "TYPE", "CONFIG"},
|
||||||
len(m.alerts),
|
len(m.alerts),
|
||||||
func(start, end int) [][]string {
|
func(start, end int) [][]string {
|
||||||
var rows [][]string
|
var rows [][]string
|
||||||
for i := start; i < end; i++ {
|
for i := start; i < end; i++ {
|
||||||
a := m.alerts[i]
|
a := m.alerts[i]
|
||||||
h := m.engine.GetAlertHealth(a.ID)
|
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
fmt.Sprintf("%d", i+1),
|
fmt.Sprintf("%d", i+1),
|
||||||
fmtAlertHealth(h),
|
m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, 15)),
|
||||||
m.zones.Mark(fmt.Sprintf("alert-%d", i), limitStr(a.Name, nameW-2)),
|
|
||||||
fmtAlertType(a.Type),
|
fmtAlertType(a.Type),
|
||||||
limitStr(fmtAlertConfig(struct {
|
fmtAlertConfig(struct {
|
||||||
Type string
|
Type string
|
||||||
Settings map[string]string
|
Settings map[string]string
|
||||||
}{a.Type, a.Settings}), cfgW-2),
|
}{a.Type, a.Settings}),
|
||||||
fmtAlertLastSent(h),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
},
|
},
|
||||||
widths, nil,
|
nil, nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewAlertDetailPanel() string {
|
|
||||||
if m.cursor >= len(m.alerts) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
a := m.alerts[m.cursor]
|
|
||||||
h := m.engine.GetAlertHealth(a.ID)
|
|
||||||
|
|
||||||
var b strings.Builder
|
|
||||||
|
|
||||||
b.WriteString(subtleStyle.Render(" Alerts > ") + titleStyle.Render(a.Name) + "\n\n")
|
|
||||||
|
|
||||||
row := func(label, value string) {
|
|
||||||
fmt.Fprintf(&b, " %-16s %s\n", subtleStyle.Render(label), value)
|
|
||||||
}
|
|
||||||
|
|
||||||
row("Type", fmtAlertType(a.Type))
|
|
||||||
|
|
||||||
if h.LastSendAt.IsZero() {
|
|
||||||
row("Health", subtleStyle.Render("never sent"))
|
|
||||||
} else if h.LastSendOK {
|
|
||||||
row("Health", specialStyle.Render("OK"))
|
|
||||||
} else {
|
|
||||||
row("Health", dangerStyle.Render("FAILED"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if !h.LastSendAt.IsZero() {
|
|
||||||
row("Last Sent", h.LastSendAt.Format("2006-01-02 15:04:05")+" ("+fmtAlertLastSent(h)+")")
|
|
||||||
}
|
|
||||||
if h.SendCount > 0 {
|
|
||||||
row("Sends", fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount))
|
|
||||||
}
|
|
||||||
if h.LastError != "" {
|
|
||||||
row("Last Error", dangerStyle.Render(limitStr(h.LastError, 60)))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n" + subtleStyle.Render(" CONFIGURATION") + "\n")
|
|
||||||
for k, v := range a.Settings {
|
|
||||||
row(k, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
b.WriteString("\n\n")
|
|
||||||
b.WriteString(subtleStyle.Render(" [i/Esc] Back [e] Edit [t] Test [q] Quit"))
|
|
||||||
|
|
||||||
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
func (m *Model) initAlertHuhForm() tea.Cmd {
|
func (m *Model) initAlertHuhForm() tea.Cmd {
|
||||||
m.alertFormData = &alertFormData{
|
m.alertFormData = &alertFormData{
|
||||||
AlertType: "discord",
|
AlertType: "discord",
|
||||||
@@ -410,7 +319,7 @@ func (m *Model) initAlertHuhForm() tea.Cmd {
|
|||||||
).Title("Gotify Settings").WithHideFunc(func() bool {
|
).Title("Gotify Settings").WithHideFunc(func() bool {
|
||||||
return m.alertFormData.AlertType != "gotify"
|
return m.alertFormData.AlertType != "gotify"
|
||||||
}),
|
}),
|
||||||
).WithTheme(m.theme.HuhTheme())
|
).WithTheme(huh.ThemeDracula())
|
||||||
|
|
||||||
return m.huhForm.Init()
|
return m.huhForm.Init()
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-88
@@ -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
|
|
||||||
}
|
|
||||||
shown++
|
|
||||||
rendered = append(rendered, renderLogLine(line))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
filterLabel := "All"
|
count := 0
|
||||||
if m.logFilterImportant {
|
for _, l := range lines {
|
||||||
filterLabel = "Important"
|
if strings.TrimSpace(l) != "" {
|
||||||
|
count++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
header := subtleStyle.Render(fmt.Sprintf(
|
header := subtleStyle.Render(fmt.Sprintf(" %d entries [↑/↓] Scroll [PgUp/PgDn] Page", count))
|
||||||
" %d entries [↑/↓] Scroll [PgUp/PgDn] Page [f] Filter: %s", shown, filterLabel))
|
return "\n" + header + "\n\n" + strings.Join(colored, "\n")
|
||||||
|
|
||||||
if m.logFilterImportant && shown < total {
|
|
||||||
header += subtleStyle.Render(fmt.Sprintf(" (%d hidden)", total-shown))
|
|
||||||
}
|
|
||||||
|
|
||||||
return "\n" + header + "\n\n" + strings.Join(rendered, "\n")
|
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-29
@@ -2,17 +2,16 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/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"
|
||||||
)
|
)
|
||||||
|
|
||||||
var maintStyle lipgloss.Style
|
var maintStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#bb9af7"))
|
||||||
|
|
||||||
type maintFormData struct {
|
type maintFormData struct {
|
||||||
Title string
|
Title string
|
||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -204,7 +187,7 @@ func (m *Model) initMaintHuhForm() tea.Cmd {
|
|||||||
).Title("Duration").WithHideFunc(func() bool {
|
).Title("Duration").WithHideFunc(func() bool {
|
||||||
return m.maintFormData.Type == "incident"
|
return m.maintFormData.Type == "incident"
|
||||||
}),
|
}),
|
||||||
).WithTheme(m.theme.HuhTheme())
|
).WithTheme(huh.ThemeDracula())
|
||||||
|
|
||||||
return m.huhForm.Init()
|
return m.huhForm.Init()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-211
@@ -2,13 +2,12 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"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"
|
||||||
@@ -38,7 +37,10 @@ func typeIcon(siteType string, collapsed bool) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var siteGroupStyle lipgloss.Style
|
var siteGroupStyle = lipgloss.NewStyle().
|
||||||
|
Padding(0, 1).
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#7D56F4"))
|
||||||
|
|
||||||
type siteFormData struct {
|
type siteFormData struct {
|
||||||
Name string
|
Name string
|
||||||
@@ -60,18 +62,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 +87,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,10 +96,6 @@ func latencySparkline(latencies []time.Duration, statuses []bool, width int) str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ch := string(sparkChars[idx])
|
ch := string(sparkChars[idx])
|
||||||
isDown := i < len(sampledStatuses) && !sampledStatuses[i]
|
|
||||||
if isDown {
|
|
||||||
sb.WriteString(dangerStyle.Render(ch))
|
|
||||||
} else {
|
|
||||||
ms := l.Milliseconds()
|
ms := l.Milliseconds()
|
||||||
if ms < 200 {
|
if ms < 200 {
|
||||||
sb.WriteString(specialStyle.Render(ch))
|
sb.WriteString(specialStyle.Render(ch))
|
||||||
@@ -111,7 +105,6 @@ func latencySparkline(latencies []time.Duration, statuses []bool, width int) str
|
|||||||
sb.WriteString(dangerStyle.Render(ch))
|
sb.WriteString(dangerStyle.Render(ch))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,8 +304,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 +311,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 {
|
||||||
@@ -415,26 +340,22 @@ func (m Model) viewSitesTab() string {
|
|||||||
if len(m.sites) == 0 {
|
if len(m.sites) == 0 {
|
||||||
welcome := lipgloss.NewStyle().
|
welcome := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(m.theme.Accent).
|
BorderForeground(lipgloss.Color("#7D56F4")).
|
||||||
Padding(1, 3).
|
Padding(1, 3).
|
||||||
Render(
|
Render(
|
||||||
titleStyle.Render("uptop") + "\n\n" +
|
titleStyle.Render("Go-Upkeep") + "\n\n" +
|
||||||
"No monitors configured yet.\n\n" +
|
"No monitors configured yet.\n\n" +
|
||||||
subtleStyle.Render("[n] Add your first monitor"),
|
subtleStyle.Render("[n] Add your first monitor"),
|
||||||
)
|
)
|
||||||
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 +368,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 +386,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 +396,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 +413,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
|
||||||
@@ -596,7 +509,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
Description("Required for HTTP monitors").
|
Description("Required for HTTP monitors").
|
||||||
Value(&m.siteFormData.URL).
|
Value(&m.siteFormData.URL).
|
||||||
Validate(func(s string) error {
|
Validate(func(s string) error {
|
||||||
if m.siteFormData.SiteType != "http" {
|
if m.siteFormData.SiteType == "push" || m.siteFormData.SiteType == "group" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if s == "" {
|
if s == "" {
|
||||||
@@ -642,15 +555,12 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
Description("Target port for TCP port monitors").
|
Description("Target port for TCP port monitors").
|
||||||
Value(&m.siteFormData.Port).
|
Value(&m.siteFormData.Port).
|
||||||
Validate(func(s string) error {
|
Validate(func(s string) error {
|
||||||
if m.siteFormData.SiteType != "port" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
v, err := strconv.Atoi(s)
|
v, err := strconv.Atoi(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("must be a number")
|
return fmt.Errorf("must be a number")
|
||||||
}
|
}
|
||||||
if v < 1 || v > 65535 {
|
if v < 0 || v > 65535 {
|
||||||
return fmt.Errorf("port must be 1-65535")
|
return fmt.Errorf("port must be 0-65535")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}),
|
}),
|
||||||
@@ -705,9 +615,6 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
Placeholder("7").
|
Placeholder("7").
|
||||||
Value(&m.siteFormData.Threshold).
|
Value(&m.siteFormData.Threshold).
|
||||||
Validate(func(s string) error {
|
Validate(func(s string) error {
|
||||||
if !m.siteFormData.CheckSSL {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
v, err := strconv.Atoi(s)
|
v, err := strconv.Atoi(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("must be a number")
|
return fmt.Errorf("must be a number")
|
||||||
@@ -721,9 +628,6 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
Placeholder("0").
|
Placeholder("0").
|
||||||
Value(&m.siteFormData.Retries).
|
Value(&m.siteFormData.Retries).
|
||||||
Validate(func(s string) error {
|
Validate(func(s string) error {
|
||||||
if m.siteFormData.SiteType == "group" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
v, err := strconv.Atoi(s)
|
v, err := strconv.Atoi(s)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("must be a number")
|
return fmt.Errorf("must be a number")
|
||||||
@@ -738,7 +642,7 @@ func (m *Model) initSiteHuhForm() tea.Cmd {
|
|||||||
).Title("Advanced").WithHideFunc(func() bool {
|
).Title("Advanced").WithHideFunc(func() bool {
|
||||||
return m.siteFormData.SiteType == "group"
|
return m.siteFormData.SiteType == "group"
|
||||||
}),
|
}),
|
||||||
).WithTheme(m.theme.HuhTheme())
|
).WithTheme(huh.ThemeDracula())
|
||||||
|
|
||||||
return m.huhForm.Init()
|
return m.huhForm.Init()
|
||||||
}
|
}
|
||||||
@@ -820,30 +724,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 +733,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,36 +743,20 @@ 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")
|
|
||||||
if site.Method != "" && site.Method != "GET" {
|
|
||||||
row("Method", site.Method)
|
row("Method", site.Method)
|
||||||
}
|
row("Codes", site.AcceptedCodes)
|
||||||
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 != "" {
|
|
||||||
section("CONFIG")
|
|
||||||
if site.MaxRetries > 0 {
|
if site.MaxRetries > 0 {
|
||||||
row("Retries", fmtRetries(site))
|
row("Retries", fmtRetries(site))
|
||||||
}
|
}
|
||||||
@@ -903,6 +766,8 @@ func (m Model) viewDetailPanel() string {
|
|||||||
if site.Description != "" {
|
if site.Description != "" {
|
||||||
row("Description", 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 +780,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 +800,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
|
|
||||||
}
|
|
||||||
if count == 0 {
|
|
||||||
minL, maxL = l, l
|
|
||||||
} else if l < minL {
|
|
||||||
minL = l
|
minL = l
|
||||||
} else if l > maxL {
|
}
|
||||||
|
if l > maxL {
|
||||||
maxL = l
|
maxL = l
|
||||||
}
|
}
|
||||||
total += l
|
|
||||||
count++
|
|
||||||
}
|
}
|
||||||
if count > 0 {
|
avg := total / time.Duration(len(hist.Latencies))
|
||||||
avg := total / time.Duration(count)
|
|
||||||
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
|
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
|
||||||
subtleStyle.Render("Min"), minL.Milliseconds(),
|
subtleStyle.Render("Min"), minL.Milliseconds(),
|
||||||
subtleStyle.Render("Avg"), avg.Milliseconds(),
|
subtleStyle.Render("Avg"), avg.Milliseconds(),
|
||||||
|
|||||||
@@ -32,19 +32,8 @@ func (m Model) viewUsersTab() string {
|
|||||||
return "\n No users configured. Press [n] to add one."
|
return "\n No users configured. Press [n] to add one."
|
||||||
}
|
}
|
||||||
|
|
||||||
var headers []string
|
|
||||||
var widths []int
|
|
||||||
if m.isWide() {
|
|
||||||
headers = []string{"#", "USERNAME", "ROLE", "PUBLIC KEY"}
|
|
||||||
widths = []int{4, 18, 10, 50}
|
|
||||||
} else {
|
|
||||||
headers = []string{"#", "USER", "ROLE", "KEY"}
|
|
||||||
widths = []int{4, 14, 8, 30}
|
|
||||||
}
|
|
||||||
userW := widths[1]
|
|
||||||
|
|
||||||
return m.renderTable(
|
return m.renderTable(
|
||||||
headers,
|
[]string{"#", "USERNAME", "ROLE", "PUBLIC KEY"},
|
||||||
len(m.users),
|
len(m.users),
|
||||||
func(start, end int) [][]string {
|
func(start, end int) [][]string {
|
||||||
var rows [][]string
|
var rows [][]string
|
||||||
@@ -52,14 +41,14 @@ func (m Model) viewUsersTab() string {
|
|||||||
u := m.users[i]
|
u := m.users[i]
|
||||||
rows = append(rows, []string{
|
rows = append(rows, []string{
|
||||||
fmt.Sprintf("%d", i+1),
|
fmt.Sprintf("%d", i+1),
|
||||||
m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, userW-2)),
|
m.zones.Mark(fmt.Sprintf("user-%d", i), limitStr(u.Username, 15)),
|
||||||
fmtRole(u.Role),
|
fmtRole(u.Role),
|
||||||
fmtKey(u.PublicKey),
|
fmtKey(u.PublicKey),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return rows
|
return rows
|
||||||
},
|
},
|
||||||
widths, nil,
|
nil, nil,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +94,7 @@ func (m *Model) initUserHuhForm() tea.Cmd {
|
|||||||
huh.NewOption("Admin", "admin"),
|
huh.NewOption("Admin", "admin"),
|
||||||
).Value(&m.userFormData.Role),
|
).Value(&m.userFormData.Role),
|
||||||
).Title("SSH Access"),
|
).Title("SSH Access"),
|
||||||
).WithTheme(m.theme.HuhTheme())
|
).WithTheme(huh.ThemeDracula())
|
||||||
|
|
||||||
return m.huhForm.Init()
|
return m.huhForm.Init()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
tableHeaderStyle lipgloss.Style
|
tableHeaderStyle = lipgloss.NewStyle().
|
||||||
tableCellStyle lipgloss.Style
|
Foreground(lipgloss.Color("#7D56F4")).
|
||||||
tableSelectedStyle lipgloss.Style
|
Bold(true).
|
||||||
tableBorderStyle lipgloss.Style
|
Padding(0, 1)
|
||||||
tableZebraStyle lipgloss.Style
|
|
||||||
|
tableCellStyle = lipgloss.NewStyle().Padding(0, 1)
|
||||||
|
|
||||||
|
tableSelectedStyle = lipgloss.NewStyle().
|
||||||
|
Padding(0, 1).
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("#ffffff")).
|
||||||
|
Background(lipgloss.Color("#3b3b5c"))
|
||||||
|
|
||||||
|
tableBorderStyle = lipgloss.NewStyle().
|
||||||
|
Foreground(lipgloss.Color("#444"))
|
||||||
|
|
||||||
|
tableZebraStyle = lipgloss.NewStyle().
|
||||||
|
Padding(0, 1).
|
||||||
|
Background(lipgloss.Color("#1a1a2e"))
|
||||||
)
|
)
|
||||||
|
|
||||||
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 +42,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 +55,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 +65,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 +78,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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
package tui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/charmbracelet/huh"
|
|
||||||
"github.com/charmbracelet/lipgloss"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Theme struct {
|
|
||||||
Name string
|
|
||||||
|
|
||||||
// Base layers
|
|
||||||
Bg lipgloss.Color
|
|
||||||
Surface lipgloss.Color
|
|
||||||
Panel lipgloss.Color
|
|
||||||
Border lipgloss.Color
|
|
||||||
|
|
||||||
// Text
|
|
||||||
Fg lipgloss.Color
|
|
||||||
Muted lipgloss.Color
|
|
||||||
Subtle lipgloss.Color
|
|
||||||
|
|
||||||
// Semantic
|
|
||||||
Success lipgloss.Color
|
|
||||||
Warning lipgloss.Color
|
|
||||||
Danger lipgloss.Color
|
|
||||||
Info lipgloss.Color
|
|
||||||
Accent lipgloss.Color
|
|
||||||
Purple lipgloss.Color
|
|
||||||
|
|
||||||
// Table
|
|
||||||
ZebraBg lipgloss.Color
|
|
||||||
|
|
||||||
// Selection
|
|
||||||
SelectedFg lipgloss.Color
|
|
||||||
SelectedBg lipgloss.Color
|
|
||||||
}
|
|
||||||
|
|
||||||
var themes = []Theme{
|
|
||||||
themeFlexokiDark,
|
|
||||||
themeTokyoNight,
|
|
||||||
themeCatppuccinMocha,
|
|
||||||
themeNord,
|
|
||||||
themeGruvbox,
|
|
||||||
}
|
|
||||||
|
|
||||||
var themeFlexokiDark = Theme{
|
|
||||||
Name: "Flexoki Dark",
|
|
||||||
Bg: "#1C1B1A",
|
|
||||||
Surface: "#282726",
|
|
||||||
Panel: "#343331",
|
|
||||||
Border: "#575653",
|
|
||||||
Fg: "#CECDC3",
|
|
||||||
Muted: "#878580",
|
|
||||||
Subtle: "#6F6E69",
|
|
||||||
Success: "#879A39",
|
|
||||||
Warning: "#D0A215",
|
|
||||||
Danger: "#D14D41",
|
|
||||||
Info: "#4385BE",
|
|
||||||
Accent: "#3AA99F",
|
|
||||||
Purple: "#8B7EC8",
|
|
||||||
ZebraBg: "#222120",
|
|
||||||
SelectedFg: "#FFFCF0",
|
|
||||||
SelectedBg: "#403E3C",
|
|
||||||
}
|
|
||||||
|
|
||||||
var themeTokyoNight = Theme{
|
|
||||||
Name: "Tokyo Night",
|
|
||||||
Bg: "#1a1b26",
|
|
||||||
Surface: "#24283b",
|
|
||||||
Panel: "#292e42",
|
|
||||||
Border: "#3b4261",
|
|
||||||
Fg: "#c0caf5",
|
|
||||||
Muted: "#a9b1d6",
|
|
||||||
Subtle: "#565f89",
|
|
||||||
Success: "#9ece6a",
|
|
||||||
Warning: "#e0af68",
|
|
||||||
Danger: "#f7768e",
|
|
||||||
Info: "#7aa2f7",
|
|
||||||
Accent: "#7dcfff",
|
|
||||||
Purple: "#bb9af7",
|
|
||||||
ZebraBg: "#1c1d28",
|
|
||||||
SelectedFg: "#c0caf5",
|
|
||||||
SelectedBg: "#292e42",
|
|
||||||
}
|
|
||||||
|
|
||||||
var themeGruvbox = Theme{
|
|
||||||
Name: "Gruvbox",
|
|
||||||
Bg: "#282828",
|
|
||||||
Surface: "#3c3836",
|
|
||||||
Panel: "#504945",
|
|
||||||
Border: "#665c54",
|
|
||||||
Fg: "#ebdbb2",
|
|
||||||
Muted: "#bdae93",
|
|
||||||
Subtle: "#7c6f64",
|
|
||||||
Success: "#b8bb26",
|
|
||||||
Warning: "#fabd2f",
|
|
||||||
Danger: "#fb4934",
|
|
||||||
Info: "#83a598",
|
|
||||||
Accent: "#8ec07c",
|
|
||||||
Purple: "#d3869b",
|
|
||||||
ZebraBg: "#2a2a2a",
|
|
||||||
SelectedFg: "#fbf1c7",
|
|
||||||
SelectedBg: "#504945",
|
|
||||||
}
|
|
||||||
|
|
||||||
var themeCatppuccinMocha = Theme{
|
|
||||||
Name: "Catppuccin Mocha",
|
|
||||||
Bg: "#1e1e2e",
|
|
||||||
Surface: "#313244",
|
|
||||||
Panel: "#45475a",
|
|
||||||
Border: "#585b70",
|
|
||||||
Fg: "#cdd6f4",
|
|
||||||
Muted: "#a6adc8",
|
|
||||||
Subtle: "#6c7086",
|
|
||||||
Success: "#a6e3a1",
|
|
||||||
Warning: "#f9e2af",
|
|
||||||
Danger: "#f38ba8",
|
|
||||||
Info: "#89b4fa",
|
|
||||||
Accent: "#94e2d5",
|
|
||||||
Purple: "#cba6f7",
|
|
||||||
ZebraBg: "#232334",
|
|
||||||
SelectedFg: "#cdd6f4",
|
|
||||||
SelectedBg: "#45475a",
|
|
||||||
}
|
|
||||||
|
|
||||||
var themeNord = Theme{
|
|
||||||
Name: "Nord",
|
|
||||||
Bg: "#2e3440",
|
|
||||||
Surface: "#3b4252",
|
|
||||||
Panel: "#434c5e",
|
|
||||||
Border: "#4c566a",
|
|
||||||
Fg: "#d8dee9",
|
|
||||||
Muted: "#d8dee9",
|
|
||||||
Subtle: "#4c566a",
|
|
||||||
Success: "#a3be8c",
|
|
||||||
Warning: "#ebcb8b",
|
|
||||||
Danger: "#bf616a",
|
|
||||||
Info: "#81a1c1",
|
|
||||||
Accent: "#88c0d0",
|
|
||||||
Purple: "#b48ead",
|
|
||||||
ZebraBg: "#323845",
|
|
||||||
SelectedFg: "#eceff4",
|
|
||||||
SelectedBg: "#434c5e",
|
|
||||||
}
|
|
||||||
|
|
||||||
func (t Theme) HuhTheme() *huh.Theme {
|
|
||||||
ht := huh.ThemeBase()
|
|
||||||
|
|
||||||
ht.Focused.Base = ht.Focused.Base.BorderForeground(t.Border)
|
|
||||||
ht.Focused.Card = ht.Focused.Base
|
|
||||||
ht.Focused.Title = ht.Focused.Title.Foreground(t.Accent).Bold(true)
|
|
||||||
ht.Focused.NoteTitle = ht.Focused.NoteTitle.Foreground(t.Accent).Bold(true).MarginBottom(1)
|
|
||||||
ht.Focused.Description = ht.Focused.Description.Foreground(t.Muted)
|
|
||||||
ht.Focused.ErrorIndicator = ht.Focused.ErrorIndicator.Foreground(t.Danger)
|
|
||||||
ht.Focused.ErrorMessage = ht.Focused.ErrorMessage.Foreground(t.Danger)
|
|
||||||
ht.Focused.SelectSelector = ht.Focused.SelectSelector.Foreground(t.Purple)
|
|
||||||
ht.Focused.NextIndicator = ht.Focused.NextIndicator.Foreground(t.Purple)
|
|
||||||
ht.Focused.PrevIndicator = ht.Focused.PrevIndicator.Foreground(t.Purple)
|
|
||||||
ht.Focused.Option = ht.Focused.Option.Foreground(t.Fg)
|
|
||||||
ht.Focused.MultiSelectSelector = ht.Focused.MultiSelectSelector.Foreground(t.Purple)
|
|
||||||
ht.Focused.SelectedOption = ht.Focused.SelectedOption.Foreground(t.Success)
|
|
||||||
ht.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(t.Success).SetString("✓ ")
|
|
||||||
ht.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(t.Subtle).SetString("• ")
|
|
||||||
ht.Focused.UnselectedOption = ht.Focused.UnselectedOption.Foreground(t.Fg)
|
|
||||||
ht.Focused.FocusedButton = ht.Focused.FocusedButton.Foreground(t.Bg).Background(t.Accent)
|
|
||||||
ht.Focused.Next = ht.Focused.FocusedButton
|
|
||||||
ht.Focused.BlurredButton = ht.Focused.BlurredButton.Foreground(t.Fg).Background(t.Surface)
|
|
||||||
ht.Focused.TextInput.Cursor = ht.Focused.TextInput.Cursor.Foreground(t.Accent)
|
|
||||||
ht.Focused.TextInput.Placeholder = ht.Focused.TextInput.Placeholder.Foreground(t.Subtle)
|
|
||||||
ht.Focused.TextInput.Prompt = ht.Focused.TextInput.Prompt.Foreground(t.Purple)
|
|
||||||
|
|
||||||
ht.Blurred = ht.Focused
|
|
||||||
ht.Blurred.Base = ht.Focused.Base.BorderStyle(lipgloss.HiddenBorder())
|
|
||||||
ht.Blurred.Card = ht.Blurred.Base
|
|
||||||
ht.Blurred.NextIndicator = lipgloss.NewStyle()
|
|
||||||
ht.Blurred.PrevIndicator = lipgloss.NewStyle()
|
|
||||||
|
|
||||||
ht.Group.Title = ht.Focused.Title
|
|
||||||
ht.Group.Description = ht.Focused.Description
|
|
||||||
|
|
||||||
return ht
|
|
||||||
}
|
|
||||||
|
|
||||||
func themeByName(name string) Theme {
|
|
||||||
for _, t := range themes {
|
|
||||||
if t.Name == name {
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return themes[0]
|
|
||||||
}
|
|
||||||
+29
-138
@@ -3,16 +3,14 @@ package tui
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go-upkeep/internal/models"
|
||||||
|
"go-upkeep/internal/monitor"
|
||||||
|
"go-upkeep/internal/store"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
|
||||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/harmonica"
|
"github.com/charmbracelet/harmonica"
|
||||||
@@ -22,34 +20,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
subtleStyle lipgloss.Style
|
subtleStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#9ca0b0", Dark: "#565f89"})
|
||||||
specialStyle lipgloss.Style
|
specialStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#43BF6D", Dark: "#73F59F"})
|
||||||
warnStyle lipgloss.Style
|
warnStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F0E442", Dark: "#F0E442"})
|
||||||
dangerStyle lipgloss.Style
|
dangerStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#F25D94", Dark: "#F25D94"})
|
||||||
titleStyle lipgloss.Style
|
titleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Bold(true)
|
||||||
activeTab lipgloss.Style
|
|
||||||
inactiveTab lipgloss.Style
|
activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(lipgloss.Color("#7D56F4")).Foreground(lipgloss.Color("#7D56F4")).Bold(true).Padding(0, 1)
|
||||||
|
inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(lipgloss.AdaptiveColor{Light: "#AAA", Dark: "#555"})
|
||||||
)
|
)
|
||||||
|
|
||||||
func applyTheme(t Theme) {
|
|
||||||
subtleStyle = lipgloss.NewStyle().Foreground(t.Subtle)
|
|
||||||
specialStyle = lipgloss.NewStyle().Foreground(t.Success)
|
|
||||||
warnStyle = lipgloss.NewStyle().Foreground(t.Warning)
|
|
||||||
dangerStyle = lipgloss.NewStyle().Foreground(t.Danger)
|
|
||||||
titleStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true)
|
|
||||||
activeTab = lipgloss.NewStyle().Border(lipgloss.NormalBorder(), false, false, true, false).BorderForeground(t.Accent).Foreground(t.Accent).Bold(true).Padding(0, 1)
|
|
||||||
inactiveTab = lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted)
|
|
||||||
|
|
||||||
tableHeaderStyle = lipgloss.NewStyle().Foreground(t.Accent).Bold(true).Padding(0, 1)
|
|
||||||
tableCellStyle = lipgloss.NewStyle().Padding(0, 1)
|
|
||||||
tableSelectedStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.SelectedFg).Background(t.SelectedBg)
|
|
||||||
tableBorderStyle = lipgloss.NewStyle().Foreground(t.Border)
|
|
||||||
tableZebraStyle = lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg)
|
|
||||||
|
|
||||||
siteGroupStyle = lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent)
|
|
||||||
maintStyle = lipgloss.NewStyle().Foreground(t.Purple)
|
|
||||||
}
|
|
||||||
|
|
||||||
var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
var pulseFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -69,7 +49,6 @@ const (
|
|||||||
stateLogs
|
stateLogs
|
||||||
stateUsers
|
stateUsers
|
||||||
stateDetail
|
stateDetail
|
||||||
stateAlertDetail
|
|
||||||
stateFormSite
|
stateFormSite
|
||||||
stateFormAlert
|
stateFormAlert
|
||||||
stateFormUser
|
stateFormUser
|
||||||
@@ -95,7 +74,6 @@ type Model struct {
|
|||||||
maintFormData *maintFormData
|
maintFormData *maintFormData
|
||||||
|
|
||||||
logViewport viewport.Model
|
logViewport viewport.Model
|
||||||
logFilterImportant bool
|
|
||||||
isAdmin bool
|
isAdmin bool
|
||||||
zones *zone.Manager
|
zones *zone.Manager
|
||||||
|
|
||||||
@@ -106,8 +84,6 @@ type Model struct {
|
|||||||
collapsed map[int]bool
|
collapsed map[int]bool
|
||||||
store store.Store
|
store store.Store
|
||||||
engine *monitor.Engine
|
engine *monitor.Engine
|
||||||
theme Theme
|
|
||||||
themeIndex int
|
|
||||||
|
|
||||||
// harmonica animation state
|
// harmonica animation state
|
||||||
pulseSpring harmonica.Spring
|
pulseSpring harmonica.Spring
|
||||||
@@ -123,10 +99,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 {
|
||||||
@@ -135,19 +107,6 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
|
|||||||
z := zone.New()
|
z := zone.New()
|
||||||
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
|
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
|
||||||
collapsed := loadCollapsed(s)
|
collapsed := loadCollapsed(s)
|
||||||
|
|
||||||
themeName, _ := s.GetPreference("theme")
|
|
||||||
theme := themeByName(themeName)
|
|
||||||
themeIdx := 0
|
|
||||||
for i, t := range themes {
|
|
||||||
if t.Name == theme.Name {
|
|
||||||
themeIdx = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyTheme(theme)
|
|
||||||
|
|
||||||
return Model{
|
return Model{
|
||||||
state: stateDashboard,
|
state: stateDashboard,
|
||||||
logViewport: vpLogs,
|
logViewport: vpLogs,
|
||||||
@@ -158,9 +117,6 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine) Model {
|
|||||||
zones: z,
|
zones: z,
|
||||||
pulseSpring: spring,
|
pulseSpring: spring,
|
||||||
collapsed: collapsed,
|
collapsed: collapsed,
|
||||||
theme: theme,
|
|
||||||
themeIndex: themeIdx,
|
|
||||||
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,14 +347,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 +356,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 +427,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 +445,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 {
|
||||||
@@ -527,11 +458,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.refreshData()
|
m.refreshData()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case "T":
|
|
||||||
m.themeIndex = (m.themeIndex + 1) % len(themes)
|
|
||||||
m.theme = themes[m.themeIndex]
|
|
||||||
applyTheme(m.theme)
|
|
||||||
_ = m.store.SetPreference("theme", m.theme.Name)
|
|
||||||
case "d", "backspace":
|
case "d", "backspace":
|
||||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||||
m.deleteID = m.sites[m.cursor].ID
|
m.deleteID = m.sites[m.cursor].ID
|
||||||
@@ -760,6 +686,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 +698,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)
|
||||||
@@ -805,7 +723,7 @@ func (m Model) View() string {
|
|||||||
hint := subtleStyle.Render("[y] Confirm [n] Cancel")
|
hint := subtleStyle.Render("[y] Confirm [n] Cancel")
|
||||||
box := lipgloss.NewStyle().
|
box := lipgloss.NewStyle().
|
||||||
Border(lipgloss.RoundedBorder()).
|
Border(lipgloss.RoundedBorder()).
|
||||||
BorderForeground(m.theme.Danger).
|
BorderForeground(lipgloss.Color("#F25D94")).
|
||||||
Padding(1, 3).
|
Padding(1, 3).
|
||||||
Render(msg + "\n\n" + hint)
|
Render(msg + "\n\n" + hint)
|
||||||
return lipgloss.NewStyle().Padding(2, 4).Render(box)
|
return lipgloss.NewStyle().Padding(2, 4).Render(box)
|
||||||
@@ -843,8 +761,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 +770,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 +789,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 +854,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,33 +869,25 @@ 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(" · "))
|
||||||
|
|
||||||
var footer string
|
var footer string
|
||||||
if m.filterMode {
|
if m.filterMode {
|
||||||
cursor := lipgloss.NewStyle().Foreground(m.theme.Accent).Render("│")
|
cursor := lipgloss.NewStyle().Foreground(lipgloss.Color("#7D56F4")).Render("│")
|
||||||
footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear")
|
footer = "\n" + titleStyle.Render("/") + " " + m.filterText + cursor + " " + subtleStyle.Render("[Enter]Apply [Esc]Clear")
|
||||||
} else {
|
} else {
|
||||||
var keys string
|
var keys 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 [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 [Tab]Switch [q]Quit"
|
||||||
case 5:
|
case 5:
|
||||||
keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit"
|
keys = "[n]Add [d]Revoke [Tab]Switch [q]Quit"
|
||||||
default:
|
default:
|
||||||
keys = "[T]Theme [Tab]Switch [q]Quit"
|
keys = "[Tab]Switch [q]Quit"
|
||||||
}
|
}
|
||||||
footer = "\n" + statusLine + " " + subtleStyle.Render(keys)
|
footer = "\n" + statusLine + " " + subtleStyle.Render(keys)
|
||||||
if m.filterText != "" && m.currentTab == 0 {
|
if m.filterText != "" && m.currentTab == 0 {
|
||||||
@@ -1014,19 +908,16 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func limitStr(text string, max int) string {
|
func limitStr(text string, max int) string {
|
||||||
runes := []rune(text)
|
if len(text) > max {
|
||||||
if len(runes) > max {
|
return text[:max-3] + "..."
|
||||||
return string(runes[:max-3]) + "..."
|
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user