Compare commits
58 Commits
v0.1.0
..
650e6c4b16
| Author | SHA1 | Date | |
|---|---|---|---|
|
650e6c4b16
|
|||
|
ab1194f74d
|
|||
|
25f4b20b87
|
|||
|
7171dcd6f8
|
|||
|
822eda4dfd
|
|||
|
a5fd3aec90
|
|||
|
8f16a09da1
|
|||
|
50f77da131
|
|||
|
edbc2beddd
|
|||
|
3a089e7c1d
|
|||
|
5cc1a005ea
|
|||
|
11344b4ae6
|
|||
|
11b647b952
|
|||
|
1268d99f5c
|
|||
|
fb50184f37
|
|||
|
99121d07d8
|
|||
|
a59edf8410
|
|||
|
6799163cd4
|
|||
|
28fb6c8889
|
|||
|
d982359f25
|
|||
|
4af800a359
|
|||
|
a8c43bdb8e
|
|||
|
d0805f61c6
|
|||
|
ad469c86eb
|
|||
|
01dd53241a
|
|||
|
81f8c71b6f
|
|||
|
7109b6fa1c
|
|||
|
e5ac4a1fec
|
|||
|
065d5d74bb
|
|||
|
08f14f3af8
|
|||
|
5720fabdbc
|
|||
|
54299583d6
|
|||
|
c9bd9a5a2e
|
|||
|
66b0681a76
|
|||
|
060cd24de2
|
|||
|
5c40629987
|
|||
|
e12f42fe16
|
|||
|
8323d27e7d
|
|||
|
047bb237e0
|
|||
|
5398cccd44
|
|||
|
4bf64c3841
|
|||
|
d760420f7c
|
|||
|
0e5f2dded5
|
|||
|
07f3cc8e09
|
|||
|
ef8e5c0b93
|
|||
|
94b27488bd
|
|||
|
7d0b4dab8b
|
|||
|
d0d716b07a
|
|||
|
9889ba4417
|
|||
|
c71d5b17f0
|
|||
|
5ca534b0b1
|
|||
|
70c12ca24b
|
|||
|
dbd519c121
|
|||
|
b32145fb58
|
|||
|
47d3b0e68f
|
|||
|
8fd13fefbf
|
|||
|
974c4b61ea
|
|||
|
d50a5159d4
|
@@ -1,5 +1,5 @@
|
||||
# ─── uptop configuration ───────────────────────────────────
|
||||
# Copy to .env and edit. Only uncomment what you need.
|
||||
# Export in your environment or pass via docker run --env-file.\n# Only uncomment what you need.
|
||||
|
||||
# ─── Core ──────────────────────────────────────────────────
|
||||
UPTOP_PORT=23234 # SSH server port
|
||||
@@ -40,3 +40,5 @@ UPTOP_DB_DSN=/data/uptop.db # File path (SQLite) or connection string (Postgre
|
||||
# UPTOP_ALLOW_PRIVATE_TARGETS=false # Allow monitoring RFC1918/loopback addresses
|
||||
# UPTOP_METRICS_PUBLIC=false # Expose /metrics without auth
|
||||
# UPTOP_CORS_ORIGIN= # Access-Control-Allow-Origin for /status/json
|
||||
# UPTOP_TRUSTED_PROXIES= # Comma-separated CIDRs/IPs for X-Forwarded-For trust
|
||||
# UPTOP_MAINT_RETENTION=168h # How long ended maintenance windows are kept
|
||||
|
||||
@@ -16,7 +16,7 @@ body:
|
||||
label: What happened?
|
||||
description: Include what you expected to happen instead.
|
||||
placeholder: |
|
||||
When I run `uptop serve`, the TUI crashes after 10 seconds.
|
||||
When I run `uptop`, the TUI crashes after 10 seconds.
|
||||
I expected it to keep running and display monitor status.
|
||||
validations:
|
||||
required: true
|
||||
@@ -25,7 +25,7 @@ body:
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. Run `uptop serve`
|
||||
1. Run `uptop`
|
||||
2. Wait ~10 seconds
|
||||
3. TUI crashes with panic
|
||||
validations:
|
||||
@@ -37,7 +37,7 @@ body:
|
||||
description: Output of `uptop version`, OS, terminal. Paste any errors below.
|
||||
render: shell
|
||||
placeholder: |
|
||||
uptop version 2026.06.1
|
||||
uptop 0.1.0 (abc1234, 2026-06-17)
|
||||
OS: Debian 13
|
||||
Terminal: Ghostty
|
||||
|
||||
|
||||
@@ -49,9 +49,11 @@ jobs:
|
||||
version: "~> v2"
|
||||
args: release --clean --release-notes=/tmp/release-notes.md
|
||||
env:
|
||||
GORELEASER_CURRENT_TAG: ${{ github.ref_name }}
|
||||
GORELEASER_FORCE_TOKEN: gitea
|
||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
GITEA_API_URL: http://gitea:3000/api/v1
|
||||
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
|
||||
|
||||
# GitHub release relaying is handled by .github/workflows/mirror-release.yml,
|
||||
# which runs on GitHub Actions when the push mirror delivers the tag and
|
||||
|
||||
@@ -35,11 +35,16 @@ jobs:
|
||||
|
||||
TAGS="lerkolabs/uptop:${TAG}"
|
||||
TAGS="${TAGS},lerkolabs/uptop:sha-${SHORT_SHA}"
|
||||
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:${TAG}"
|
||||
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:sha-${SHORT_SHA}"
|
||||
# :latest only for real releases — rc rehearsal tags must not move it
|
||||
if [ "${{ github.ref_type }}" = "tag" ]; then
|
||||
case "$TAG" in
|
||||
*-*) ;;
|
||||
*) TAGS="${TAGS},lerkolabs/uptop:latest" ;;
|
||||
*)
|
||||
TAGS="${TAGS},lerkolabs/uptop:latest"
|
||||
TAGS="${TAGS},ghcr.io/lerkolabs/uptop:latest"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
echo "tags=$TAGS" >> "$GITHUB_OUTPUT"
|
||||
@@ -56,6 +61,13 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ secrets.GHCR_USERNAME }}
|
||||
password: ${{ secrets.GHCR_TOKEN }}
|
||||
|
||||
# Scan must gate the push: build amd64 locally, scan it, and only then run
|
||||
# the multi-arch push (amd64 layers come from the builder cache, so the
|
||||
# second build only adds the arm64 work).
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Bug Report
|
||||
description: Something isn't working as expected
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: search
|
||||
attributes:
|
||||
label: Before filing
|
||||
options:
|
||||
- label: I searched existing issues and didn't find a match
|
||||
required: true
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
description: Include what you expected to happen instead.
|
||||
placeholder: |
|
||||
When I run `uptop`, the TUI crashes after 10 seconds.
|
||||
I expected it to keep running and display monitor status.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. Run `uptop`
|
||||
2. Wait ~10 seconds
|
||||
3. TUI crashes with panic
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment & logs
|
||||
description: Output of `uptop version`, OS, terminal. Paste any errors below.
|
||||
render: shell
|
||||
placeholder: |
|
||||
uptop 0.1.0 (abc1234, 2026-06-17)
|
||||
OS: Debian 13
|
||||
Terminal: Ghostty
|
||||
|
||||
[paste any error output here]
|
||||
validations:
|
||||
required: false
|
||||
@@ -1,8 +1 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Report a Bug
|
||||
url: https://gitea.lerkolabs.com/lerkolabs/uptop/issues/new?template=bug_report.yaml
|
||||
about: Report bugs on our Gitea instance
|
||||
- name: Request a Feature
|
||||
url: https://gitea.lerkolabs.com/lerkolabs/uptop/issues/new?template=feature_request.yaml
|
||||
about: Suggest features on our Gitea instance
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement
|
||||
labels:
|
||||
- feature
|
||||
body:
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What's frustrating or missing?
|
||||
placeholder: I find myself always needing to ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: How would you like this to work?
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Forward Issues to Gitea
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
forward:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Build issue body
|
||||
env:
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
ISSUE_URL: ${{ github.event.issue.html_url }}
|
||||
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
run: |
|
||||
jq -n \
|
||||
--arg url "$ISSUE_URL" \
|
||||
--arg author "$ISSUE_AUTHOR" \
|
||||
--arg body "$ISSUE_BODY" \
|
||||
'">" + " Forwarded from GitHub: " + $url + "\n> Reported by: [@" + $author + "](https://github.com/" + $author + ")\n\n---\n\n" + $body' \
|
||||
-r > /tmp/gitea-issue-body.md || exit 1
|
||||
|
||||
- name: Resolve label IDs
|
||||
id: labels
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GH_LABELS: ${{ toJSON(github.event.issue.labels.*.name) }}
|
||||
run: |
|
||||
GITEA_LABELS=$(curl -f \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
"https://gitea.lerkolabs.com/api/v1/repos/lerkolabs/uptop/labels?limit=50") || exit 1
|
||||
|
||||
GH_LABELS_COMPACT=$(echo "$GH_LABELS" | jq -c '.')
|
||||
LABEL_IDS=$(echo "$GITEA_LABELS" | jq -c --argjson gh "$GH_LABELS_COMPACT" '
|
||||
[.[] | select(
|
||||
.name == "github" or
|
||||
(.name as $n | $gh | index($n) != null)
|
||||
) | .id]
|
||||
')
|
||||
|
||||
echo "ids=${LABEL_IDS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create Gitea issue
|
||||
id: create
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
LABEL_IDS: ${{ steps.labels.outputs.ids }}
|
||||
run: |
|
||||
RESPONSE=$(curl -f -X POST \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"https://gitea.lerkolabs.com/api/v1/repos/lerkolabs/uptop/issues" \
|
||||
-d "$(jq -n \
|
||||
--arg title "$ISSUE_TITLE" \
|
||||
--rawfile body /tmp/gitea-issue-body.md \
|
||||
--argjson labels "$LABEL_IDS" \
|
||||
'{title: $title, body: $body, labels: $labels}'
|
||||
)") || exit 1
|
||||
|
||||
GITEA_URL=$(echo "$RESPONSE" | jq -re '.html_url') || exit 1
|
||||
GITEA_NUM=$(echo "$RESPONSE" | jq -re '.number') || exit 1
|
||||
|
||||
echo "url=${GITEA_URL}" >> "$GITHUB_OUTPUT"
|
||||
echo "number=${GITEA_NUM}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Comment on GitHub issue
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITEA_URL: ${{ steps.create.outputs.url }}
|
||||
run: |
|
||||
gh issue comment "${{ github.event.issue.number }}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--body "Thanks for reporting! This issue has been forwarded to our [primary tracker](${GITEA_URL}). Discussion and updates will happen there."
|
||||
@@ -25,4 +25,6 @@ authorized_keys
|
||||
tmp
|
||||
*.local.json
|
||||
*.local.md
|
||||
data/
|
||||
.env
|
||||
vhs
|
||||
|
||||
@@ -59,6 +59,24 @@ nfpms:
|
||||
dst: /usr/share/doc/uptop/LICENSE
|
||||
type: doc
|
||||
|
||||
brews:
|
||||
- name: uptop
|
||||
repository:
|
||||
owner: lerkolabs
|
||||
name: homebrew-tap
|
||||
token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}"
|
||||
url_template: "https://github.com/lerkolabs/uptop/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
|
||||
commit_author:
|
||||
name: lerkolabs-bot
|
||||
email: bot@lerkolabs.com
|
||||
homepage: https://github.com/lerkolabs/uptop
|
||||
description: Self-hosted uptime monitoring with a TUI over SSH
|
||||
license: MIT
|
||||
install: |
|
||||
bin.install "uptop"
|
||||
test: |
|
||||
system bin/"uptop", "version"
|
||||
|
||||
# Changelog generation must stay enabled: the --release-notes flag is consumed
|
||||
# by the changelog pipe, so disabling it silently drops the git-cliff notes
|
||||
# (empty release body on v0.1.0-rc.1). With --release-notes set, GoReleaser
|
||||
|
||||
@@ -23,14 +23,13 @@ RUN mkdir -p /data/.ssh && chown -R uptop:uptop /data
|
||||
COPY --from=builder /app/uptop .
|
||||
COPY --chmod=755 docker-entrypoint.sh /usr/local/bin/
|
||||
|
||||
ENV LIPGLOSS_RENDERER_HAS_DARK_BACKGROUND=true
|
||||
ENV UPTOP_DB_TYPE=sqlite
|
||||
ENV UPTOP_DB_DSN=/data/uptop.db
|
||||
ENV UPTOP_KEYS=/data/authorized_keys
|
||||
ENV UPTOP_SSH_HOST_KEY=/data/.ssh/id_ed25519
|
||||
ENV UPTOP_PORT=23234
|
||||
|
||||
EXPOSE 23234
|
||||
EXPOSE 8080 23234
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -qO- http://localhost:8080/api/health || exit 1
|
||||
USER uptop
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<div align="center">
|
||||
<h1>uptop</h1>
|
||||
<img src="assets/logo.svg" alt="uptop" width="320">
|
||||
<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>
|
||||
<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">
|
||||
<a href="https://gitea.lerkolabs.com/lerkolabs/uptop/actions"><img src="https://gitea.lerkolabs.com/lerkolabs/uptop/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
||||
<a href="https://github.com/lerkolabs/uptop/releases/latest"><img src="https://img.shields.io/github/v/release/lerkolabs/uptop" alt="Latest Release"></a>
|
||||
<a href="https://goreportcard.com/report/github.com/lerkolabs/uptop"><img src="https://goreportcard.com/badge/github.com/lerkolabs/uptop" alt="Go Report Card"></a>
|
||||
<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">
|
||||
<img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT License">
|
||||
</p>
|
||||
|
||||
<img src="assets/monitors.png" alt="uptop monitors view" width="800">
|
||||
<img src="assets/demo.gif" alt="uptop demo" width="800">
|
||||
</div>
|
||||
|
||||
## What is this
|
||||
@@ -27,7 +29,7 @@ Canonical repo: [gitea.lerkolabs.com/lerkolabs/uptop](https://gitea.lerkolabs.co
|
||||
- **10 alert providers** — Discord, Slack, Email, Ntfy, Webhook, Telegram, PagerDuty, Pushover, Gotify, Opsgenie
|
||||
- **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
|
||||
- **Prometheus metrics** — `/metrics` endpoint (`UPTOP_METRICS_PUBLIC=true` to expose without auth)
|
||||
- **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
|
||||
@@ -38,18 +40,27 @@ Canonical repo: [gitea.lerkolabs.com/lerkolabs/uptop](https://gitea.lerkolabs.co
|
||||
|
||||
<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>
|
||||
<td><img src="assets/monitors.png" alt="monitors + log sidebar" width="400"></td>
|
||||
<td><img src="assets/detail.png" alt="inline detail panel" width="400"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="assets/logs.png" alt="logs view" width="400"></td>
|
||||
<td><img src="assets/fulldetail.png" alt="full-screen detail" width="400"></td>
|
||||
<td><img src="assets/settings.png" alt="settings / alerts" width="400"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="assets/maint.png" alt="maintenance windows" 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>
|
||||
|
||||
### Themes
|
||||
|
||||
Five built-in themes: Flexoki Dark, Tokyo Night, Catppuccin Mocha, Nord, Gruvbox. Press `T` to cycle.
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/themes.png" alt="all five themes" width="800">
|
||||
</p>
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
@@ -146,6 +157,8 @@ Full reference in [docs/config-as-code.md](docs/config-as-code.md).
|
||||
| `UPTOP_INSECURE_SKIP_VERIFY` | `false` | Skip TLS verification for checks |
|
||||
| `UPTOP_ALLOW_PRIVATE_TARGETS` | `false` | Allow monitoring RFC1918/loopback addresses |
|
||||
| `UPTOP_ADMIN_KEY` | | SSH public key seeded as first admin on startup |
|
||||
| `UPTOP_METRICS_PUBLIC` | `false` | Expose `/metrics` without auth |
|
||||
| `UPTOP_MAINT_RETENTION` | `168h` | How long ended maintenance windows are kept |
|
||||
| `UPTOP_TRUSTED_PROXIES` | | Comma-separated CIDRs/IPs whose `X-Forwarded-For` is trusted ([details](#running-behind-a-reverse-proxy)) |
|
||||
|
||||
See [`.env.example`](.env.example) for all options including TLS, probes, and advanced settings.
|
||||
@@ -179,7 +192,7 @@ uptop prunes its own history in the background — no external cleanup jobs need
|
||||
| Check history | newest 1,000 checks per monitor |
|
||||
| State changes (UP/DOWN transitions) | newest 5,000 per monitor |
|
||||
| Logs | newest 200 entries |
|
||||
| Maintenance windows | 7 days after they end (configurable) |
|
||||
| Maintenance windows | 7 days after they end (`UPTOP_MAINT_RETENTION`) |
|
||||
|
||||
Sparklines, uptime percentages, and SLA reports are computed from these windows, so very long-horizon stats aren't retained. Export to Prometheus via `/metrics` if you need unlimited history.
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 358 KiB |
|
After Width: | Height: | Size: 117 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 80" fill="none">
|
||||
<!-- Terminal-inspired text mark for uptop -->
|
||||
<rect width="320" height="80" rx="8" fill="#1C1B1A"/>
|
||||
<!-- Prompt caret -->
|
||||
<text x="16" y="52" font-family="'JetBrains Mono','Fira Code','SF Mono',monospace" font-size="36" font-weight="700" fill="#3AA99F">▲</text>
|
||||
<!-- "uptop" in monospace -->
|
||||
<text x="52" y="52" font-family="'JetBrains Mono','Fira Code','SF Mono',monospace" font-size="36" font-weight="700" fill="#CECDC3">uptop</text>
|
||||
<!-- Status dot -->
|
||||
<circle cx="296" cy="40" r="6" fill="#879A39"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
|
Before Width: | Height: | Size: 206 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 232 KiB After Width: | Height: | Size: 312 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 312 KiB |
|
After Width: | Height: | Size: 264 KiB |
@@ -43,10 +43,10 @@ func TestKeyCache_AllowsKnownDeniesUnknown(t *testing.T) {
|
||||
_, unknown := testKey(t)
|
||||
kc := newKeyCache(&kcMockStore{users: []models.User{{PublicKey: authorized}}})
|
||||
|
||||
if !kc.IsAllowed(known) {
|
||||
if !kc.IsAllowed(context.Background(), known) {
|
||||
t.Error("known key denied")
|
||||
}
|
||||
if kc.IsAllowed(unknown) {
|
||||
if kc.IsAllowed(context.Background(), unknown) {
|
||||
t.Error("unknown key allowed")
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func TestKeyCache_RetainsKeysOnRefreshError(t *testing.T) {
|
||||
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
|
||||
kc := newKeyCache(ms)
|
||||
|
||||
if !kc.IsAllowed(known) {
|
||||
if !kc.IsAllowed(context.Background(), known) {
|
||||
t.Fatal("known key denied on first refresh")
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ func TestKeyCache_RetainsKeysOnRefreshError(t *testing.T) {
|
||||
kc.updated = time.Now().Add(-time.Hour)
|
||||
kc.mu.Unlock()
|
||||
|
||||
if !kc.IsAllowed(known) {
|
||||
if !kc.IsAllowed(context.Background(), known) {
|
||||
t.Error("transient refresh error locked out a previously valid key")
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func TestKeyCache_FailsClosedAfterInvalidate(t *testing.T) {
|
||||
ms := &kcMockStore{users: []models.User{{PublicKey: authorized}}}
|
||||
kc := newKeyCache(ms)
|
||||
|
||||
if !kc.IsAllowed(known) {
|
||||
if !kc.IsAllowed(context.Background(), known) {
|
||||
t.Fatal("known key denied on first refresh")
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func TestKeyCache_FailsClosedAfterInvalidate(t *testing.T) {
|
||||
ms.err = errors.New("db down")
|
||||
kc.Invalidate()
|
||||
|
||||
if kc.IsAllowed(known) {
|
||||
if kc.IsAllowed(context.Background(), known) {
|
||||
t.Error("revoked key still allowed while DB is down — fails open")
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,7 @@ func TestUserInvalidatingStore_DeleteDropsKeyCache(t *testing.T) {
|
||||
kc := newKeyCache(ms)
|
||||
s := &userInvalidatingStore{Store: ms, kc: kc}
|
||||
|
||||
if !kc.IsAllowed(known) {
|
||||
if !kc.IsAllowed(context.Background(), known) {
|
||||
t.Fatal("known key denied on first refresh")
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ func TestUserInvalidatingStore_DeleteDropsKeyCache(t *testing.T) {
|
||||
ms.users = nil
|
||||
ms.err = errors.New("db down")
|
||||
|
||||
if kc.IsAllowed(known) {
|
||||
if kc.IsAllowed(context.Background(), known) {
|
||||
t.Error("deleted user's key still allowed from stale cache")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,14 @@ func init() {
|
||||
return
|
||||
}
|
||||
if mv := info.Main.Version; mv != "" && mv != "(devel)" {
|
||||
version = strings.TrimPrefix(mv, "v")
|
||||
mv = strings.TrimPrefix(mv, "v")
|
||||
// Pseudo-versions (e.g. "0.1.1-0.20260620165311-5ca534b0b100+dirty")
|
||||
// are noisy in the TUI footer. Extract just the base semver.
|
||||
if i := strings.Index(mv, "-0."); i > 0 {
|
||||
mv = mv[:i]
|
||||
}
|
||||
mv = strings.TrimSuffix(mv, "+dirty")
|
||||
version = mv
|
||||
}
|
||||
for _, s := range info.Settings {
|
||||
switch s.Key {
|
||||
@@ -77,17 +84,37 @@ func main() {
|
||||
case "export":
|
||||
runExport(os.Args[2:])
|
||||
return
|
||||
case "version", "--version", "-v":
|
||||
case "version", "--version", "-v", "-version":
|
||||
printVersion()
|
||||
return
|
||||
case "migrate-secrets":
|
||||
runMigrateSecrets(os.Args[2:])
|
||||
return
|
||||
case "help", "--help", "-h":
|
||||
printUsage()
|
||||
return
|
||||
case "serve":
|
||||
runServe(os.Args[2:])
|
||||
return
|
||||
}
|
||||
}
|
||||
runServe(os.Args[1:])
|
||||
}
|
||||
|
||||
func printUsage() {
|
||||
fmt.Fprintf(os.Stderr, `Usage: uptop <command> [flags]
|
||||
|
||||
Commands:
|
||||
serve Start the server (default if no command given)
|
||||
apply Apply monitors from a YAML file
|
||||
export Export monitors to YAML
|
||||
migrate-secrets Re-encrypt alert credentials with current key
|
||||
version Print version and exit
|
||||
|
||||
Run 'uptop serve --help' for server flags.
|
||||
`)
|
||||
}
|
||||
|
||||
func printVersion() {
|
||||
out := "uptop " + version
|
||||
var meta []string
|
||||
@@ -152,7 +179,7 @@ func parseTrustedProxies(raw string) []*net.IPNet {
|
||||
return cidrs
|
||||
}
|
||||
|
||||
func openStore(dbType, dsn string) store.Store {
|
||||
func openStore(ctx context.Context, dbType, dsn string) store.Store {
|
||||
var ss *store.SQLStore
|
||||
var err error
|
||||
if dbType == "postgres" {
|
||||
@@ -174,7 +201,7 @@ func openStore(dbType, dsn string) store.Store {
|
||||
} else {
|
||||
slog.Warn("no UPTOP_ENCRYPTION_KEY set, alert credentials stored unencrypted")
|
||||
}
|
||||
if err := ss.Init(context.Background()); err != nil {
|
||||
if err := ss.Init(ctx); err != nil {
|
||||
slog.Error("database init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -186,7 +213,7 @@ func runApply(args []string) {
|
||||
filePath := fs.String("f", "", "Path to YAML config file (required)")
|
||||
dryRun := fs.Bool("dry-run", false, "Show planned changes without applying")
|
||||
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("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
|
||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
||||
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
||||
|
||||
@@ -196,7 +223,8 @@ func runApply(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
s := openStore(*dbType, *dsn)
|
||||
ctx := context.Background()
|
||||
s := openStore(ctx, *dbType, *dsn)
|
||||
|
||||
f, err := config.LoadFile(*filePath)
|
||||
if err != nil {
|
||||
@@ -204,7 +232,7 @@ func runApply(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
changes, err := config.Apply(context.Background(), s, f, config.ApplyOpts{
|
||||
changes, err := config.Apply(ctx, s, f, config.ApplyOpts{
|
||||
DryRun: *dryRun,
|
||||
Prune: *prune,
|
||||
})
|
||||
@@ -219,13 +247,14 @@ func runApply(args []string) {
|
||||
func runExport(args []string) {
|
||||
fs := flag.NewFlagSet("export", flag.ExitOnError)
|
||||
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("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
|
||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
||||
_ = fs.Parse(args) // ExitOnError: parse errors exit before returning
|
||||
|
||||
s := openStore(*dbType, *dsn)
|
||||
ctx := context.Background()
|
||||
s := openStore(ctx, *dbType, *dsn)
|
||||
|
||||
f, err := config.Export(context.Background(), s)
|
||||
f, err := config.Export(ctx, s)
|
||||
if err != nil {
|
||||
slog.Error("export failed", "err", err)
|
||||
os.Exit(1)
|
||||
@@ -239,7 +268,7 @@ 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")
|
||||
dbType := fs.String("db-type", envOrDefault("UPTOP_DB_TYPE", "sqlite"), "Database type (sqlite or postgres)")
|
||||
dsn := fs.String("dsn", envOrDefault("UPTOP_DB_DSN", "uptop.db"), "Database DSN")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
@@ -254,6 +283,8 @@ func runMigrateSecrets(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
var ss *store.SQLStore
|
||||
if *dbType == "postgres" {
|
||||
ss, err = store.NewPostgresStore(*dsn)
|
||||
@@ -264,21 +295,21 @@ func runMigrateSecrets(args []string) {
|
||||
slog.Error("database connection failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := ss.Init(context.Background()); err != nil {
|
||||
if err := ss.Init(ctx); err != nil {
|
||||
slog.Error("database init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
ss.SetEncryptor(enc)
|
||||
|
||||
alerts, err := ss.GetAllAlerts(context.Background())
|
||||
alerts, err := ss.GetAllAlerts(ctx)
|
||||
if err != nil {
|
||||
slog.Error("failed to load alerts", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
migrated := 0
|
||||
for _, a := range alerts {
|
||||
if err := ss.UpdateAlert(context.Background(), a.ID, a.Name, a.Type, a.Settings); err != nil {
|
||||
if err := ss.UpdateAlert(ctx, a.ID, a.Name, a.Type, a.Settings); err != nil {
|
||||
slog.Error("alert migration failed", "alert", a.Name, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -331,7 +362,7 @@ func runServe(args []string) {
|
||||
|
||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
port := fs.Int("port", cfg.Port, "SSH Port")
|
||||
flagDBType := fs.String("db-type", cfg.DBType, "Database type")
|
||||
flagDBType := fs.String("db-type", cfg.DBType, "Database type (sqlite or postgres)")
|
||||
flagDSN := fs.String("dsn", cfg.DBDSN, "Database DSN")
|
||||
demo := fs.Bool("demo", false, "Seed demo data")
|
||||
importKuma := fs.String("import-kuma", "", "Import Uptime Kuma backup JSON file")
|
||||
@@ -365,15 +396,19 @@ func runServe(args []string) {
|
||||
|
||||
kc := newKeyCache(ss)
|
||||
var s store.Store = &userInvalidatingStore{Store: ss, kc: kc}
|
||||
if err := s.Init(context.Background()); err != nil {
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if err := s.Init(ctx); err != nil {
|
||||
slog.Error("database init failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if *demo {
|
||||
seedDemoData(s)
|
||||
seedDemoData(ctx, s)
|
||||
}
|
||||
|
||||
seedKeysFromEnv(s)
|
||||
seedKeysFromEnv(ctx, s)
|
||||
|
||||
if *importKuma != "" {
|
||||
kb, err := importer.LoadKumaFile(*importKuma)
|
||||
@@ -382,7 +417,7 @@ func runServe(args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
backup := importer.ConvertKuma(kb)
|
||||
if err := s.ImportData(context.Background(), backup); err != nil {
|
||||
if err := s.ImportData(ctx, backup); err != nil {
|
||||
slog.Error("import failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -402,12 +437,9 @@ func runServe(args []string) {
|
||||
}
|
||||
eng.SetMaintRetention(cfg.MaintRetention)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
eng.InitHistory()
|
||||
eng.InitLogs()
|
||||
eng.InitAlertHealth()
|
||||
eng.InitHistory(ctx)
|
||||
eng.InitLogs(ctx)
|
||||
eng.InitAlertHealth(ctx)
|
||||
eng.Start(ctx)
|
||||
|
||||
localTUI := isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
|
||||
@@ -423,7 +455,7 @@ func runServe(args []string) {
|
||||
sshSrv := startSSHServer(*port, s, eng, kc)
|
||||
|
||||
if localTUI {
|
||||
p := tea.NewProgram(tui.InitialModel(true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
p := tea.NewProgram(tui.InitialModel(ctx, true, s, eng, version), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if _, err := p.Run(); err != nil {
|
||||
slog.Error("TUI failed", "err", err)
|
||||
}
|
||||
@@ -457,11 +489,11 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache)
|
||||
wish.WithAddress(fmt.Sprintf(":%d", port)),
|
||||
wish.WithHostKeyPath(envOrDefault("UPTOP_SSH_HOST_KEY", ".ssh/id_ed25519")),
|
||||
wish.WithPublicKeyAuth(func(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||
return kc.IsAllowed(key)
|
||||
return kc.IsAllowed(ctx, key)
|
||||
}),
|
||||
wish.WithMiddleware(
|
||||
bm.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
return tui.InitialModel(false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
|
||||
return tui.InitialModel(s.Context(), false, db, eng, version), []tea.ProgramOption{tea.WithAltScreen(), tea.WithMouseCellMotion()}
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -477,8 +509,7 @@ func startSSHServer(port int, db store.Store, eng *monitor.Engine, kc *keyCache)
|
||||
return s
|
||||
}
|
||||
|
||||
func seedDemoData(s store.Store) {
|
||||
ctx := context.Background()
|
||||
func seedDemoData(ctx context.Context, s store.Store) {
|
||||
existing, _ := s.GetSites(ctx)
|
||||
if len(existing) > 0 {
|
||||
return
|
||||
@@ -539,8 +570,8 @@ func newKeyCache(db store.Store) *keyCache {
|
||||
return &keyCache{db: db, ttl: 30 * time.Second}
|
||||
}
|
||||
|
||||
func (c *keyCache) refresh() {
|
||||
users, err := c.db.GetAllUsers(context.Background())
|
||||
func (c *keyCache) refresh(ctx context.Context) {
|
||||
users, err := c.db.GetAllUsers(ctx)
|
||||
if err != nil {
|
||||
// Keep the previous key set: a transient DB error must not lock every
|
||||
// admin out. Revocation still fails closed because Invalidate clears
|
||||
@@ -573,13 +604,13 @@ func (c *keyCache) Invalidate() {
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *keyCache) IsAllowed(incomingKey ssh.PublicKey) bool {
|
||||
func (c *keyCache) IsAllowed(ctx context.Context, incomingKey ssh.PublicKey) bool {
|
||||
c.mu.RLock()
|
||||
stale := time.Since(c.updated) > c.ttl
|
||||
c.mu.RUnlock()
|
||||
|
||||
if stale {
|
||||
c.refresh()
|
||||
c.refresh(ctx)
|
||||
}
|
||||
|
||||
c.mu.RLock()
|
||||
@@ -625,8 +656,7 @@ func (s *userInvalidatingStore) ImportData(ctx context.Context, data models.Back
|
||||
return err
|
||||
}
|
||||
|
||||
func seedKeysFromEnv(s store.Store) {
|
||||
ctx := context.Background()
|
||||
func seedKeysFromEnv(ctx context.Context, s store.Store) {
|
||||
var keys []string
|
||||
|
||||
if v := os.Getenv("UPTOP_ADMIN_KEY"); v != "" {
|
||||
|
||||
@@ -5,6 +5,8 @@ services:
|
||||
leader:
|
||||
image: lerkolabs/uptop:latest
|
||||
container_name: uptop-leader
|
||||
sysctls:
|
||||
- net.ipv4.ping_group_range=0 2147483647
|
||||
ports:
|
||||
- "23234:23234" # SSH
|
||||
- "8080:8080" # HTTP
|
||||
@@ -40,6 +42,8 @@ services:
|
||||
follower:
|
||||
image: lerkolabs/uptop:latest
|
||||
container_name: uptop-follower
|
||||
sysctls:
|
||||
- net.ipv4.ping_group_range=0 2147483647
|
||||
ports:
|
||||
- "23233:23234" # SSH (Mapped to different host port)
|
||||
- "8081:8080" # HTTP (Mapped to different host port)
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
- UPTOP_DB_TYPE=postgres
|
||||
- UPTOP_DB_DSN=postgres://devuser:devpass@postgres:5432/uptop_dev?sslmode=disable
|
||||
|
||||
# --- Web Server Configuration (Phase 4) ---
|
||||
# --- Web Server Configuration ---
|
||||
- UPTOP_HTTP_PORT=8080
|
||||
- UPTOP_STATUS_ENABLED=true
|
||||
- UPTOP_STATUS_TITLE=Dev Infrastructure Status
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
services:
|
||||
leader:
|
||||
image: lerkolabs/uptop:latest
|
||||
sysctls:
|
||||
- net.ipv4.ping_group_range=0 2147483647
|
||||
environment:
|
||||
- UPTOP_CLUSTER_MODE=leader
|
||||
- UPTOP_CLUSTER_SECRET=changeme # EXAMPLE ONLY — rotate before use
|
||||
@@ -12,6 +14,8 @@ services:
|
||||
|
||||
probe-us-east:
|
||||
image: lerkolabs/uptop:latest
|
||||
healthcheck:
|
||||
disable: true
|
||||
environment:
|
||||
- UPTOP_CLUSTER_MODE=probe
|
||||
- UPTOP_NODE_ID=us-east-1
|
||||
@@ -24,6 +28,8 @@ services:
|
||||
|
||||
probe-eu-west:
|
||||
image: lerkolabs/uptop:latest
|
||||
healthcheck:
|
||||
disable: true
|
||||
environment:
|
||||
- UPTOP_CLUSTER_MODE=probe
|
||||
- UPTOP_NODE_ID=eu-west-1
|
||||
|
||||
@@ -8,6 +8,8 @@ services:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
sysctls:
|
||||
- net.ipv4.ping_group_range=0 2147483647
|
||||
tmpfs:
|
||||
- /tmp
|
||||
ports:
|
||||
|
||||
@@ -47,13 +47,11 @@ Probes are lightweight, stateless nodes that run checks from different locations
|
||||
| Node | Variable | Value |
|
||||
|------|----------|-------|
|
||||
| Both | `UPTOP_CLUSTER_SECRET` | Same shared secret |
|
||||
| Leader | `UPTOP_AGG_STRATEGY` | `any-down`, `majority-down`, or `all-down` |
|
||||
| Probe | `UPTOP_CLUSTER_MODE` | `probe` |
|
||||
| Probe | `UPTOP_PEER_URL` | Leader's HTTP URL |
|
||||
| Probe | `UPTOP_NODE_ID` | Unique identifier (e.g. `probe-us-east`) |
|
||||
| Probe | `UPTOP_NODE_REGION` | Region tag matching monitor assignments |
|
||||
|
||||
Optional: `UPTOP_NODE_NAME` for a human-readable label in the TUI.
|
||||
Optional: `UPTOP_AGG_STRATEGY` (default `any-down`), `UPTOP_NODE_REGION` (omit to match all monitors), `UPTOP_NODE_NAME` (human-readable label in the TUI).
|
||||
|
||||
See [`deploy/docker-compose.probe.yml`](../deploy/docker-compose.probe.yml) for a multi-region example.
|
||||
|
||||
@@ -80,6 +78,6 @@ Set via `UPTOP_AGG_STRATEGY` on the leader.
|
||||
|
||||
## Security
|
||||
|
||||
- Set `UPTOP_CLUSTER_SECRET` on all nodes. Without it, cluster API endpoints are unauthenticated.
|
||||
- Set `UPTOP_CLUSTER_SECRET` on all nodes. Without it, cluster API endpoints reject all requests (fail closed); only `/api/health` stays open.
|
||||
- Secrets are sent in HTTP headers (`X-Uptop-Secret`). Use TLS or a reverse proxy for production.
|
||||
- uptop warns on startup if the cluster secret is missing or if cluster mode is active without TLS.
|
||||
|
||||
@@ -18,7 +18,9 @@ import (
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
var alertClient = &http.Client{Timeout: 10 * time.Second}
|
||||
const alertHTTPTimeout = 10 * time.Second
|
||||
|
||||
var alertClient = &http.Client{Timeout: alertHTTPTimeout}
|
||||
|
||||
// sanitizeError strips the request URL from transport errors before they are
|
||||
// stored or displayed. *url.Error embeds the full URL, which for several
|
||||
|
||||
@@ -38,14 +38,20 @@ func Start(ctx context.Context, cfg Config, eng *monitor.Engine) {
|
||||
// "probe" mode is handled directly in main.go before cluster.Start is called
|
||||
}
|
||||
|
||||
const (
|
||||
followerTimeout = 2 * time.Second
|
||||
leaderFailureThreshold = 3
|
||||
followerRetryInterval = 5 * time.Second
|
||||
)
|
||||
|
||||
func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
|
||||
client := http.Client{Timeout: 2 * time.Second}
|
||||
client := http.Client{Timeout: followerTimeout}
|
||||
failures := 0
|
||||
threshold := 3
|
||||
threshold := leaderFailureThreshold
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-time.After(5 * time.Second):
|
||||
case <-time.After(followerRetryInterval):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -59,7 +65,7 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
|
||||
isLeaderHealthy := false
|
||||
|
||||
if err == nil {
|
||||
isLeaderHealthy = resp.StatusCode == 200
|
||||
isLeaderHealthy = resp.StatusCode == http.StatusOK
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
|
||||
@@ -26,12 +26,18 @@ type ProbeConfig struct {
|
||||
AllowPrivateTargets bool
|
||||
}
|
||||
|
||||
const (
|
||||
probeMinInterval = 10
|
||||
probeDefaultInterval = 30
|
||||
probeAPITimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
func RunProbe(ctx context.Context, cfg ProbeConfig) error {
|
||||
if cfg.Interval < 10 {
|
||||
cfg.Interval = 30
|
||||
if cfg.Interval < probeMinInterval {
|
||||
cfg.Interval = probeDefaultInterval
|
||||
}
|
||||
|
||||
apiClient := &http.Client{Timeout: 10 * time.Second}
|
||||
apiClient := &http.Client{Timeout: probeAPITimeout}
|
||||
dial := monitor.SafeDialContext(cfg.AllowPrivateTargets)
|
||||
strictClient := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
|
||||
@@ -86,6 +86,11 @@ type ProbeNode struct {
|
||||
Version string
|
||||
}
|
||||
|
||||
type LogEntry struct {
|
||||
Message string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/alert"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
type AlertHealth struct {
|
||||
LastSendAt time.Time
|
||||
LastSendOK bool
|
||||
LastError string
|
||||
SendCount int
|
||||
FailCount int
|
||||
}
|
||||
|
||||
// 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(ctx context.Context) {
|
||||
records, err := e.db.LoadAlertHealth(ctx)
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleStatusChange folds a check result into the live state. snap is the
|
||||
// stale snapshot the check ran against; the actual mutation is applied onto the
|
||||
// CURRENT live entry via applyState, so a concurrent pause / config edit /
|
||||
// heartbeat is never reverted by this write. Logs and alerts are emitted after
|
||||
// the lock is released, off the critical section.
|
||||
func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int, latency time.Duration, errorReason string) {
|
||||
if !e.IsActive() {
|
||||
return
|
||||
}
|
||||
|
||||
inMaint := e.isInMaintenance(snap.ID)
|
||||
status := models.Status(rawStatus)
|
||||
|
||||
var (
|
||||
prev, next models.Status
|
||||
name, typ string
|
||||
alertID int
|
||||
failCount, maxRetries int
|
||||
confirmedDown bool
|
||||
failedCheck bool
|
||||
downSince time.Time
|
||||
sslWarnFire bool
|
||||
sslDays int
|
||||
skipped bool
|
||||
changed bool
|
||||
)
|
||||
|
||||
_, exists := e.applyState(snap.ID, func(s *models.Site) {
|
||||
// A non-UP result computed from a stale snapshot must not override a
|
||||
// heartbeat (or newer check) that landed while we were evaluating.
|
||||
if status != models.StatusUp && s.LastCheck.After(snap.LastCheck) {
|
||||
skipped = true
|
||||
return
|
||||
}
|
||||
|
||||
prev = s.Status
|
||||
name = s.Name
|
||||
typ = s.Type
|
||||
alertID = s.AlertID
|
||||
maxRetries = s.MaxRetries
|
||||
downSince = s.StatusChangedAt
|
||||
|
||||
// Fresh check results (measured by the run against snap).
|
||||
s.StatusCode = code
|
||||
s.Latency = snap.Latency
|
||||
s.LastCheck = snap.LastCheck
|
||||
s.HasSSL = snap.HasSSL
|
||||
s.CertExpiry = snap.CertExpiry
|
||||
s.LastError = errorReason
|
||||
if status == models.StatusUp {
|
||||
s.LastSuccessAt = time.Now()
|
||||
s.LastError = ""
|
||||
}
|
||||
|
||||
// Status + failure-count transition, based on the CURRENT live status.
|
||||
if status == models.StatusUp {
|
||||
s.FailureCount = 0
|
||||
s.Status = models.StatusUp
|
||||
} else {
|
||||
if s.FailureCount <= s.MaxRetries {
|
||||
s.FailureCount++
|
||||
}
|
||||
if s.FailureCount > s.MaxRetries {
|
||||
if s.Status != status {
|
||||
confirmedDown = true
|
||||
}
|
||||
s.Status = status
|
||||
s.FailureCount = s.MaxRetries + 1
|
||||
} else {
|
||||
failedCheck = true
|
||||
}
|
||||
}
|
||||
failCount = s.FailureCount
|
||||
|
||||
if s.Status != prev && prev != models.StatusPending {
|
||||
s.StatusChangedAt = time.Now()
|
||||
} else if s.StatusChangedAt.IsZero() && s.Status != models.StatusPending {
|
||||
s.StatusChangedAt = time.Now()
|
||||
}
|
||||
|
||||
// SSL expiry warning (fresh HasSSL/CertExpiry + config threshold).
|
||||
if typ == "http" && s.CheckSSL && s.HasSSL {
|
||||
days := int(time.Until(s.CertExpiry).Hours() / 24)
|
||||
if days <= s.ExpiryThreshold && !s.SentSSLWarning && status != models.StatusSSLExp {
|
||||
sslWarnFire = true
|
||||
sslDays = days
|
||||
s.SentSSLWarning = true
|
||||
} else if days > s.ExpiryThreshold {
|
||||
s.SentSSLWarning = false
|
||||
}
|
||||
}
|
||||
|
||||
next = s.Status
|
||||
changed = next != prev
|
||||
})
|
||||
|
||||
if !exists || skipped {
|
||||
return
|
||||
}
|
||||
|
||||
e.recordCheck(snap.ID, latency, status == models.StatusUp)
|
||||
|
||||
if confirmedDown {
|
||||
if errorReason != "" {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN: %s", name, errorReason))
|
||||
} else {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", name))
|
||||
}
|
||||
} else if failedCheck {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", name, failCount, maxRetries))
|
||||
}
|
||||
|
||||
if changed && prev != models.StatusPending {
|
||||
e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: string(prev), toStatus: string(next), reason: errorReason})
|
||||
}
|
||||
|
||||
if sslWarnFire {
|
||||
if !inMaint {
|
||||
e.triggerAlert(alertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", name, sslDays))
|
||||
} else {
|
||||
e.AddLog(fmt.Sprintf("SSL warning for '%s' suppressed (maintenance)", name))
|
||||
}
|
||||
}
|
||||
|
||||
if prev == models.StatusUp && next == models.StatusLate {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", name))
|
||||
}
|
||||
|
||||
if !prev.IsBroken() && next.IsBroken() && next != models.StatusPending {
|
||||
if inMaint {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", name))
|
||||
} else {
|
||||
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", name, rawStatus)
|
||||
if errorReason != "" {
|
||||
msg = fmt.Sprintf("Monitor '%s' is DOWN: %s", name, errorReason)
|
||||
}
|
||||
if typ == "push" {
|
||||
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", name)
|
||||
}
|
||||
e.triggerAlert(alertID, "🚨 ALERT", msg)
|
||||
}
|
||||
}
|
||||
if prev.IsBroken() && next == models.StatusUp {
|
||||
downDur := ""
|
||||
if !downSince.IsZero() {
|
||||
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
|
||||
}
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' recovered%s", name, downDur))
|
||||
if !inMaint {
|
||||
e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", name, downDur))
|
||||
}
|
||||
}
|
||||
if prev == models.StatusLate && next == models.StatusUp && !prev.IsBroken() {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", name))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) triggerAlert(alertID int, title, message string) {
|
||||
if alertID <= 0 {
|
||||
return
|
||||
}
|
||||
cfg, err := e.db.GetAlert(e.ctx, alertID)
|
||||
if err != nil {
|
||||
e.AddLog(fmt.Sprintf("Failed to load alert config %d: %v", alertID, err))
|
||||
return
|
||||
}
|
||||
provider := alert.GetProvider(cfg)
|
||||
if provider != nil {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(e.ctx, alertSendTimeout)
|
||||
defer cancel()
|
||||
if err := provider.Send(ctx, title, message); err != nil {
|
||||
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 so health survives restarts; DB IO off the alert path.
|
||||
e.enqueueWrite(writeAlertHealth{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(ctx context.Context, alertID int) error {
|
||||
cfg, err := e.db.GetAlert(ctx, 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(ctx, alertSendTimeout)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
// --- Group 1: State Machine ---
|
||||
|
||||
func TestHandleStatusChange_PendingToUp(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 3, AlertID: 1},
|
||||
SiteState: models.SiteState{Status: "PENDING"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.handleStatusChange(site, "UP", 200, 10*time.Millisecond, "")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "UP" {
|
||||
t.Errorf("expected UP, got %s", s.Status)
|
||||
}
|
||||
if s.FailureCount != 0 {
|
||||
t.Errorf("expected FailureCount 0, got %d", s.FailureCount)
|
||||
}
|
||||
waitAsync()
|
||||
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||
t.Error("expected no alert for PENDING→UP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_UpIncrementFailure(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 3},
|
||||
SiteState: models.SiteState{Status: "UP", FailureCount: 0},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.handleStatusChange(site, "DOWN", 500, 0, "test error")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "UP" {
|
||||
t.Errorf("expected UP (under retry threshold), got %s", s.Status)
|
||||
}
|
||||
if s.FailureCount != 1 {
|
||||
t.Errorf("expected FailureCount 1, got %d", s.FailureCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_UpToDown_ExceedsRetries(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "discord", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 2, AlertID: 1},
|
||||
SiteState: models.SiteState{Status: "UP", FailureCount: 2},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.handleStatusChange(site, "DOWN", 500, 0, "test error")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "DOWN" {
|
||||
t.Errorf("expected DOWN, got %s", s.Status)
|
||||
}
|
||||
if s.FailureCount != 3 {
|
||||
t.Errorf("expected FailureCount 3, got %d", s.FailureCount)
|
||||
}
|
||||
waitAsync()
|
||||
calls := ms.getAlertCallsSnapshot()
|
||||
if len(calls) == 0 || calls[0] != 1 {
|
||||
t.Errorf("expected alert call for alertID 1, got %v", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_UpToDown_ZeroRetries(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
|
||||
SiteState: models.SiteState{Status: "UP", FailureCount: 0},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "DOWN" {
|
||||
t.Errorf("expected DOWN, got %s", s.Status)
|
||||
}
|
||||
waitAsync()
|
||||
if len(ms.getAlertCallsSnapshot()) == 0 {
|
||||
t.Error("expected alert on immediate DOWN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_DownToUp_Recovery(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", AlertID: 1},
|
||||
SiteState: models.SiteState{Status: "DOWN", FailureCount: 4},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "UP" {
|
||||
t.Errorf("expected UP, got %s", s.Status)
|
||||
}
|
||||
if s.FailureCount != 0 {
|
||||
t.Errorf("expected FailureCount 0, got %d", s.FailureCount)
|
||||
}
|
||||
waitAsync()
|
||||
if len(ms.getAlertCallsSnapshot()) == 0 {
|
||||
t.Error("expected recovery alert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_DownStaysDown(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 2},
|
||||
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "DOWN" {
|
||||
t.Errorf("expected DOWN, got %s", s.Status)
|
||||
}
|
||||
waitAsync()
|
||||
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||
t.Error("expected no re-alert for already DOWN")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_SSLExpired(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.handleStatusChange(site, "SSL EXP", 0, 0, "SSL certificate expired")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "SSL EXP" {
|
||||
t.Errorf("expected SSL EXP, got %s", s.Status)
|
||||
}
|
||||
waitAsync()
|
||||
if len(ms.getAlertCallsSnapshot()) == 0 {
|
||||
t.Error("expected alert on SSL EXP")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_AlertSuppressedMaintenance(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
ms.maintenance[1] = true
|
||||
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0, AlertID: 1},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
e.refreshMaintenanceCache(context.Background())
|
||||
|
||||
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "DOWN" {
|
||||
t.Errorf("expected DOWN, got %s", s.Status)
|
||||
}
|
||||
waitAsync()
|
||||
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||
t.Error("expected no alert during maintenance")
|
||||
}
|
||||
logs := e.GetLogs()
|
||||
found := false
|
||||
for _, l := range logs {
|
||||
if containsStr(l.Message, "suppressed") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("expected log mentioning suppressed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_RecoverySuppressedMaintenance(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
ms.maintenance[1] = true
|
||||
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", AlertID: 1},
|
||||
SiteState: models.SiteState{Status: "DOWN"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
e.refreshMaintenanceCache(context.Background())
|
||||
|
||||
e.handleStatusChange(site, "UP", 200, 0, "")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "UP" {
|
||||
t.Errorf("expected UP, got %s", s.Status)
|
||||
}
|
||||
waitAsync()
|
||||
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||
t.Error("expected no alert during maintenance recovery")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_SSLWarning(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1},
|
||||
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: false, CertExpiry: time.Now().Add(15 * 24 * time.Hour)},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.handleStatusChange(site, "UP", 200, 0, "")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if !s.SentSSLWarning {
|
||||
t.Error("expected SentSSLWarning=true")
|
||||
}
|
||||
waitAsync()
|
||||
if len(ms.getAlertCallsSnapshot()) == 0 {
|
||||
t.Error("expected SSL warning alert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_SSLWarningNotRepeated(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1},
|
||||
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: true, CertExpiry: time.Now().Add(15 * 24 * time.Hour)},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.handleStatusChange(site, "UP", 200, 0, "")
|
||||
|
||||
waitAsync()
|
||||
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||
t.Error("expected no repeat SSL warning")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_SSLWarningReset(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30},
|
||||
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: true, CertExpiry: time.Now().Add(60 * 24 * time.Hour)},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.handleStatusChange(site, "UP", 200, 0, "")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.SentSSLWarning {
|
||||
t.Error("expected SentSSLWarning reset to false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_SSLWarningSuppressedMaint(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
ms.maintenance[1] = true
|
||||
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", CheckSSL: true, ExpiryThreshold: 30, AlertID: 1},
|
||||
SiteState: models.SiteState{Status: "UP", HasSSL: true, SentSSLWarning: false, CertExpiry: time.Now().Add(15 * 24 * time.Hour)},
|
||||
}
|
||||
injectSite(e, site)
|
||||
e.refreshMaintenanceCache(context.Background())
|
||||
|
||||
e.handleStatusChange(site, "UP", 200, 0, "")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if !s.SentSSLWarning {
|
||||
t.Error("expected SentSSLWarning=true even in maintenance")
|
||||
}
|
||||
waitAsync()
|
||||
if len(ms.getAlertCallsSnapshot()) != 0 {
|
||||
t.Error("expected no alert during maintenance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleStatusChange_InactiveEngine(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
e.SetActive(false)
|
||||
|
||||
e.handleStatusChange(site, "DOWN", 0, 0, "test error")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "UP" {
|
||||
t.Error("expected no state change when inactive")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group 10: liveState merge (lost-update race) ---
|
||||
|
||||
// A pause that lands while a check is in flight must survive the check's
|
||||
// write-back. The old code snapshotted the site, ran the check, then wrote the
|
||||
// whole stale struct back — reverting the pause.
|
||||
func TestHandleStatusChange_PauseDuringCheckSurvives(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
// `site` is the stale snapshot the check ran against (Paused=false).
|
||||
// Meanwhile the user pauses the monitor.
|
||||
e.ToggleSitePause(1)
|
||||
|
||||
// Check completes and folds its result in using the stale snapshot.
|
||||
e.handleStatusChange(site, "DOWN", 500, 0, "boom")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if !s.Paused {
|
||||
t.Error("pause was reverted by a stale check write-back")
|
||||
}
|
||||
if s.Status != "DOWN" {
|
||||
t.Errorf("expected check result still applied (DOWN), got %s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// A config edit that lands while a check is in flight must survive; the check
|
||||
// must not resurrect the old config from its snapshot.
|
||||
func TestHandleStatusChange_ConfigEditDuringCheckSurvives(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", URL: "http://old.com", Type: "http", MaxRetries: 0, Interval: 30},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
// Config changes mid-check.
|
||||
e.UpdateSiteConfig(models.SiteConfig{ID: 1, Name: "test", URL: "http://new.com", Type: "http", Interval: 60})
|
||||
|
||||
// Stale check (ran against http://old.com) folds its result in.
|
||||
e.handleStatusChange(site, "UP", 200, 5*time.Millisecond, "")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.URL != "http://new.com" {
|
||||
t.Errorf("config edit reverted: URL=%s", s.URL)
|
||||
}
|
||||
if s.Interval != 60 {
|
||||
t.Errorf("config edit reverted: Interval=%d", s.Interval)
|
||||
}
|
||||
}
|
||||
|
||||
// The classic push false-DOWN: a heartbeat marks the monitor UP while a
|
||||
// staleness evaluation (computed from the older LastCheck) is mid-flight.
|
||||
// The stale DOWN must not overwrite the fresh heartbeat.
|
||||
func TestHandleStatusChange_HeartbeatNotOverwrittenByStaleDown(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
// Snapshot the engine would have taken before evaluating staleness:
|
||||
// LastCheck is old, so checkPush decided "DOWN".
|
||||
snap := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Token: "tok", Interval: 10},
|
||||
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-120 * time.Second)},
|
||||
}
|
||||
injectSite(e, snap)
|
||||
|
||||
// A heartbeat lands first, advancing LastCheck and confirming UP.
|
||||
if !e.RecordHeartbeat("tok") {
|
||||
t.Fatal("heartbeat rejected")
|
||||
}
|
||||
|
||||
// Now the in-flight stale evaluation tries to write DOWN.
|
||||
e.handleStatusChange(snap, "DOWN", 0, 0, "heartbeat missed")
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "UP" {
|
||||
t.Errorf("stale DOWN overwrote a fresh heartbeat: status=%s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// A check result for a site removed mid-check must be dropped, not recreate it.
|
||||
func TestHandleStatusChange_RemovedSiteDropped(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", MaxRetries: 0},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.RemoveSite(1)
|
||||
e.handleStatusChange(site, "DOWN", 500, 0, "boom")
|
||||
|
||||
if _, ok := getSite(e, 1); ok {
|
||||
t.Error("removed site was recreated by a late check write-back")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group 12: Phase 3 engine correctness ---
|
||||
|
||||
// PENDING→DOWN must honor MaxRetries instead of alerting on first failure.
|
||||
func TestHandleStatusChange_PendingRetriesBeforeDown(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "new-monitor", MaxRetries: 2},
|
||||
SiteState: models.SiteState{Status: "PENDING"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.handleStatusChange(site, "DOWN", 0, 0, "timeout")
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "PENDING" {
|
||||
t.Errorf("expected PENDING during retry, got %s", s.Status)
|
||||
}
|
||||
if s.FailureCount != 1 {
|
||||
t.Errorf("expected FailureCount 1, got %d", s.FailureCount)
|
||||
}
|
||||
|
||||
e.handleStatusChange(s, "DOWN", 0, 0, "timeout")
|
||||
s, _ = getSite(e, 1)
|
||||
if s.Status != "PENDING" {
|
||||
t.Errorf("expected PENDING during retry 2, got %s", s.Status)
|
||||
}
|
||||
|
||||
e.handleStatusChange(s, "DOWN", 0, 0, "timeout")
|
||||
s, _ = getSite(e, 1)
|
||||
if s.Status != "DOWN" {
|
||||
t.Errorf("expected DOWN after retries exhausted, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// LATE→DOWN must also honor MaxRetries.
|
||||
func TestHandleStatusChange_LateRetriesBeforeDown(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "push-mon", MaxRetries: 1},
|
||||
SiteState: models.SiteState{Status: "LATE"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.handleStatusChange(site, "DOWN", 0, 0, "missed heartbeat")
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "LATE" {
|
||||
t.Errorf("expected LATE during retry, got %s", s.Status)
|
||||
}
|
||||
|
||||
e.handleStatusChange(s, "DOWN", 0, 0, "missed heartbeat")
|
||||
s, _ = getSite(e, 1)
|
||||
if s.Status != "DOWN" {
|
||||
t.Errorf("expected DOWN after retries exhausted, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
@@ -130,8 +130,14 @@ func TestRunCheck_Port_Open(t *testing.T) {
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
_, portStr, err := net.SplitHostPort(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatalf("Atoi: %v", err)
|
||||
}
|
||||
|
||||
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
|
||||
result := RunCheck(context.Background(), site, nil, nil, false, true)
|
||||
@@ -149,8 +155,14 @@ func TestRunCheck_Port_Closed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
_, portStr, err := net.SplitHostPort(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatalf("Atoi: %v", err)
|
||||
}
|
||||
ln.Close()
|
||||
|
||||
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 1}
|
||||
@@ -168,8 +180,14 @@ func TestRunPortCheck_UsesPinnedIP(t *testing.T) {
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
_, portStr, err := net.SplitHostPort(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatalf("Atoi: %v", err)
|
||||
}
|
||||
|
||||
// Pass a pinned IP — runPortCheck should dial it instead of resolving Hostname.
|
||||
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "will-not-resolve.invalid", Port: port, Timeout: 2}
|
||||
@@ -187,8 +205,14 @@ func TestRunPortCheck_NilPinnedIP_UsesHostname(t *testing.T) {
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
_, portStr, err := net.SplitHostPort(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatalf("Atoi: %v", err)
|
||||
}
|
||||
|
||||
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
|
||||
result := runPortCheck(context.Background(), site, nil)
|
||||
@@ -205,8 +229,14 @@ func TestRunCheck_Port_BlocksPrivateByDefault(t *testing.T) {
|
||||
}
|
||||
defer ln.Close()
|
||||
|
||||
_, portStr, _ := net.SplitHostPort(ln.Addr().String())
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
_, portStr, err := net.SplitHostPort(ln.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatalf("SplitHostPort: %v", err)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatalf("Atoi: %v", err)
|
||||
}
|
||||
|
||||
site := models.SiteConfig{ID: 1, Type: "port", Hostname: "127.0.0.1", Port: port, Timeout: 2}
|
||||
result := RunCheck(context.Background(), site, nil, nil, false, false)
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func (e *Engine) RecordHeartbeat(token string) bool {
|
||||
if !e.IsActive() {
|
||||
return false
|
||||
}
|
||||
|
||||
e.mu.RLock()
|
||||
targetID, ok := e.tokenIndex[token]
|
||||
e.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
prevStatus models.Status
|
||||
name string
|
||||
alertID int
|
||||
downSince time.Time
|
||||
)
|
||||
_, exists := e.applyState(targetID, func(s *models.Site) {
|
||||
prevStatus = s.Status
|
||||
name = s.Name
|
||||
alertID = s.AlertID
|
||||
downSince = s.StatusChangedAt // captured before mutation = when it went down
|
||||
|
||||
s.LastCheck = time.Now()
|
||||
s.Status = models.StatusUp
|
||||
s.FailureCount = 0
|
||||
s.Latency = 0
|
||||
s.LastError = ""
|
||||
s.LastSuccessAt = time.Now()
|
||||
if prevStatus != models.StatusUp {
|
||||
s.StatusChangedAt = time.Now()
|
||||
}
|
||||
})
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
switch prevStatus {
|
||||
case models.StatusPending:
|
||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", name))
|
||||
case models.StatusLate:
|
||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", name))
|
||||
case models.StatusStale:
|
||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was stale)", name))
|
||||
case models.StatusDown:
|
||||
downDur := ""
|
||||
if !downSince.IsZero() {
|
||||
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
|
||||
}
|
||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered%s", name, downDur))
|
||||
go e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", name, downDur))
|
||||
}
|
||||
|
||||
e.recordCheck(targetID, 0, true)
|
||||
|
||||
if prevStatus != models.StatusUp && prevStatus != models.StatusPending {
|
||||
e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: string(prevStatus), toStatus: string(models.StatusUp)})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *Engine) getRecheckChan(id int) chan struct{} {
|
||||
e.recheckMu.Lock()
|
||||
defer e.recheckMu.Unlock()
|
||||
ch, ok := e.recheck[id]
|
||||
if !ok {
|
||||
ch = make(chan struct{}, 1)
|
||||
e.recheck[id] = ch
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (e *Engine) signalRecheck(id int) {
|
||||
ch := e.getRecheckChan(id)
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) monitorRoutine(ctx context.Context, id int) {
|
||||
recheckCh := e.getRecheckChan(id)
|
||||
|
||||
// Stagger initial check to avoid thundering herd on startup
|
||||
stagger := time.Duration(rand.IntN(3000)) * time.Millisecond //nolint:gosec // non-security jitter
|
||||
select {
|
||||
case <-time.After(stagger):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
e.checkByID(ctx, id)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if !e.IsActive() {
|
||||
select {
|
||||
case <-time.After(pollInterval):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-recheckCh:
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
e.mu.RLock()
|
||||
site, exists := e.liveState[id]
|
||||
e.mu.RUnlock()
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
if site.Paused {
|
||||
select {
|
||||
case <-time.After(pollInterval):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-recheckCh:
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
interval := site.Interval
|
||||
if interval < minCheckInterval {
|
||||
interval = minCheckInterval
|
||||
}
|
||||
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
|
||||
select {
|
||||
case <-time.After(time.Duration(interval)*time.Second + jitter):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-recheckCh:
|
||||
}
|
||||
e.checkByID(ctx, id)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) checkByID(ctx context.Context, id int) {
|
||||
if !e.IsActive() {
|
||||
return
|
||||
}
|
||||
|
||||
e.mu.RLock()
|
||||
site, exists := e.liveState[id]
|
||||
e.mu.RUnlock()
|
||||
if !exists || site.Paused {
|
||||
return
|
||||
}
|
||||
|
||||
switch site.Type {
|
||||
case "push":
|
||||
e.checkPush(ctx, site)
|
||||
case "group":
|
||||
e.checkGroup(ctx, site)
|
||||
default:
|
||||
result := RunCheck(ctx, site.SiteConfig, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets)
|
||||
updatedSite := site
|
||||
updatedSite.HasSSL = result.HasSSL
|
||||
updatedSite.CertExpiry = result.CertExpiry
|
||||
updatedSite.Latency = time.Duration(result.LatencyNs)
|
||||
updatedSite.LastCheck = time.Now()
|
||||
e.handleStatusChange(updatedSite, result.Status, result.StatusCode, time.Duration(result.LatencyNs), result.ErrorReason)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) checkPush(_ context.Context, site models.Site) {
|
||||
if site.Status == models.StatusPending {
|
||||
return
|
||||
}
|
||||
|
||||
interval := time.Duration(site.Interval) * time.Second
|
||||
grace := interval / 2
|
||||
if grace < minPushGrace {
|
||||
grace = minPushGrace
|
||||
}
|
||||
|
||||
overdue := site.LastCheck.Add(interval)
|
||||
staleMark := overdue.Add(grace / 2)
|
||||
graceEnd := overdue.Add(grace)
|
||||
now := time.Now()
|
||||
|
||||
if now.After(graceEnd) {
|
||||
if site.Status != models.StatusDown {
|
||||
e.handleStatusChange(site, string(models.StatusDown), 0, 0, "heartbeat missed")
|
||||
}
|
||||
} else if now.After(staleMark) {
|
||||
if site.Status != models.StatusStale {
|
||||
e.handleStatusChange(site, string(models.StatusStale), 0, 0, "heartbeat stale")
|
||||
}
|
||||
} else if now.After(overdue) {
|
||||
if site.Status != models.StatusLate {
|
||||
e.handleStatusChange(site, string(models.StatusLate), 0, 0, "heartbeat overdue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) checkGroup(_ context.Context, site models.Site) {
|
||||
e.mu.RLock()
|
||||
status := models.StatusUp
|
||||
hasChildren := false
|
||||
for _, child := range e.liveState {
|
||||
if child.ParentID != site.ID || child.Type == "group" {
|
||||
continue
|
||||
}
|
||||
hasChildren = true
|
||||
if child.Paused || e.isInMaintenance(child.ID) {
|
||||
continue
|
||||
}
|
||||
if child.Status == models.StatusDown || child.Status == models.StatusSSLExp {
|
||||
status = models.StatusDown
|
||||
} else if child.Status == models.StatusStale && status != models.StatusDown {
|
||||
status = models.StatusStale
|
||||
} else if child.Status == models.StatusLate && status != models.StatusDown && status != models.StatusStale {
|
||||
status = models.StatusLate
|
||||
} else if child.Status == models.StatusPending && status != models.StatusDown && status != models.StatusStale && status != models.StatusLate {
|
||||
status = models.StatusPending
|
||||
}
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
if !hasChildren {
|
||||
status = models.StatusPending
|
||||
}
|
||||
|
||||
var prev models.Status
|
||||
e.applyState(site.ID, func(s *models.Site) {
|
||||
prev = s.Status
|
||||
s.Status = status
|
||||
if status != prev && prev != models.StatusPending {
|
||||
s.StatusChangedAt = time.Now()
|
||||
}
|
||||
})
|
||||
e.recordCheck(site.ID, 0, !status.IsBroken())
|
||||
|
||||
if status != prev && prev != models.StatusPending {
|
||||
e.enqueueWrite(writeStateChange{siteID: site.ID, fromStatus: string(prev), toStatus: string(status)})
|
||||
if status.IsBroken() {
|
||||
e.AddLog(fmt.Sprintf("Group '%s' is %s", site.Name, status))
|
||||
} else if prev.IsBroken() {
|
||||
e.AddLog(fmt.Sprintf("Group '%s' recovered", site.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) EnqueueProbeCheck(siteID int, nodeID string, latencyNs int64, isUp bool) {
|
||||
e.enqueueWrite(writeProbeCheck{siteID: siteID, nodeID: nodeID, latencyNs: latencyNs, isUp: isUp})
|
||||
}
|
||||
|
||||
// SetAggStrategy must be called before Start: the field is read by the probe
|
||||
// aggregation path without synchronization.
|
||||
func (e *Engine) SetAggStrategy(strategy AggregationStrategy) {
|
||||
e.aggStrategy = strategy
|
||||
}
|
||||
|
||||
func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool, errorReason string) {
|
||||
e.mu.RLock()
|
||||
site, exists := e.liveState[siteID]
|
||||
e.mu.RUnlock()
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
staleAfter := time.Duration(site.Interval) * time.Second * 3
|
||||
if staleAfter < time.Minute {
|
||||
staleAfter = time.Minute
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
e.probeResultsMu.Lock()
|
||||
if e.probeResults[siteID] == nil {
|
||||
e.probeResults[siteID] = make(map[string]NodeResult)
|
||||
}
|
||||
e.probeResults[siteID][nodeID] = NodeResult{
|
||||
NodeID: nodeID,
|
||||
IsUp: isUp,
|
||||
LatencyNs: latencyNs,
|
||||
CheckedAt: now,
|
||||
ErrorReason: errorReason,
|
||||
}
|
||||
results := make([]NodeResult, 0, len(e.probeResults[siteID]))
|
||||
for id, r := range e.probeResults[siteID] {
|
||||
if now.Sub(r.CheckedAt) > staleAfter {
|
||||
delete(e.probeResults[siteID], id)
|
||||
continue
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
e.probeResultsMu.Unlock()
|
||||
|
||||
aggUp, avgLatency := AggregateStatus(results, e.aggStrategy)
|
||||
|
||||
probeStatus := models.StatusUp
|
||||
if !aggUp {
|
||||
probeStatus = models.StatusDown
|
||||
}
|
||||
|
||||
updatedSite := site
|
||||
updatedSite.Latency = time.Duration(avgLatency)
|
||||
updatedSite.LastCheck = time.Now()
|
||||
e.handleStatusChange(updatedSite, string(probeStatus), 0, time.Duration(avgLatency), errorReason)
|
||||
}
|
||||
|
||||
func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
|
||||
e.probeResultsMu.RLock()
|
||||
defer e.probeResultsMu.RUnlock()
|
||||
src := e.probeResults[siteID]
|
||||
cp := make(map[string]NodeResult, len(src))
|
||||
for k, v := range src {
|
||||
cp[k] = v
|
||||
}
|
||||
return cp
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
// --- Group 2: Heartbeat ---
|
||||
|
||||
func TestRecordHeartbeat_ValidToken(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "push-test", Type: "push", Token: "abc123"},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
if !e.RecordHeartbeat("abc123") {
|
||||
t.Error("expected true for valid token")
|
||||
}
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "UP" {
|
||||
t.Errorf("expected UP, got %s", s.Status)
|
||||
}
|
||||
if time.Since(s.LastCheck) > time.Second {
|
||||
t.Error("expected LastCheck to be recent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordHeartbeat_RecoveryFromDown(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
ms.alerts[1] = models.AlertConfig{ID: 1, Name: "test", Type: "webhook", Settings: map[string]string{"url": "http://example.com"}}
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "push-test", Type: "push", Token: "abc123", AlertID: 1},
|
||||
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
if !e.RecordHeartbeat("abc123") {
|
||||
t.Error("expected true")
|
||||
}
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "UP" {
|
||||
t.Errorf("expected UP, got %s", s.Status)
|
||||
}
|
||||
if s.FailureCount != 0 {
|
||||
t.Errorf("expected FailureCount 0, got %d", s.FailureCount)
|
||||
}
|
||||
waitAsync()
|
||||
if len(ms.getAlertCallsSnapshot()) == 0 {
|
||||
t.Error("expected recovery alert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordHeartbeat_UnknownToken(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
|
||||
if e.RecordHeartbeat("unknown") {
|
||||
t.Error("expected false for unknown token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordHeartbeat_InactiveEngine(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Type: "push", Token: "abc123"},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
e.SetActive(false)
|
||||
|
||||
if e.RecordHeartbeat("abc123") {
|
||||
t.Error("expected false when inactive")
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group 3: Push Deadline ---
|
||||
|
||||
func TestCheckPush_DeadlineMissed(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 10, MaxRetries: 0},
|
||||
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-120 * time.Second)},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.checkPush(context.Background(), site)
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "DOWN" {
|
||||
t.Errorf("expected DOWN after missed deadline, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPush_OverdueBecomesLate(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 300},
|
||||
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-310 * time.Second)},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.checkPush(context.Background(), site)
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "LATE" {
|
||||
t.Errorf("expected LATE when overdue but within grace, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPush_OverdueBecomesStale(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
// interval=300, grace=150 (300/2), staleMark=overdue+75
|
||||
// at 380s: past staleMark(375) but before graceEnd(450)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 300},
|
||||
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now().Add(-380 * time.Second)},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.checkPush(context.Background(), site)
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "STALE" {
|
||||
t.Errorf("expected STALE when past midpoint of grace, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPush_WithinDeadline(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 60},
|
||||
SiteState: models.SiteState{Status: "UP", LastCheck: time.Now()},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.checkPush(context.Background(), site)
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "UP" {
|
||||
t.Errorf("expected UP, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPush_PendingStaysPending(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "push", Type: "push", Interval: 60},
|
||||
SiteState: models.SiteState{Status: "PENDING"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.checkPush(context.Background(), site)
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "PENDING" {
|
||||
t.Errorf("expected PENDING to stay until first heartbeat, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Group 4: Group Checks ---
|
||||
|
||||
func TestCheckGroup_AllChildrenUp(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
group := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
|
||||
SiteState: models.SiteState{Status: "PENDING"},
|
||||
}
|
||||
child1 := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
child2 := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, group)
|
||||
injectSite(e, child1)
|
||||
injectSite(e, child2)
|
||||
|
||||
e.checkGroup(context.Background(), group)
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "UP" {
|
||||
t.Errorf("expected group UP, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckGroup_OneChildDown(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
group := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
child1 := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
child2 := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1},
|
||||
SiteState: models.SiteState{Status: "DOWN"},
|
||||
}
|
||||
injectSite(e, group)
|
||||
injectSite(e, child1)
|
||||
injectSite(e, child2)
|
||||
|
||||
e.checkGroup(context.Background(), group)
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "DOWN" {
|
||||
t.Errorf("expected group DOWN, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckGroup_PausedChildIgnored(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
group := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
|
||||
}
|
||||
child1 := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
child2 := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1, Paused: true},
|
||||
SiteState: models.SiteState{Status: "DOWN"},
|
||||
}
|
||||
injectSite(e, group)
|
||||
injectSite(e, child1)
|
||||
injectSite(e, child2)
|
||||
|
||||
e.checkGroup(context.Background(), group)
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "UP" {
|
||||
t.Errorf("expected UP (paused child ignored), got %s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckGroup_MaintenanceChildIgnored(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
ms.maintenance[3] = true
|
||||
e := newTestEngine(ms)
|
||||
group := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
|
||||
}
|
||||
child1 := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
child2 := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1},
|
||||
SiteState: models.SiteState{Status: "DOWN"},
|
||||
}
|
||||
injectSite(e, group)
|
||||
injectSite(e, child1)
|
||||
injectSite(e, child2)
|
||||
e.refreshMaintenanceCache(context.Background())
|
||||
|
||||
e.checkGroup(context.Background(), group)
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "UP" {
|
||||
t.Errorf("expected UP (maint child ignored), got %s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckGroup_NoChildren(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
group := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, group)
|
||||
|
||||
e.checkGroup(context.Background(), group)
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Status != "PENDING" {
|
||||
t.Errorf("expected PENDING for no children, got %s", s.Status)
|
||||
}
|
||||
}
|
||||
|
||||
// Groups must not auto-pause when all children are paused — that creates a
|
||||
// one-way trap because monitorRoutine skips paused sites.
|
||||
func TestCheckGroup_AllPausedNoAutoFreeze(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
group := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "group", Type: "group"},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
child1 := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 2, Name: "child1", Type: "http", ParentID: 1, Paused: true},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
child2 := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 3, Name: "child2", Type: "http", ParentID: 1, Paused: true},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, group)
|
||||
injectSite(e, child1)
|
||||
injectSite(e, child2)
|
||||
|
||||
e.checkGroup(context.Background(), group)
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.Paused {
|
||||
t.Error("group must not auto-pause when all children are paused")
|
||||
}
|
||||
}
|
||||
|
||||
// Dead probe results must be expired so they don't poison aggregation.
|
||||
func TestIngestProbeResult_ExpiresStaleProbes(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http", Interval: 30},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.probeResultsMu.Lock()
|
||||
e.probeResults[1] = map[string]NodeResult{
|
||||
"dead-probe": {
|
||||
NodeID: "dead-probe",
|
||||
IsUp: false,
|
||||
CheckedAt: time.Now().Add(-10 * time.Minute),
|
||||
},
|
||||
}
|
||||
e.probeResultsMu.Unlock()
|
||||
|
||||
e.IngestProbeResult("live-probe", 1, 5000, true, "")
|
||||
|
||||
e.probeResultsMu.RLock()
|
||||
_, deadExists := e.probeResults[1]["dead-probe"]
|
||||
_, liveExists := e.probeResults[1]["live-probe"]
|
||||
e.probeResultsMu.RUnlock()
|
||||
|
||||
if deadExists {
|
||||
t.Error("stale probe result should have been expired")
|
||||
}
|
||||
if !liveExists {
|
||||
t.Error("live probe result should still exist")
|
||||
}
|
||||
}
|
||||
|
||||
// RemoveSite must clean up probeResults.
|
||||
func TestRemoveSite_CleansProbeResults(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http"},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
e.probeResultsMu.Lock()
|
||||
e.probeResults[1] = map[string]NodeResult{
|
||||
"node-a": {NodeID: "node-a", IsUp: true, CheckedAt: time.Now()},
|
||||
}
|
||||
e.probeResultsMu.Unlock()
|
||||
|
||||
e.RemoveSite(1)
|
||||
|
||||
e.probeResultsMu.RLock()
|
||||
defer e.probeResultsMu.RUnlock()
|
||||
if _, exists := e.probeResults[1]; exists {
|
||||
t.Error("probe results should be cleaned up after RemoveSite")
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ type SiteHistory struct {
|
||||
UpChecks int
|
||||
}
|
||||
|
||||
func (e *Engine) InitHistory() {
|
||||
all, err := e.db.LoadAllHistory(context.Background(), maxHistoryLen)
|
||||
func (e *Engine) InitHistory(ctx context.Context) {
|
||||
all, err := e.db.LoadAllHistory(ctx, maxHistoryLen)
|
||||
if err != nil {
|
||||
e.AddLog("Failed to load check history: " + err.Error())
|
||||
return
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (e *Engine) maintenancePruner(ctx context.Context) {
|
||||
ticker := time.NewTicker(maintPruneInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
e.pruneMaintenanceWindows(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
e.pruneMaintenanceWindows(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) pruneMaintenanceWindows(ctx context.Context) {
|
||||
pruned, err := e.db.PruneExpiredMaintenanceWindows(ctx, e.maintRetention)
|
||||
if err != nil {
|
||||
e.AddLog(fmt.Sprintf("Maintenance prune error: %v", err))
|
||||
return
|
||||
}
|
||||
if pruned > 0 {
|
||||
e.AddLog(fmt.Sprintf("Pruned %d expired maintenance window(s)", pruned))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) isInMaintenance(monitorID int) bool {
|
||||
e.maintCacheMu.RLock()
|
||||
defer e.maintCacheMu.RUnlock()
|
||||
return e.maintCache[monitorID]
|
||||
}
|
||||
|
||||
func (e *Engine) refreshMaintenanceCache(ctx context.Context) {
|
||||
windows, err := e.db.GetActiveMaintenanceWindows(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
directMaint := make(map[int]bool)
|
||||
var globalMaint bool
|
||||
for _, w := range windows {
|
||||
if w.MonitorID == 0 {
|
||||
globalMaint = true
|
||||
} else {
|
||||
directMaint[w.MonitorID] = true
|
||||
}
|
||||
}
|
||||
|
||||
resolved := make(map[int]bool)
|
||||
e.mu.RLock()
|
||||
for id, site := range e.liveState {
|
||||
if globalMaint || directMaint[id] || (site.ParentID > 0 && directMaint[site.ParentID]) {
|
||||
resolved[id] = true
|
||||
}
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
e.maintCacheMu.Lock()
|
||||
e.maintCache = resolved
|
||||
e.maintCacheMu.Unlock()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
// Maintenance cache resolves parent relationships correctly.
|
||||
func TestIsInMaintenance_UsesCache(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
ms.maintenance[10] = true // direct maintenance on group
|
||||
e := newTestEngine(ms)
|
||||
group := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 10, Name: "group", Type: "group"},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
child := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 20, Name: "child", Type: "http", ParentID: 10},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, group)
|
||||
injectSite(e, child)
|
||||
e.refreshMaintenanceCache(context.Background())
|
||||
|
||||
if !e.isInMaintenance(10) {
|
||||
t.Error("group should be in maintenance (direct)")
|
||||
}
|
||||
if !e.isInMaintenance(20) {
|
||||
t.Error("child should be in maintenance (parent)")
|
||||
}
|
||||
if e.isInMaintenance(99) {
|
||||
t.Error("unknown monitor should not be in maintenance")
|
||||
}
|
||||
}
|
||||
|
||||
// Global maintenance (monitor_id=0) applies to all monitors.
|
||||
func TestIsInMaintenance_GlobalMaintenance(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
ms.maintenance[0] = true
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "http"},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
e.refreshMaintenanceCache(context.Background())
|
||||
|
||||
if !e.isInMaintenance(1) {
|
||||
t.Error("all monitors should be in maintenance during global window")
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,12 @@ import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/alert"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
)
|
||||
@@ -24,23 +22,16 @@ const (
|
||||
maintPruneInterval = 15 * time.Minute
|
||||
defaultMaintRetention = 7 * 24 * time.Hour
|
||||
dbWriteBuffer = 4096
|
||||
alertSendTimeout = 30 * time.Second
|
||||
dbPruneInterval = 10 * time.Minute
|
||||
)
|
||||
|
||||
type AlertHealth struct {
|
||||
LastSendAt time.Time
|
||||
LastSendOK bool
|
||||
LastError string
|
||||
SendCount int
|
||||
FailCount int
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
mu sync.RWMutex
|
||||
liveState map[int]models.Site
|
||||
|
||||
logMu sync.RWMutex
|
||||
logStore []string
|
||||
logStore []models.LogEntry
|
||||
|
||||
activeMu sync.RWMutex
|
||||
isActive bool
|
||||
@@ -73,6 +64,7 @@ type Engine struct {
|
||||
dbWrites chan dbWrite
|
||||
writerWG sync.WaitGroup
|
||||
checkerWG sync.WaitGroup
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
stopOnce sync.Once
|
||||
}
|
||||
@@ -99,6 +91,7 @@ func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
|
||||
allowPrivateTargets: allowPrivateTargets,
|
||||
maintRetention: defaultMaintRetention,
|
||||
dbWrites: make(chan dbWrite, dbWriteBuffer),
|
||||
ctx: context.Background(),
|
||||
db: s,
|
||||
strictClient: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
@@ -152,11 +145,13 @@ func fmtDurationShort(d time.Duration) string {
|
||||
// appendLog adds a timestamped entry to the in-memory ring buffer and returns
|
||||
// it. It never touches the database, so it is safe to call from the db-write
|
||||
// drop/error path without recursing back through the write queue.
|
||||
func (e *Engine) appendLog(msg string) string {
|
||||
ts := time.Now().Format("15:04:05")
|
||||
entry := fmt.Sprintf("[%s] %s", ts, sanitizeLog(msg))
|
||||
func (e *Engine) appendLog(msg string) models.LogEntry {
|
||||
entry := models.LogEntry{
|
||||
Message: sanitizeLog(msg),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
e.logMu.Lock()
|
||||
e.logStore = append([]string{entry}, e.logStore...)
|
||||
e.logStore = append([]models.LogEntry{entry}, e.logStore...)
|
||||
if len(e.logStore) > maxLogEntries {
|
||||
e.logStore = e.logStore[:maxLogEntries]
|
||||
}
|
||||
@@ -166,7 +161,7 @@ func (e *Engine) appendLog(msg string) string {
|
||||
|
||||
func (e *Engine) AddLog(msg string) {
|
||||
entry := e.appendLog(msg)
|
||||
e.enqueueWrite(writeLog{message: entry})
|
||||
e.enqueueWrite(writeLog{message: entry.Message})
|
||||
}
|
||||
|
||||
// enqueueWrite hands a persistence task to the writer goroutine without
|
||||
@@ -207,6 +202,8 @@ func (e *Engine) dbWriter(ctx context.Context) {
|
||||
}
|
||||
|
||||
// drainWrites flushes everything still buffered, best-effort, at shutdown.
|
||||
// Uses context.Background because the engine ctx is already cancelled when
|
||||
// this runs — writes still need to reach the DB.
|
||||
func (e *Engine) drainWrites() {
|
||||
for {
|
||||
select {
|
||||
@@ -245,169 +242,33 @@ func (e *Engine) Stop() {
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Engine) InitLogs() {
|
||||
logs, err := e.db.LoadLogs(context.Background(), maxLogEntries)
|
||||
func (e *Engine) InitLogs(ctx context.Context) {
|
||||
entries, err := e.db.LoadLogs(ctx, maxLogEntries)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if len(logs) == 0 {
|
||||
if len(entries) == 0 {
|
||||
return
|
||||
}
|
||||
e.logMu.Lock()
|
||||
defer e.logMu.Unlock()
|
||||
e.logStore = logs
|
||||
e.logStore = entries
|
||||
}
|
||||
|
||||
// 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(context.Background())
|
||||
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() []models.LogEntry {
|
||||
e.logMu.RLock()
|
||||
defer e.logMu.RUnlock()
|
||||
logs := make([]string, len(e.logStore))
|
||||
logs := make([]models.LogEntry, len(e.logStore))
|
||||
copy(logs, e.logStore)
|
||||
return logs
|
||||
}
|
||||
|
||||
func (e *Engine) SetActive(active bool) {
|
||||
e.activeMu.Lock()
|
||||
defer e.activeMu.Unlock()
|
||||
if e.isActive != active {
|
||||
e.isActive = active
|
||||
status := "RESUMED (Active)"
|
||||
if !active {
|
||||
status = "PAUSED (Passive)"
|
||||
}
|
||||
e.AddLog(fmt.Sprintf("Engine %s", status))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) IsActive() bool {
|
||||
e.activeMu.RLock()
|
||||
defer e.activeMu.RUnlock()
|
||||
return e.isActive
|
||||
}
|
||||
|
||||
func (e *Engine) GetAllSites() []models.Site {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
sites := make([]models.Site, 0, len(e.liveState))
|
||||
for _, s := range e.liveState {
|
||||
sites = append(sites, s)
|
||||
}
|
||||
return sites
|
||||
}
|
||||
|
||||
func (e *Engine) GetLiveState() map[int]models.Site {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
cp := make(map[int]models.Site, len(e.liveState))
|
||||
for k, v := range e.liveState {
|
||||
cp[k] = v
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
func (e *Engine) RecordHeartbeat(token string) bool {
|
||||
if !e.IsActive() {
|
||||
return false
|
||||
}
|
||||
|
||||
e.mu.RLock()
|
||||
targetID, ok := e.tokenIndex[token]
|
||||
e.mu.RUnlock()
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
var (
|
||||
prevStatus models.Status
|
||||
name string
|
||||
alertID int
|
||||
downSince time.Time
|
||||
)
|
||||
_, exists := e.applyState(targetID, func(s *models.Site) {
|
||||
prevStatus = s.Status
|
||||
name = s.Name
|
||||
alertID = s.AlertID
|
||||
downSince = s.StatusChangedAt // captured before mutation = when it went down
|
||||
|
||||
s.LastCheck = time.Now()
|
||||
s.Status = models.StatusUp
|
||||
s.FailureCount = 0
|
||||
s.Latency = 0
|
||||
s.LastError = ""
|
||||
s.LastSuccessAt = time.Now()
|
||||
if prevStatus != models.StatusUp {
|
||||
s.StatusChangedAt = time.Now()
|
||||
}
|
||||
})
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
switch prevStatus {
|
||||
case models.StatusPending:
|
||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' received first heartbeat", name))
|
||||
case models.StatusLate:
|
||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", name))
|
||||
case models.StatusStale:
|
||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was stale)", name))
|
||||
case models.StatusDown:
|
||||
downDur := ""
|
||||
if !downSince.IsZero() {
|
||||
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
|
||||
}
|
||||
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered%s", name, downDur))
|
||||
go e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", name, downDur))
|
||||
}
|
||||
|
||||
e.recordCheck(targetID, 0, true)
|
||||
|
||||
if prevStatus != models.StatusUp && prevStatus != models.StatusPending {
|
||||
e.enqueueWrite(writeStateChange{siteID: targetID, fromStatus: string(prevStatus), toStatus: string(models.StatusUp)})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *Engine) addToTokenIndex(site models.Site) {
|
||||
if site.Type == "push" && site.Token != "" {
|
||||
e.tokenIndex[site.Token] = site.ID
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) removeFromTokenIndex(id int) {
|
||||
for token, sid := range e.tokenIndex {
|
||||
if sid == id {
|
||||
delete(e.tokenIndex, token)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) Start(ctx context.Context) {
|
||||
// e.cancel is invoked by Stop() to drain and halt the writer; gosec can't
|
||||
// trace the cross-method call, and cancelling the parent reaps this child
|
||||
// regardless, so the leak it warns about can't occur.
|
||||
ctx, e.cancel = context.WithCancel(ctx) //nolint:gosec // cancel is called in Stop()
|
||||
e.ctx = ctx
|
||||
|
||||
e.writerWG.Add(1)
|
||||
go e.dbWriter(ctx)
|
||||
@@ -422,8 +283,6 @@ func (e *Engine) Start(ctx context.Context) {
|
||||
default:
|
||||
}
|
||||
|
||||
e.refreshMaintenanceCache(ctx)
|
||||
|
||||
configs, err := e.db.GetSites(ctx)
|
||||
if err != nil {
|
||||
e.AddLog(fmt.Sprintf("Failed to load sites: %v", err))
|
||||
@@ -466,6 +325,10 @@ func (e *Engine) Start(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh after sites load so the cache covers newly added sites.
|
||||
// On first iteration liveState was empty before the loop above.
|
||||
e.refreshMaintenanceCache(ctx)
|
||||
|
||||
e.mu.RLock()
|
||||
var vanished []int
|
||||
for id := range e.liveState {
|
||||
@@ -493,637 +356,3 @@ func (e *Engine) Start(ctx context.Context) {
|
||||
e.maintenancePruner(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
func (e *Engine) maintenancePruner(ctx context.Context) {
|
||||
ticker := time.NewTicker(maintPruneInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
e.pruneMaintenanceWindows(ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
e.pruneMaintenanceWindows(ctx)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) pruneMaintenanceWindows(ctx context.Context) {
|
||||
pruned, err := e.db.PruneExpiredMaintenanceWindows(ctx, e.maintRetention)
|
||||
if err != nil {
|
||||
e.AddLog(fmt.Sprintf("Maintenance prune error: %v", err))
|
||||
return
|
||||
}
|
||||
if pruned > 0 {
|
||||
e.AddLog(fmt.Sprintf("Pruned %d expired maintenance window(s)", pruned))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) UpdateSiteConfig(cfg models.SiteConfig) {
|
||||
e.mu.Lock()
|
||||
if existing, ok := e.liveState[cfg.ID]; ok {
|
||||
e.removeFromTokenIndex(cfg.ID)
|
||||
existing.SiteConfig = cfg
|
||||
e.liveState[cfg.ID] = existing
|
||||
e.addToTokenIndex(existing)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
e.signalRecheck(cfg.ID)
|
||||
}
|
||||
|
||||
func (e *Engine) getRecheckChan(id int) chan struct{} {
|
||||
e.recheckMu.Lock()
|
||||
defer e.recheckMu.Unlock()
|
||||
ch, ok := e.recheck[id]
|
||||
if !ok {
|
||||
ch = make(chan struct{}, 1)
|
||||
e.recheck[id] = ch
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (e *Engine) signalRecheck(id int) {
|
||||
ch := e.getRecheckChan(id)
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) RemoveSite(id int) {
|
||||
e.mu.Lock()
|
||||
e.removeFromTokenIndex(id)
|
||||
delete(e.liveState, id)
|
||||
e.mu.Unlock()
|
||||
e.removeHistory(id)
|
||||
|
||||
e.probeResultsMu.Lock()
|
||||
delete(e.probeResults, id)
|
||||
e.probeResultsMu.Unlock()
|
||||
|
||||
e.recheckMu.Lock()
|
||||
delete(e.recheck, id)
|
||||
e.recheckMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *Engine) ToggleSitePause(id int) bool {
|
||||
var (
|
||||
paused bool
|
||||
name string
|
||||
)
|
||||
_, ok := e.applyState(id, func(s *models.Site) {
|
||||
s.Paused = !s.Paused
|
||||
paused = s.Paused
|
||||
name = s.Name
|
||||
})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if paused {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' paused", name))
|
||||
} else {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' resumed", name))
|
||||
}
|
||||
return paused
|
||||
}
|
||||
|
||||
func (e *Engine) monitorRoutine(ctx context.Context, id int) {
|
||||
recheckCh := e.getRecheckChan(id)
|
||||
|
||||
// Stagger initial check to avoid thundering herd on startup
|
||||
stagger := time.Duration(rand.IntN(3000)) * time.Millisecond //nolint:gosec // non-security jitter
|
||||
select {
|
||||
case <-time.After(stagger):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
|
||||
e.checkByID(ctx, id)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if !e.IsActive() {
|
||||
select {
|
||||
case <-time.After(pollInterval):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-recheckCh:
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
e.mu.RLock()
|
||||
site, exists := e.liveState[id]
|
||||
e.mu.RUnlock()
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
if site.Paused {
|
||||
select {
|
||||
case <-time.After(pollInterval):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-recheckCh:
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
interval := site.Interval
|
||||
if interval < minCheckInterval {
|
||||
interval = minCheckInterval
|
||||
}
|
||||
jitter := time.Duration(rand.IntN(interval*100)) * time.Millisecond //nolint:gosec // non-security jitter
|
||||
select {
|
||||
case <-time.After(time.Duration(interval)*time.Second + jitter):
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-recheckCh:
|
||||
}
|
||||
e.checkByID(ctx, id)
|
||||
}
|
||||
}
|
||||
|
||||
// applyState atomically reads, mutates, and writes back the live entry for id.
|
||||
// The mutator runs under the engine write lock and receives a pointer to the
|
||||
// CURRENT live state, so concurrent config edits, pauses, and heartbeats are
|
||||
// never clobbered by a stale snapshot. The mutator must only touch runtime /
|
||||
// check-result fields — config fields (Name/URL/Type/Token/Interval/AlertID/…)
|
||||
// are owned by UpdateSiteConfig and must not be written here. Returns the
|
||||
// post-mutation copy and whether the site still exists.
|
||||
func (e *Engine) applyState(id int, mutate func(s *models.Site)) (models.Site, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
cur, ok := e.liveState[id]
|
||||
if !ok {
|
||||
return models.Site{}, false
|
||||
}
|
||||
mutate(&cur)
|
||||
e.liveState[id] = cur
|
||||
return cur, true
|
||||
}
|
||||
|
||||
func (e *Engine) checkByID(ctx context.Context, id int) {
|
||||
if !e.IsActive() {
|
||||
return
|
||||
}
|
||||
|
||||
e.mu.RLock()
|
||||
site, exists := e.liveState[id]
|
||||
e.mu.RUnlock()
|
||||
if !exists || site.Paused {
|
||||
return
|
||||
}
|
||||
|
||||
switch site.Type {
|
||||
case "push":
|
||||
e.checkPush(ctx, site)
|
||||
case "group":
|
||||
e.checkGroup(ctx, site)
|
||||
default:
|
||||
result := RunCheck(ctx, site.SiteConfig, e.strictClient, e.insecureClient, e.insecureSkipVerify, e.allowPrivateTargets)
|
||||
updatedSite := site
|
||||
updatedSite.HasSSL = result.HasSSL
|
||||
updatedSite.CertExpiry = result.CertExpiry
|
||||
updatedSite.Latency = time.Duration(result.LatencyNs)
|
||||
updatedSite.LastCheck = time.Now()
|
||||
e.handleStatusChange(updatedSite, result.Status, result.StatusCode, time.Duration(result.LatencyNs), result.ErrorReason)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) checkPush(_ context.Context, site models.Site) {
|
||||
if site.Status == models.StatusPending {
|
||||
return
|
||||
}
|
||||
|
||||
interval := time.Duration(site.Interval) * time.Second
|
||||
grace := interval / 2
|
||||
if grace < minPushGrace {
|
||||
grace = minPushGrace
|
||||
}
|
||||
|
||||
overdue := site.LastCheck.Add(interval)
|
||||
staleMark := overdue.Add(grace / 2)
|
||||
graceEnd := overdue.Add(grace)
|
||||
now := time.Now()
|
||||
|
||||
if now.After(graceEnd) {
|
||||
if site.Status != models.StatusDown {
|
||||
e.handleStatusChange(site, string(models.StatusDown), 0, 0, "heartbeat missed")
|
||||
}
|
||||
} else if now.After(staleMark) {
|
||||
if site.Status != models.StatusStale {
|
||||
e.handleStatusChange(site, string(models.StatusStale), 0, 0, "heartbeat stale")
|
||||
}
|
||||
} else if now.After(overdue) {
|
||||
if site.Status != models.StatusLate {
|
||||
e.handleStatusChange(site, string(models.StatusLate), 0, 0, "heartbeat overdue")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleStatusChange folds a check result into the live state. snap is the
|
||||
// stale snapshot the check ran against; the actual mutation is applied onto the
|
||||
// CURRENT live entry via applyState, so a concurrent pause / config edit /
|
||||
// heartbeat is never reverted by this write. Logs and alerts are emitted after
|
||||
// the lock is released, off the critical section.
|
||||
func (e *Engine) handleStatusChange(snap models.Site, rawStatus string, code int, latency time.Duration, errorReason string) {
|
||||
if !e.IsActive() {
|
||||
return
|
||||
}
|
||||
|
||||
inMaint := e.isInMaintenance(snap.ID)
|
||||
status := models.Status(rawStatus)
|
||||
|
||||
var (
|
||||
prev, next models.Status
|
||||
name, typ string
|
||||
alertID int
|
||||
failCount, maxRetries int
|
||||
confirmedDown bool
|
||||
failedCheck bool
|
||||
downSince time.Time
|
||||
sslWarnFire bool
|
||||
sslDays int
|
||||
skipped bool
|
||||
changed bool
|
||||
)
|
||||
|
||||
_, exists := e.applyState(snap.ID, func(s *models.Site) {
|
||||
// A non-UP result computed from a stale snapshot must not override a
|
||||
// heartbeat (or newer check) that landed while we were evaluating.
|
||||
if status != models.StatusUp && s.LastCheck.After(snap.LastCheck) {
|
||||
skipped = true
|
||||
return
|
||||
}
|
||||
|
||||
prev = s.Status
|
||||
name = s.Name
|
||||
typ = s.Type
|
||||
alertID = s.AlertID
|
||||
maxRetries = s.MaxRetries
|
||||
downSince = s.StatusChangedAt
|
||||
|
||||
// Fresh check results (measured by the run against snap).
|
||||
s.StatusCode = code
|
||||
s.Latency = snap.Latency
|
||||
s.LastCheck = snap.LastCheck
|
||||
s.HasSSL = snap.HasSSL
|
||||
s.CertExpiry = snap.CertExpiry
|
||||
s.LastError = errorReason
|
||||
if status == models.StatusUp {
|
||||
s.LastSuccessAt = time.Now()
|
||||
s.LastError = ""
|
||||
}
|
||||
|
||||
// Status + failure-count transition, based on the CURRENT live status.
|
||||
if status == models.StatusUp {
|
||||
s.FailureCount = 0
|
||||
s.Status = models.StatusUp
|
||||
} else {
|
||||
if s.FailureCount <= s.MaxRetries {
|
||||
s.FailureCount++
|
||||
}
|
||||
if s.FailureCount > s.MaxRetries {
|
||||
if s.Status != status {
|
||||
confirmedDown = true
|
||||
}
|
||||
s.Status = status
|
||||
s.FailureCount = s.MaxRetries + 1
|
||||
} else {
|
||||
failedCheck = true
|
||||
}
|
||||
}
|
||||
failCount = s.FailureCount
|
||||
|
||||
if s.Status != prev && prev != models.StatusPending {
|
||||
s.StatusChangedAt = time.Now()
|
||||
} else if s.StatusChangedAt.IsZero() && s.Status != models.StatusPending {
|
||||
s.StatusChangedAt = time.Now()
|
||||
}
|
||||
|
||||
// SSL expiry warning (fresh HasSSL/CertExpiry + config threshold).
|
||||
if typ == "http" && s.CheckSSL && s.HasSSL {
|
||||
days := int(time.Until(s.CertExpiry).Hours() / 24)
|
||||
if days <= s.ExpiryThreshold && !s.SentSSLWarning && status != models.StatusSSLExp {
|
||||
sslWarnFire = true
|
||||
sslDays = days
|
||||
s.SentSSLWarning = true
|
||||
} else if days > s.ExpiryThreshold {
|
||||
s.SentSSLWarning = false
|
||||
}
|
||||
}
|
||||
|
||||
next = s.Status
|
||||
changed = next != prev
|
||||
})
|
||||
|
||||
if !exists || skipped {
|
||||
return
|
||||
}
|
||||
|
||||
e.recordCheck(snap.ID, latency, status == models.StatusUp)
|
||||
|
||||
if confirmedDown {
|
||||
if errorReason != "" {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN: %s", name, errorReason))
|
||||
} else {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' confirmed DOWN", name))
|
||||
}
|
||||
} else if failedCheck {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' failed check %d/%d", name, failCount, maxRetries))
|
||||
}
|
||||
|
||||
if changed && prev != models.StatusPending {
|
||||
e.enqueueWrite(writeStateChange{siteID: snap.ID, fromStatus: string(prev), toStatus: string(next), reason: errorReason})
|
||||
}
|
||||
|
||||
if sslWarnFire {
|
||||
if !inMaint {
|
||||
e.triggerAlert(alertID, "SSL WARNING", fmt.Sprintf("SSL for '%s' expires in %d days", name, sslDays))
|
||||
} else {
|
||||
e.AddLog(fmt.Sprintf("SSL warning for '%s' suppressed (maintenance)", name))
|
||||
}
|
||||
}
|
||||
|
||||
if prev == models.StatusUp && next == models.StatusLate {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", name))
|
||||
}
|
||||
|
||||
if !prev.IsBroken() && next.IsBroken() && next != models.StatusPending {
|
||||
if inMaint {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", name))
|
||||
} else {
|
||||
msg := fmt.Sprintf("Monitor '%s' is DOWN (%s)", name, rawStatus)
|
||||
if errorReason != "" {
|
||||
msg = fmt.Sprintf("Monitor '%s' is DOWN: %s", name, errorReason)
|
||||
}
|
||||
if typ == "push" {
|
||||
msg = fmt.Sprintf("Push Monitor '%s' missed heartbeat.", name)
|
||||
}
|
||||
e.triggerAlert(alertID, "🚨 ALERT", msg)
|
||||
}
|
||||
}
|
||||
if prev.IsBroken() && next == models.StatusUp {
|
||||
downDur := ""
|
||||
if !downSince.IsZero() {
|
||||
downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(downSince)))
|
||||
}
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' recovered%s", name, downDur))
|
||||
if !inMaint {
|
||||
e.triggerAlert(alertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", name, downDur))
|
||||
}
|
||||
}
|
||||
if prev == models.StatusLate && next == models.StatusUp && !prev.IsBroken() {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", name))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) triggerAlert(alertID int, title, message string) {
|
||||
if alertID <= 0 {
|
||||
return
|
||||
}
|
||||
cfg, err := e.db.GetAlert(context.Background(), alertID)
|
||||
if err != nil {
|
||||
e.AddLog(fmt.Sprintf("Failed to load alert config %d: %v", alertID, err))
|
||||
return
|
||||
}
|
||||
provider := alert.GetProvider(cfg)
|
||||
if provider != nil {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := provider.Send(ctx, title, message); err != nil {
|
||||
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 so health survives restarts; DB IO off the alert path.
|
||||
e.enqueueWrite(writeAlertHealth{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(context.Background(), 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 {
|
||||
e.maintCacheMu.RLock()
|
||||
defer e.maintCacheMu.RUnlock()
|
||||
return e.maintCache[monitorID]
|
||||
}
|
||||
|
||||
func (e *Engine) refreshMaintenanceCache(ctx context.Context) {
|
||||
windows, err := e.db.GetActiveMaintenanceWindows(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
directMaint := make(map[int]bool)
|
||||
var globalMaint bool
|
||||
for _, w := range windows {
|
||||
if w.MonitorID == 0 {
|
||||
globalMaint = true
|
||||
} else {
|
||||
directMaint[w.MonitorID] = true
|
||||
}
|
||||
}
|
||||
|
||||
resolved := make(map[int]bool)
|
||||
e.mu.RLock()
|
||||
for id, site := range e.liveState {
|
||||
if globalMaint || directMaint[id] || (site.ParentID > 0 && directMaint[site.ParentID]) {
|
||||
resolved[id] = true
|
||||
}
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
e.maintCacheMu.Lock()
|
||||
e.maintCache = resolved
|
||||
e.maintCacheMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *Engine) GetDisplayStatus(site models.Site) string {
|
||||
if site.Paused {
|
||||
return "PAUSED"
|
||||
}
|
||||
if e.isInMaintenance(site.ID) {
|
||||
return "MAINT"
|
||||
}
|
||||
return string(site.Status)
|
||||
}
|
||||
|
||||
func (e *Engine) checkGroup(_ context.Context, site models.Site) {
|
||||
e.mu.RLock()
|
||||
status := models.StatusUp
|
||||
hasChildren := false
|
||||
for _, child := range e.liveState {
|
||||
if child.ParentID != site.ID || child.Type == "group" {
|
||||
continue
|
||||
}
|
||||
hasChildren = true
|
||||
if child.Paused || e.isInMaintenance(child.ID) {
|
||||
continue
|
||||
}
|
||||
if child.Status == models.StatusDown || child.Status == models.StatusSSLExp {
|
||||
status = models.StatusDown
|
||||
} else if child.Status == models.StatusStale && status != models.StatusDown {
|
||||
status = models.StatusStale
|
||||
} else if child.Status == models.StatusLate && status != models.StatusDown && status != models.StatusStale {
|
||||
status = models.StatusLate
|
||||
} else if child.Status == models.StatusPending && status != models.StatusDown && status != models.StatusStale && status != models.StatusLate {
|
||||
status = models.StatusPending
|
||||
}
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
if !hasChildren {
|
||||
status = models.StatusPending
|
||||
}
|
||||
|
||||
e.applyState(site.ID, func(s *models.Site) {
|
||||
s.Status = status
|
||||
})
|
||||
e.recordCheck(site.ID, 0, !status.IsBroken())
|
||||
}
|
||||
|
||||
func (e *Engine) EnqueueProbeCheck(siteID int, nodeID string, latencyNs int64, isUp bool) {
|
||||
e.enqueueWrite(writeProbeCheck{siteID: siteID, nodeID: nodeID, latencyNs: latencyNs, isUp: isUp})
|
||||
}
|
||||
|
||||
// SetAggStrategy must be called before Start: the field is read by the probe
|
||||
// aggregation path without synchronization.
|
||||
func (e *Engine) SetAggStrategy(strategy AggregationStrategy) {
|
||||
e.aggStrategy = strategy
|
||||
}
|
||||
|
||||
func (e *Engine) IngestProbeResult(nodeID string, siteID int, latencyNs int64, isUp bool, errorReason string) {
|
||||
e.mu.RLock()
|
||||
site, exists := e.liveState[siteID]
|
||||
e.mu.RUnlock()
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
|
||||
staleAfter := time.Duration(site.Interval) * time.Second * 3
|
||||
if staleAfter < time.Minute {
|
||||
staleAfter = time.Minute
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
e.probeResultsMu.Lock()
|
||||
if e.probeResults[siteID] == nil {
|
||||
e.probeResults[siteID] = make(map[string]NodeResult)
|
||||
}
|
||||
e.probeResults[siteID][nodeID] = NodeResult{
|
||||
NodeID: nodeID,
|
||||
IsUp: isUp,
|
||||
LatencyNs: latencyNs,
|
||||
CheckedAt: now,
|
||||
ErrorReason: errorReason,
|
||||
}
|
||||
results := make([]NodeResult, 0, len(e.probeResults[siteID]))
|
||||
for id, r := range e.probeResults[siteID] {
|
||||
if now.Sub(r.CheckedAt) > staleAfter {
|
||||
delete(e.probeResults[siteID], id)
|
||||
continue
|
||||
}
|
||||
results = append(results, r)
|
||||
}
|
||||
e.probeResultsMu.Unlock()
|
||||
|
||||
aggUp, avgLatency := AggregateStatus(results, e.aggStrategy)
|
||||
|
||||
probeStatus := models.StatusUp
|
||||
if !aggUp {
|
||||
probeStatus = models.StatusDown
|
||||
}
|
||||
|
||||
updatedSite := site
|
||||
updatedSite.Latency = time.Duration(avgLatency)
|
||||
updatedSite.LastCheck = time.Now()
|
||||
e.handleStatusChange(updatedSite, string(probeStatus), 0, time.Duration(avgLatency), errorReason)
|
||||
}
|
||||
|
||||
func (e *Engine) GetProbeResults(siteID int) map[string]NodeResult {
|
||||
e.probeResultsMu.RLock()
|
||||
defer e.probeResultsMu.RUnlock()
|
||||
src := e.probeResults[siteID]
|
||||
cp := make(map[string]NodeResult, len(src))
|
||||
for k, v := range src {
|
||||
cp[k] = v
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
func (e *Engine) GetStateChanges(siteID int, limit int) []models.StateChange {
|
||||
changes, err := e.db.GetStateChanges(context.Background(), siteID, limit)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func (e *Engine) GetStateChangesSince(siteID int, since time.Time) []models.StateChange {
|
||||
changes, err := e.db.GetStateChangesSince(context.Background(), siteID, since)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const dialTimeout = 10 * time.Second
|
||||
|
||||
var privateRanges []*net.IPNet
|
||||
|
||||
func init() {
|
||||
@@ -60,7 +62,7 @@ func SafeDialContext(allowPrivate bool) func(ctx context.Context, network, addr
|
||||
}
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
||||
dialer := &net.Dialer{Timeout: dialTimeout}
|
||||
for _, ip := range ips {
|
||||
target := net.JoinHostPort(ip.IP.String(), port)
|
||||
conn, err := dialer.DialContext(ctx, network, target)
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func (e *Engine) SetActive(active bool) {
|
||||
e.activeMu.Lock()
|
||||
defer e.activeMu.Unlock()
|
||||
if e.isActive != active {
|
||||
e.isActive = active
|
||||
status := "RESUMED (Active)"
|
||||
if !active {
|
||||
status = "PAUSED (Passive)"
|
||||
}
|
||||
e.AddLog(fmt.Sprintf("Engine %s", status))
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) IsActive() bool {
|
||||
e.activeMu.RLock()
|
||||
defer e.activeMu.RUnlock()
|
||||
return e.isActive
|
||||
}
|
||||
|
||||
func (e *Engine) GetAllSites() []models.Site {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
sites := make([]models.Site, 0, len(e.liveState))
|
||||
for _, s := range e.liveState {
|
||||
sites = append(sites, s)
|
||||
}
|
||||
return sites
|
||||
}
|
||||
|
||||
func (e *Engine) GetLiveState() map[int]models.Site {
|
||||
e.mu.RLock()
|
||||
defer e.mu.RUnlock()
|
||||
cp := make(map[int]models.Site, len(e.liveState))
|
||||
for k, v := range e.liveState {
|
||||
cp[k] = v
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
func (e *Engine) addToTokenIndex(site models.Site) {
|
||||
if site.Type == "push" && site.Token != "" {
|
||||
e.tokenIndex[site.Token] = site.ID
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) removeFromTokenIndex(id int) {
|
||||
for token, sid := range e.tokenIndex {
|
||||
if sid == id {
|
||||
delete(e.tokenIndex, token)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) UpdateSiteConfig(cfg models.SiteConfig) {
|
||||
e.mu.Lock()
|
||||
if existing, ok := e.liveState[cfg.ID]; ok {
|
||||
e.removeFromTokenIndex(cfg.ID)
|
||||
existing.SiteConfig = cfg
|
||||
e.liveState[cfg.ID] = existing
|
||||
e.addToTokenIndex(existing)
|
||||
}
|
||||
e.mu.Unlock()
|
||||
|
||||
e.signalRecheck(cfg.ID)
|
||||
}
|
||||
|
||||
func (e *Engine) RemoveSite(id int) {
|
||||
e.mu.Lock()
|
||||
e.removeFromTokenIndex(id)
|
||||
delete(e.liveState, id)
|
||||
e.mu.Unlock()
|
||||
e.removeHistory(id)
|
||||
|
||||
e.probeResultsMu.Lock()
|
||||
delete(e.probeResults, id)
|
||||
e.probeResultsMu.Unlock()
|
||||
|
||||
e.recheckMu.Lock()
|
||||
delete(e.recheck, id)
|
||||
e.recheckMu.Unlock()
|
||||
}
|
||||
|
||||
func (e *Engine) ToggleSitePause(id int) bool {
|
||||
var (
|
||||
paused bool
|
||||
name string
|
||||
)
|
||||
_, ok := e.applyState(id, func(s *models.Site) {
|
||||
s.Paused = !s.Paused
|
||||
paused = s.Paused
|
||||
name = s.Name
|
||||
})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if paused {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' paused", name))
|
||||
} else {
|
||||
e.AddLog(fmt.Sprintf("Monitor '%s' resumed", name))
|
||||
}
|
||||
return paused
|
||||
}
|
||||
|
||||
// applyState atomically reads, mutates, and writes back the live entry for id.
|
||||
// The mutator runs under the engine write lock and receives a pointer to the
|
||||
// CURRENT live state, so concurrent config edits, pauses, and heartbeats are
|
||||
// never clobbered by a stale snapshot. The mutator must only touch runtime /
|
||||
// check-result fields — config fields (Name/URL/Type/Token/Interval/AlertID/…)
|
||||
// are owned by UpdateSiteConfig and must not be written here. Returns the
|
||||
// post-mutation copy and whether the site still exists.
|
||||
func (e *Engine) applyState(id int, mutate func(s *models.Site)) (models.Site, bool) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
cur, ok := e.liveState[id]
|
||||
if !ok {
|
||||
return models.Site{}, false
|
||||
}
|
||||
mutate(&cur)
|
||||
e.liveState[id] = cur
|
||||
return cur, true
|
||||
}
|
||||
|
||||
func (e *Engine) GetDisplayStatus(site models.Site) string {
|
||||
if site.Paused {
|
||||
return "PAUSED"
|
||||
}
|
||||
if e.isInMaintenance(site.ID) {
|
||||
return "MAINT"
|
||||
}
|
||||
return string(site.Status)
|
||||
}
|
||||
|
||||
func (e *Engine) GetStateChanges(ctx context.Context, siteID int, limit int) []models.StateChange {
|
||||
changes, err := e.db.GetStateChanges(ctx, siteID, limit)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func (e *Engine) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) []models.StateChange {
|
||||
changes, err := e.db.GetStateChangesSince(ctx, siteID, since)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return changes
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func TestUpdateSiteConfig_PreservesRuntime(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", URL: "http://old.com"},
|
||||
SiteState: models.SiteState{Status: "DOWN", FailureCount: 3, Latency: 100 * time.Millisecond},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
updated := models.SiteConfig{ID: 1, Name: "test", URL: "http://new.com", Interval: 60}
|
||||
e.UpdateSiteConfig(updated)
|
||||
|
||||
s, _ := getSite(e, 1)
|
||||
if s.URL != "http://new.com" {
|
||||
t.Errorf("expected URL updated, got %s", s.URL)
|
||||
}
|
||||
if s.Status != "DOWN" {
|
||||
t.Errorf("expected Status preserved, got %s", s.Status)
|
||||
}
|
||||
if s.FailureCount != 3 {
|
||||
t.Errorf("expected FailureCount preserved, got %d", s.FailureCount)
|
||||
}
|
||||
if s.Latency != 100*time.Millisecond {
|
||||
t.Errorf("expected Latency preserved, got %v", s.Latency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveSite_CleansUp(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test", Type: "push", Token: "tok1"},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
e.recordCheck(1, 5*time.Millisecond, true)
|
||||
|
||||
e.RemoveSite(1)
|
||||
|
||||
if _, ok := getSite(e, 1); ok {
|
||||
t.Error("expected site removed from liveState")
|
||||
}
|
||||
if e.RecordHeartbeat("tok1") {
|
||||
t.Error("expected token removed from index")
|
||||
}
|
||||
if _, ok := e.GetHistory(1); ok {
|
||||
t.Error("expected history removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleSitePause(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
site := models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "test"},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
}
|
||||
injectSite(e, site)
|
||||
|
||||
paused := e.ToggleSitePause(1)
|
||||
if !paused {
|
||||
t.Error("expected paused=true after first toggle")
|
||||
}
|
||||
s, _ := getSite(e, 1)
|
||||
if !s.Paused {
|
||||
t.Error("expected Paused=true in state")
|
||||
}
|
||||
|
||||
paused = e.ToggleSitePause(1)
|
||||
if paused {
|
||||
t.Error("expected paused=false after second toggle")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleSitePause_NonexistentSite(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
if e.ToggleSitePause(999) {
|
||||
t.Error("expected false for nonexistent site")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllSites_ReturnsCopy(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
injectSite(e, models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "s1"},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
})
|
||||
injectSite(e, models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 2, Name: "s2"},
|
||||
SiteState: models.SiteState{Status: "DOWN"},
|
||||
})
|
||||
|
||||
sites := e.GetAllSites()
|
||||
if len(sites) != 2 {
|
||||
t.Fatalf("expected 2 sites, got %d", len(sites))
|
||||
}
|
||||
sites[0].Name = "mutated"
|
||||
|
||||
fresh := e.GetAllSites()
|
||||
for _, s := range fresh {
|
||||
if s.Name == "mutated" {
|
||||
t.Error("GetAllSites returned reference, not copy")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLiveState_ReturnsCopy(t *testing.T) {
|
||||
ms := newMockStore()
|
||||
e := newTestEngine(ms)
|
||||
injectSite(e, models.Site{
|
||||
SiteConfig: models.SiteConfig{ID: 1, Name: "s1"},
|
||||
SiteState: models.SiteState{Status: "UP"},
|
||||
})
|
||||
|
||||
state := e.GetLiveState()
|
||||
state[1] = models.Site{SiteConfig: models.SiteConfig{Name: "mutated"}}
|
||||
|
||||
fresh := e.GetLiveState()
|
||||
if fresh[1].Name == "mutated" {
|
||||
t.Error("GetLiveState returned reference, not copy")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
// guard.
|
||||
const maxVisitors = 10000
|
||||
|
||||
const (
|
||||
visitorCleanupInterval = 5 * time.Minute
|
||||
visitorIdleCutoff = 10 * time.Minute
|
||||
)
|
||||
|
||||
type visitor struct {
|
||||
tokens float64
|
||||
lastSeen time.Time
|
||||
@@ -90,13 +95,13 @@ func (rl *RateLimiter) evictOldest() {
|
||||
}
|
||||
|
||||
func (rl *RateLimiter) cleanup() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
ticker := time.NewTicker(visitorCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
rl.mu.Lock()
|
||||
cutoff := time.Now().Add(-10 * time.Minute)
|
||||
cutoff := time.Now().Add(-visitorIdleCutoff)
|
||||
for ip, v := range rl.visitors {
|
||||
if v.lastSeen.Before(cutoff) {
|
||||
delete(rl.visitors, ip)
|
||||
|
||||
@@ -45,15 +45,27 @@ type Server struct {
|
||||
statusRL *RateLimiter
|
||||
}
|
||||
|
||||
const (
|
||||
pushRateLimit = 60
|
||||
probeRateLimit = 30
|
||||
backupRateLimit = 10
|
||||
statusRateLimit = 120
|
||||
|
||||
httpReadHeaderTimeout = 10 * time.Second
|
||||
httpReadTimeout = 30 * time.Second
|
||||
httpWriteTimeout = 60 * time.Second
|
||||
httpIdleTimeout = 120 * time.Second
|
||||
)
|
||||
|
||||
func NewServer(cfg ServerConfig, s store.Store, eng *monitor.Engine) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
store: s,
|
||||
eng: eng,
|
||||
pushRL: NewRateLimiter(60, cfg.TrustedProxies),
|
||||
probeRL: NewRateLimiter(30, cfg.TrustedProxies),
|
||||
backupRL: NewRateLimiter(10, cfg.TrustedProxies),
|
||||
statusRL: NewRateLimiter(120, cfg.TrustedProxies),
|
||||
pushRL: NewRateLimiter(pushRateLimit, cfg.TrustedProxies),
|
||||
probeRL: NewRateLimiter(probeRateLimit, cfg.TrustedProxies),
|
||||
backupRL: NewRateLimiter(backupRateLimit, cfg.TrustedProxies),
|
||||
statusRL: NewRateLimiter(statusRateLimit, cfg.TrustedProxies),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +89,10 @@ func (s *Server) Start() *http.Server {
|
||||
httpSrv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
ReadHeaderTimeout: httpReadHeaderTimeout,
|
||||
ReadTimeout: httpReadTimeout,
|
||||
WriteTimeout: httpWriteTimeout,
|
||||
IdleTimeout: httpIdleTimeout,
|
||||
}
|
||||
go func() {
|
||||
if s.cfg.TLSCert != "" && s.cfg.TLSKey != "" {
|
||||
@@ -159,10 +171,6 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if s.cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Uptop-Secret"), s.cfg.ClusterKey) {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("OK"))
|
||||
}
|
||||
|
||||
@@ -77,8 +77,12 @@ func (m *mockStore) GetActiveMaintenanceWindows(_ context.Context) ([]models.Mai
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func freePort() int {
|
||||
ln, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("freePort: %v", err)
|
||||
}
|
||||
port := ln.Addr().(*net.TCPAddr).Port
|
||||
ln.Close()
|
||||
return port
|
||||
@@ -95,7 +99,7 @@ func newTestServer(t *testing.T, clusterKey string, enableStatus bool) *testServ
|
||||
t.Helper()
|
||||
ms := newMockStore()
|
||||
eng := monitor.NewEngine(ms)
|
||||
port := freePort()
|
||||
port := freePort(t)
|
||||
|
||||
srv := Start(ServerConfig{
|
||||
Port: port,
|
||||
@@ -219,8 +223,8 @@ func TestHealth_WrongSecret(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != 401 {
|
||||
t.Errorf("expected 401, got %d", resp.StatusCode)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("health is unauthenticated, expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +289,10 @@ func TestImport_MethodNotAllowed(t *testing.T) {
|
||||
|
||||
func TestImport_Unauthorized(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
body, _ := json.Marshal(models.Backup{})
|
||||
body, err := json.Marshal(models.Backup{})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "wrong", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -301,7 +308,10 @@ func TestImport_Success(t *testing.T) {
|
||||
backup := models.Backup{
|
||||
Sites: []models.SiteConfig{{Name: "imported", URL: "http://example.com"}},
|
||||
}
|
||||
body, _ := json.Marshal(backup)
|
||||
body, err := json.Marshal(backup)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
resp, err := authReq("POST", ts.baseURL+"/api/backup/import", "secret", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -333,9 +343,12 @@ func TestImport_InvalidJSON(t *testing.T) {
|
||||
|
||||
func TestProbeRegister_Success(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
body, err := json.Marshal(map[string]string{
|
||||
"id": "node-1", "name": "US East", "region": "us-east",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -356,7 +369,10 @@ func TestProbeRegister_Success(t *testing.T) {
|
||||
|
||||
func TestProbeRegister_MissingID(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
body, _ := json.Marshal(map[string]string{"name": "test"})
|
||||
body, err := json.Marshal(map[string]string{"name": "test"})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "secret", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -369,7 +385,10 @@ func TestProbeRegister_MissingID(t *testing.T) {
|
||||
|
||||
func TestProbeRegister_Unauthorized(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
body, _ := json.Marshal(map[string]string{"id": "node-1"})
|
||||
body, err := json.Marshal(map[string]string{"id": "node-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
resp, err := authReq("POST", ts.baseURL+"/api/probe/register", "wrong", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -384,12 +403,15 @@ func TestProbeRegister_Unauthorized(t *testing.T) {
|
||||
|
||||
func TestProbeResults_Success(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"node_id": "node-1",
|
||||
"results": []map[string]any{
|
||||
{"site_id": 1, "latency_ns": 5000000, "is_up": true},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -402,9 +424,12 @@ func TestProbeResults_Success(t *testing.T) {
|
||||
|
||||
func TestProbeResults_MissingNodeID(t *testing.T) {
|
||||
ts := newTestServer(t, "secret", false)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"results": []map[string]any{},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal: %v", err)
|
||||
}
|
||||
resp, err := authReq("POST", ts.baseURL+"/api/probe/results", "secret", body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -573,7 +598,10 @@ func TestClientIP_TrustedProxyHandling(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||
r, err := http.NewRequest(http.MethodGet, "/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest: %v", err)
|
||||
}
|
||||
r.RemoteAddr = tt.remoteAddr
|
||||
if tt.xff != "" {
|
||||
r.Header.Set("X-Forwarded-For", tt.xff)
|
||||
@@ -591,7 +619,10 @@ func TestRateLimit_SpoofedXFFCannotBypass(t *testing.T) {
|
||||
rl := NewRateLimiter(60, nil) // no trusted proxies
|
||||
allowed := 0
|
||||
for i := 0; i < 200; i++ {
|
||||
r, _ := http.NewRequest(http.MethodGet, "/", nil)
|
||||
r, err := http.NewRequest(http.MethodGet, "/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("http.NewRequest: %v", err)
|
||||
}
|
||||
r.RemoteAddr = "203.0.113.9:5000"
|
||||
r.Header.Set("X-Forwarded-For", fmt.Sprintf("9.9.9.%d", i%256))
|
||||
if rl.Allow(clientIP(r, rl.trusted)) {
|
||||
|
||||
@@ -75,8 +75,14 @@ func TestEncryptorUniqueCiphertexts(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
a, _ := enc.Encrypt("same")
|
||||
b, _ := enc.Encrypt("same")
|
||||
a, err := enc.Encrypt("same")
|
||||
if err != nil {
|
||||
t.Fatalf("first encrypt: %v", err)
|
||||
}
|
||||
b, err := enc.Encrypt("same")
|
||||
if err != nil {
|
||||
t.Fatalf("second encrypt: %v", err)
|
||||
}
|
||||
if a == b {
|
||||
t.Error("two encryptions of same plaintext should produce different ciphertexts")
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
@@ -17,6 +17,10 @@ const (
|
||||
maxLogRows = 200
|
||||
maxStateChangesPerSite = 5000
|
||||
maxMaintenanceExport = 1000
|
||||
maxOpenConns = 25
|
||||
maxIdleConns = 5
|
||||
connMaxLifetime = 5 * time.Minute
|
||||
tokenByteLen = 16
|
||||
)
|
||||
|
||||
type SQLStore struct {
|
||||
@@ -31,9 +35,9 @@ func NewSQLStore(driverName, dsn string, dialect Dialect) (*SQLStore, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(25)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(5 * time.Minute)
|
||||
db.SetMaxOpenConns(maxOpenConns)
|
||||
db.SetMaxIdleConns(maxIdleConns)
|
||||
db.SetConnMaxLifetime(connMaxLifetime)
|
||||
_, isDollar := dialect.(*PostgresDialect)
|
||||
return &SQLStore{db: db, dialect: dialect, dollar: isDollar}, nil
|
||||
}
|
||||
@@ -61,7 +65,7 @@ func (s *SQLStore) q(query string) string {
|
||||
}
|
||||
|
||||
func generateToken() (string, error) {
|
||||
b := make([]byte, 16)
|
||||
b := make([]byte, tokenByteLen)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("crypto/rand failed: %w", err)
|
||||
}
|
||||
@@ -213,40 +217,6 @@ func (s *SQLStore) GetSiteByName(ctx context.Context, name string) (models.SiteC
|
||||
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(ctx context.Context, name string) (models.AlertConfig, error) {
|
||||
var a models.AlertConfig
|
||||
var settingsRaw string
|
||||
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("alert %q: %w", name, err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfig) (int, error) {
|
||||
token := ""
|
||||
if site.Type == "push" {
|
||||
@@ -273,89 +243,6 @@ func (s *SQLStore) AddSiteReturningID(ctx context.Context, site models.SiteConfi
|
||||
return int(id), err
|
||||
}
|
||||
|
||||
func (s *SQLStore) AddAlertReturningID(ctx context.Context, name, aType string, settings map[string]string) (int, error) {
|
||||
stored, err := s.marshalSettings(settings)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if s.dollar {
|
||||
var id int
|
||||
err := s.db.QueryRowContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?) RETURNING id"), name, aType, stored).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, 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(ctx context.Context) ([]models.AlertConfig, error) {
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT id, name, type, settings FROM alerts")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var alerts []models.AlertConfig
|
||||
for rows.Next() {
|
||||
var a models.AlertConfig
|
||||
var settingsRaw string
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil {
|
||||
return alerts, err
|
||||
}
|
||||
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
||||
if err != nil {
|
||||
return alerts, fmt.Errorf("alert %q: %w", a.Name, err)
|
||||
}
|
||||
alerts = append(alerts, a)
|
||||
}
|
||||
return alerts, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetAlert(ctx context.Context, id int) (models.AlertConfig, error) {
|
||||
var a models.AlertConfig
|
||||
var settingsRaw string
|
||||
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("alert %d: %w", id, err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) AddAlert(ctx context.Context, name, aType string, settings map[string]string) error {
|
||||
stored, err := s.marshalSettings(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) UpdateAlert(ctx context.Context, id int, name, aType string, settings map[string]string) error {
|
||||
stored, err := s.marshalSettings(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) DeleteAlert(ctx context.Context, id int) error {
|
||||
if _, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET alert_id = 0 WHERE alert_id = ?"), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.dialect.ResetSequenceOnEmpty(s.db, "alerts")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetAllUsers(ctx context.Context) ([]models.User, error) {
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT id, username, public_key, role FROM users")
|
||||
if err != nil {
|
||||
@@ -388,85 +275,6 @@ func (s *SQLStore) DeleteUser(ctx context.Context, id int) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) SaveStateChange(ctx context.Context, siteID int, fromStatus, toStatus, errorReason string) error {
|
||||
_, err := s.db.ExecContext(ctx, 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(ctx context.Context, siteID int, limit int) ([]models.StateChange, error) {
|
||||
rows, err := s.db.QueryContext(ctx, 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) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? AND changed_at >= ? ORDER BY changed_at DESC"), siteID, since)
|
||||
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(ctx context.Context, siteID int, latencyNs int64, isUp bool) error {
|
||||
return s.SaveCheckFromNode(ctx, siteID, "", latencyNs, isUp)
|
||||
}
|
||||
|
||||
// SaveCheckFromNode inserts a single check row. Retention is handled out of
|
||||
// band by PruneCheckHistory on a timer, not per-insert, to keep the write hot
|
||||
// path a plain INSERT.
|
||||
func (s *SQLStore) SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error {
|
||||
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO check_history (site_id, node_id, latency_ns, is_up) VALUES (?, ?, ?, ?)"), siteID, nodeID, latencyNs, isUp)
|
||||
return err
|
||||
}
|
||||
|
||||
// PruneCheckHistory trims check_history to the newest maxCheckHistory rows per
|
||||
// site, across all sites, in one pass. Intended to run periodically.
|
||||
func (s *SQLStore) PruneCheckHistory(ctx context.Context) error {
|
||||
q := fmt.Sprintf(`DELETE FROM check_history WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC, id DESC) AS rn
|
||||
FROM check_history
|
||||
) ranked WHERE rn > %d
|
||||
)`, maxCheckHistory)
|
||||
_, err := s.db.ExecContext(ctx, s.q(q))
|
||||
return err
|
||||
}
|
||||
|
||||
// PruneStateChanges trims state_changes to the newest maxStateChangesPerSite
|
||||
// rows per site. Generous so realistic SLA windows are unaffected; bounds the
|
||||
// otherwise unbounded growth of a flapping monitor's history.
|
||||
func (s *SQLStore) PruneStateChanges(ctx context.Context) error {
|
||||
q := fmt.Sprintf(`DELETE FROM state_changes WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY changed_at DESC, id DESC) AS rn
|
||||
FROM state_changes
|
||||
) ranked WHERE rn > %d
|
||||
)`, maxStateChangesPerSite)
|
||||
_, err := s.db.ExecContext(ctx, s.q(q))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) RegisterNode(ctx context.Context, node models.ProbeNode) error {
|
||||
_, err := s.db.ExecContext(ctx, s.dialect.UpsertNodeSQL(), node.ID, node.Name, node.Region, node.Version)
|
||||
return err
|
||||
@@ -554,145 +362,27 @@ func (s *SQLStore) PruneLogs(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT message FROM logs ORDER BY created_at DESC LIMIT ?"), limit)
|
||||
func (s *SQLStore) LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT message, created_at FROM logs ORDER BY created_at DESC, id DESC LIMIT ?"), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var logs []string
|
||||
var entries []models.LogEntry
|
||||
for rows.Next() {
|
||||
var msg string
|
||||
if err := rows.Scan(&msg); err != nil {
|
||||
return logs, err
|
||||
var e models.LogEntry
|
||||
if err := rows.Scan(&e.Message, &e.CreatedAt); err != nil {
|
||||
return entries, err
|
||||
}
|
||||
logs = append(logs, msg)
|
||||
}
|
||||
return logs, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
|
||||
result := make(map[int][]models.CheckRecord)
|
||||
rows, err := s.db.QueryContext(ctx, s.q(`
|
||||
SELECT site_id, latency_ns, is_up FROM (
|
||||
SELECT site_id, latency_ns, is_up,
|
||||
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
|
||||
FROM check_history
|
||||
) sub WHERE rn <= ?`), limit)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var r models.CheckRecord
|
||||
if err := rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp); err != nil {
|
||||
return result, err
|
||||
// Strip legacy [HH:MM] or [HH:MM:SS] prefix from pre-refactor entries.
|
||||
if len(e.Message) > 3 && e.Message[0] == '[' {
|
||||
if idx := strings.Index(e.Message, "]"); idx > 0 && idx < 12 {
|
||||
e.Message = strings.TrimSpace(e.Message[idx+1:])
|
||||
}
|
||||
}
|
||||
result[r.SiteID] = append(result[r.SiteID], r)
|
||||
entries = append(entries, e)
|
||||
}
|
||||
for id, records := range result {
|
||||
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
||||
records[i], records[j] = records[j], records[i]
|
||||
}
|
||||
result[id] = records
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) scanMaintenanceWindow(rows *sql.Rows) (models.MaintenanceWindow, error) {
|
||||
var mw models.MaintenanceWindow
|
||||
var endTime sql.NullTime
|
||||
if err := rows.Scan(&mw.ID, &mw.MonitorID, &mw.Title, &mw.Description, &mw.Type, &mw.StartTime, &endTime, &mw.CreatedBy, &mw.CreatedAt); err != nil {
|
||||
return mw, err
|
||||
}
|
||||
if endTime.Valid {
|
||||
mw.EndTime = endTime.Time
|
||||
}
|
||||
return mw, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) ORDER BY start_time DESC"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var windows []models.MaintenanceWindow
|
||||
for rows.Next() {
|
||||
mw, err := s.scanMaintenanceWindow(rows)
|
||||
if err != nil {
|
||||
return windows, err
|
||||
}
|
||||
windows = append(windows, mw)
|
||||
}
|
||||
return windows, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows ORDER BY created_at DESC LIMIT ?"), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var windows []models.MaintenanceWindow
|
||||
for rows.Next() {
|
||||
mw, err := s.scanMaintenanceWindow(rows)
|
||||
if err != nil {
|
||||
return windows, err
|
||||
}
|
||||
windows = append(windows, mw)
|
||||
}
|
||||
return windows, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error {
|
||||
if mw.StartTime.IsZero() {
|
||||
mw.StartTime = time.Now()
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"),
|
||||
mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) EndMaintenanceWindow(ctx context.Context, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) DeleteMaintenanceWindow(ctx context.Context, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM maintenance_windows WHERE id = ?"), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.dialect.ResetSequenceOnEmpty(s.db, "maintenance_windows")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) PruneExpiredMaintenanceWindows(ctx context.Context, retention time.Duration) (int64, error) {
|
||||
cutoff := time.Now().Add(-retention)
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
s.q("DELETE FROM maintenance_windows WHERE end_time IS NOT NULL AND end_time < ?"),
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *SQLStore) IsMonitorInMaintenance(ctx context.Context, monitorID int) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM maintenance_windows
|
||||
WHERE type = 'maintenance'
|
||||
AND start_time <= CURRENT_TIMESTAMP
|
||||
AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP)
|
||||
AND (monitor_id = 0 OR monitor_id = ?
|
||||
OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))`),
|
||||
monitorID, monitorID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetPreference(ctx context.Context, key string) (string, error) {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
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(ctx context.Context, name string) (models.AlertConfig, error) {
|
||||
var a models.AlertConfig
|
||||
var settingsRaw string
|
||||
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE name = ?"), name).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("alert %q: %w", name, err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) AddAlertReturningID(ctx context.Context, name, aType string, settings map[string]string) (int, error) {
|
||||
stored, err := s.marshalSettings(settings)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if s.dollar {
|
||||
var id int
|
||||
err := s.db.QueryRowContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?) RETURNING id"), name, aType, stored).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
result, err := s.db.ExecContext(ctx, 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(ctx context.Context) ([]models.AlertConfig, error) {
|
||||
rows, err := s.db.QueryContext(ctx, "SELECT id, name, type, settings FROM alerts")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var alerts []models.AlertConfig
|
||||
for rows.Next() {
|
||||
var a models.AlertConfig
|
||||
var settingsRaw string
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Type, &settingsRaw); err != nil {
|
||||
return alerts, err
|
||||
}
|
||||
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
||||
if err != nil {
|
||||
return alerts, fmt.Errorf("alert %q: %w", a.Name, err)
|
||||
}
|
||||
alerts = append(alerts, a)
|
||||
}
|
||||
return alerts, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetAlert(ctx context.Context, id int) (models.AlertConfig, error) {
|
||||
var a models.AlertConfig
|
||||
var settingsRaw string
|
||||
err := s.db.QueryRowContext(ctx, s.q("SELECT id, name, type, settings FROM alerts WHERE id = ?"), id).Scan(&a.ID, &a.Name, &a.Type, &settingsRaw)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
a.Settings, err = s.unmarshalSettings(settingsRaw)
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("alert %d: %w", id, err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) AddAlert(ctx context.Context, name, aType string, settings map[string]string) error {
|
||||
stored, err := s.marshalSettings(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, s.q("INSERT INTO alerts (name, type, settings) VALUES (?, ?, ?)"), name, aType, stored)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) UpdateAlert(ctx context.Context, id int, name, aType string, settings map[string]string) error {
|
||||
stored, err := s.marshalSettings(settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, s.q("UPDATE alerts SET name=?, type=?, settings=? WHERE id=?"), name, aType, stored, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) DeleteAlert(ctx context.Context, id int) error {
|
||||
if _, err := s.db.ExecContext(ctx, s.q("UPDATE sites SET alert_id = 0 WHERE alert_id = ?"), id); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, s.q("DELETE FROM alerts WHERE id=?"), id); err != nil {
|
||||
return err
|
||||
}
|
||||
s.dialect.ResetSequenceOnEmpty(s.db, "alerts")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func TestAlertCRUD(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil {
|
||||
t.Fatalf("AddAlert: %v", err)
|
||||
}
|
||||
|
||||
alerts, err := s.GetAllAlerts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllAlerts: %v", err)
|
||||
}
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("expected 1 alert, got %d", len(alerts))
|
||||
}
|
||||
if alerts[0].Type != "discord" {
|
||||
t.Errorf("expected type 'discord', got '%s'", alerts[0].Type)
|
||||
}
|
||||
if alerts[0].Settings["url"] != "https://example.com/hook" {
|
||||
t.Errorf("settings url mismatch")
|
||||
}
|
||||
|
||||
a, err := s.GetAlert(context.Background(), alerts[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlert: %v", err)
|
||||
}
|
||||
if a.Name != "Discord" {
|
||||
t.Errorf("expected name 'Discord', got '%s'", a.Name)
|
||||
}
|
||||
|
||||
if err := s.UpdateAlert(context.Background(), a.ID, "Slack", "slack", map[string]string{"url": "https://slack.com/hook"}); err != nil {
|
||||
t.Fatalf("UpdateAlert: %v", err)
|
||||
}
|
||||
|
||||
a, err = s.GetAlert(context.Background(), a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlert: %v", err)
|
||||
}
|
||||
if a.Type != "slack" {
|
||||
t.Errorf("expected type 'slack', got '%s'", a.Type)
|
||||
}
|
||||
|
||||
if err := s.DeleteAlert(context.Background(), a.ID); err != nil {
|
||||
t.Fatalf("DeleteAlert: %v", err)
|
||||
}
|
||||
|
||||
alerts, err = s.GetAllAlerts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllAlerts: %v", err)
|
||||
}
|
||||
if len(alerts) != 0 {
|
||||
t.Fatalf("expected 0 alerts after delete, got %d", len(alerts))
|
||||
}
|
||||
}
|
||||
|
||||
// ImportData must encrypt alert settings (like AddAlert/UpdateAlert) so a
|
||||
// restore with UPTOP_ENCRYPTION_KEY set never lands secrets in plaintext.
|
||||
func TestImportData_EncryptsAlertSettings(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
enc, err := NewEncryptor(strings.Repeat("ab", 32)) // 64 hex chars = 32 bytes
|
||||
if err != nil {
|
||||
t.Fatalf("NewEncryptor: %v", err)
|
||||
}
|
||||
s.SetEncryptor(enc)
|
||||
|
||||
backup := models.Backup{
|
||||
Alerts: []models.AlertConfig{
|
||||
{ID: 1, Name: "tg", Type: "telegram", Settings: map[string]string{"token": "123:SECRET", "chat_id": "42"}},
|
||||
},
|
||||
}
|
||||
if err := s.ImportData(context.Background(), backup); err != nil {
|
||||
t.Fatalf("ImportData: %v", err)
|
||||
}
|
||||
|
||||
var raw string
|
||||
if err := s.db.QueryRow("SELECT settings FROM alerts WHERE id = 1").Scan(&raw); err != nil {
|
||||
t.Fatalf("query settings: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(raw, encryptedPrefix) {
|
||||
t.Errorf("imported settings not encrypted: %q", raw)
|
||||
}
|
||||
if strings.Contains(raw, "SECRET") {
|
||||
t.Errorf("plaintext secret found in stored column: %q", raw)
|
||||
}
|
||||
|
||||
alerts, err := s.GetAllAlerts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllAlerts: %v", err)
|
||||
}
|
||||
if len(alerts) != 1 || alerts[0].Settings["token"] != "123:SECRET" {
|
||||
t.Errorf("decrypt round-trip failed: %+v", alerts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func (s *SQLStore) SaveStateChange(ctx context.Context, siteID int, fromStatus, toStatus, errorReason string) error {
|
||||
_, err := s.db.ExecContext(ctx, 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(ctx context.Context, siteID int, limit int) ([]models.StateChange, error) {
|
||||
rows, err := s.db.QueryContext(ctx, 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) GetStateChangesSince(ctx context.Context, siteID int, since time.Time) ([]models.StateChange, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, site_id, from_status, to_status, error_reason, changed_at FROM state_changes WHERE site_id = ? AND changed_at >= ? ORDER BY changed_at DESC"), siteID, since)
|
||||
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(ctx context.Context, siteID int, latencyNs int64, isUp bool) error {
|
||||
return s.SaveCheckFromNode(ctx, siteID, "", latencyNs, isUp)
|
||||
}
|
||||
|
||||
// SaveCheckFromNode inserts a single check row. Retention is handled out of
|
||||
// band by PruneCheckHistory on a timer, not per-insert, to keep the write hot
|
||||
// path a plain INSERT.
|
||||
func (s *SQLStore) SaveCheckFromNode(ctx context.Context, siteID int, nodeID string, latencyNs int64, isUp bool) error {
|
||||
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO check_history (site_id, node_id, latency_ns, is_up) VALUES (?, ?, ?, ?)"), siteID, nodeID, latencyNs, isUp)
|
||||
return err
|
||||
}
|
||||
|
||||
// PruneCheckHistory trims check_history to the newest maxCheckHistory rows per
|
||||
// site, across all sites, in one pass. Intended to run periodically.
|
||||
func (s *SQLStore) PruneCheckHistory(ctx context.Context) error {
|
||||
q := fmt.Sprintf(`DELETE FROM check_history WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC, id DESC) AS rn
|
||||
FROM check_history
|
||||
) ranked WHERE rn > %d
|
||||
)`, maxCheckHistory)
|
||||
_, err := s.db.ExecContext(ctx, s.q(q))
|
||||
return err
|
||||
}
|
||||
|
||||
// PruneStateChanges trims state_changes to the newest maxStateChangesPerSite
|
||||
// rows per site. Generous so realistic SLA windows are unaffected; bounds the
|
||||
// otherwise unbounded growth of a flapping monitor's history.
|
||||
func (s *SQLStore) PruneStateChanges(ctx context.Context) error {
|
||||
q := fmt.Sprintf(`DELETE FROM state_changes WHERE id IN (
|
||||
SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY changed_at DESC, id DESC) AS rn
|
||||
FROM state_changes
|
||||
) ranked WHERE rn > %d
|
||||
)`, maxStateChangesPerSite)
|
||||
_, err := s.db.ExecContext(ctx, s.q(q))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) LoadAllHistory(ctx context.Context, limit int) (map[int][]models.CheckRecord, error) {
|
||||
result := make(map[int][]models.CheckRecord)
|
||||
rows, err := s.db.QueryContext(ctx, s.q(`
|
||||
SELECT site_id, latency_ns, is_up FROM (
|
||||
SELECT site_id, latency_ns, is_up,
|
||||
ROW_NUMBER() OVER (PARTITION BY site_id ORDER BY checked_at DESC) AS rn
|
||||
FROM check_history
|
||||
) sub WHERE rn <= ?`), limit)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var r models.CheckRecord
|
||||
if err := rows.Scan(&r.SiteID, &r.LatencyNs, &r.IsUp); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result[r.SiteID] = append(result[r.SiteID], r)
|
||||
}
|
||||
for id, records := range result {
|
||||
for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
|
||||
records[i], records[j] = records[j], records[i]
|
||||
}
|
||||
result[id] = records
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func TestCheckHistory(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.SaveCheck(context.Background(), 1, 5000000, true); err != nil {
|
||||
t.Fatalf("SaveCheck: %v", err)
|
||||
}
|
||||
if err := s.SaveCheck(context.Background(), 1, 10000000, false); err != nil {
|
||||
t.Fatalf("SaveCheck: %v", err)
|
||||
}
|
||||
if err := s.SaveCheck(context.Background(), 2, 3000000, true); err != nil {
|
||||
t.Fatalf("SaveCheck site 2: %v", err)
|
||||
}
|
||||
|
||||
history, err := s.LoadAllHistory(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAllHistory: %v", err)
|
||||
}
|
||||
if len(history[1]) != 2 {
|
||||
t.Fatalf("expected 2 records for site 1, got %d", len(history[1]))
|
||||
}
|
||||
if len(history[2]) != 1 {
|
||||
t.Fatalf("expected 1 record for site 2, got %d", len(history[2]))
|
||||
}
|
||||
|
||||
upCount := 0
|
||||
for _, r := range history[1] {
|
||||
if r.IsUp {
|
||||
upCount++
|
||||
}
|
||||
}
|
||||
if upCount != 1 {
|
||||
t.Errorf("expected 1 up record for site 1, got %d", upCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneCheckHistory(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
for i := 0; i < maxCheckHistory+5; i++ {
|
||||
if err := s.SaveCheck(context.Background(), 1, int64(i), true); err != nil {
|
||||
t.Fatalf("SaveCheck site 1: %v", err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := s.SaveCheck(context.Background(), 2, int64(i), true); err != nil {
|
||||
t.Fatalf("SaveCheck site 2: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.PruneCheckHistory(context.Background()); err != nil {
|
||||
t.Fatalf("PruneCheckHistory: %v", err)
|
||||
}
|
||||
|
||||
history, err := s.LoadAllHistory(context.Background(), maxCheckHistory*2)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAllHistory: %v", err)
|
||||
}
|
||||
if len(history[1]) != maxCheckHistory {
|
||||
t.Errorf("site 1: expected %d rows after prune, got %d", maxCheckHistory, len(history[1]))
|
||||
}
|
||||
if len(history[2]) != 3 {
|
||||
t.Errorf("site 2: expected 3 rows untouched, got %d", len(history[2]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSiteCascade(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
site := models.SiteConfig{Name: "Cascade Test", URL: "https://example.com", Interval: 30}
|
||||
if err := s.AddSite(context.Background(), site); err != nil {
|
||||
t.Fatalf("AddSite: %v", err)
|
||||
}
|
||||
sites, err := s.GetSites(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetSites: %v", err)
|
||||
}
|
||||
siteID := sites[0].ID
|
||||
|
||||
if err := s.SaveCheck(context.Background(), siteID, 1000, true); err != nil {
|
||||
t.Fatalf("SaveCheck: %v", err)
|
||||
}
|
||||
if err := s.SaveStateChange(context.Background(), siteID, "UP", "DOWN", "timeout"); err != nil {
|
||||
t.Fatalf("SaveStateChange: %v", err)
|
||||
}
|
||||
mw := models.MaintenanceWindow{
|
||||
MonitorID: siteID,
|
||||
Title: "Test MW",
|
||||
Type: "maintenance",
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
if err := s.AddMaintenanceWindow(context.Background(), mw); err != nil {
|
||||
t.Fatalf("AddMaintenanceWindow: %v", err)
|
||||
}
|
||||
|
||||
if err := s.DeleteSite(context.Background(), siteID); err != nil {
|
||||
t.Fatalf("DeleteSite: %v", err)
|
||||
}
|
||||
|
||||
history, err := s.LoadAllHistory(context.Background(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAllHistory: %v", err)
|
||||
}
|
||||
if len(history[siteID]) != 0 {
|
||||
t.Errorf("expected 0 check_history rows, got %d", len(history[siteID]))
|
||||
}
|
||||
|
||||
changes, err := s.GetStateChanges(context.Background(), siteID, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetStateChanges: %v", err)
|
||||
}
|
||||
if len(changes) != 0 {
|
||||
t.Errorf("expected 0 state_changes rows, got %d", len(changes))
|
||||
}
|
||||
|
||||
windows, err := s.GetActiveMaintenanceWindows(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetActiveMaintenanceWindows: %v", err)
|
||||
}
|
||||
for _, w := range windows {
|
||||
if w.MonitorID == siteID {
|
||||
t.Errorf("orphaned maintenance window found: id=%d", w.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func (s *SQLStore) scanMaintenanceWindow(rows *sql.Rows) (models.MaintenanceWindow, error) {
|
||||
var mw models.MaintenanceWindow
|
||||
var endTime sql.NullTime
|
||||
if err := rows.Scan(&mw.ID, &mw.MonitorID, &mw.Title, &mw.Description, &mw.Type, &mw.StartTime, &endTime, &mw.CreatedBy, &mw.CreatedAt); err != nil {
|
||||
return mw, err
|
||||
}
|
||||
if endTime.Valid {
|
||||
mw.EndTime = endTime.Time
|
||||
}
|
||||
return mw, nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE start_time <= CURRENT_TIMESTAMP AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP) ORDER BY start_time DESC"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var windows []models.MaintenanceWindow
|
||||
for rows.Next() {
|
||||
mw, err := s.scanMaintenanceWindow(rows)
|
||||
if err != nil {
|
||||
return windows, err
|
||||
}
|
||||
windows = append(windows, mw)
|
||||
}
|
||||
return windows, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, s.q("SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows ORDER BY created_at DESC LIMIT ?"), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var windows []models.MaintenanceWindow
|
||||
for rows.Next() {
|
||||
mw, err := s.scanMaintenanceWindow(rows)
|
||||
if err != nil {
|
||||
return windows, err
|
||||
}
|
||||
windows = append(windows, mw)
|
||||
}
|
||||
return windows, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error) {
|
||||
var timeClause string
|
||||
var args []interface{}
|
||||
|
||||
if endTime.IsZero() {
|
||||
timeClause = "(end_time IS NULL OR end_time > ?)"
|
||||
args = append(args, startTime)
|
||||
} else {
|
||||
timeClause = "(end_time IS NULL OR end_time > ?) AND start_time < ?"
|
||||
args = append(args, startTime, endTime)
|
||||
}
|
||||
|
||||
var scopeClause string
|
||||
if monitorID == 0 {
|
||||
scopeClause = "1=1"
|
||||
} else {
|
||||
scopeClause = "(monitor_id = ? OR monitor_id = 0 OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))"
|
||||
args = append(args, monitorID, monitorID)
|
||||
}
|
||||
|
||||
query := fmt.Sprintf(
|
||||
"SELECT id, monitor_id, title, description, type, start_time, end_time, created_by, created_at FROM maintenance_windows WHERE %s AND %s ORDER BY start_time",
|
||||
timeClause, scopeClause,
|
||||
)
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, s.q(query), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var windows []models.MaintenanceWindow
|
||||
for rows.Next() {
|
||||
mw, err := s.scanMaintenanceWindow(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
windows = append(windows, mw)
|
||||
}
|
||||
return windows, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLStore) AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error {
|
||||
if mw.StartTime.IsZero() {
|
||||
mw.StartTime = time.Now()
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, s.q("INSERT INTO maintenance_windows (monitor_id, title, description, type, start_time, end_time, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)"),
|
||||
mw.MonitorID, mw.Title, mw.Description, mw.Type, mw.StartTime, sql.NullTime{Time: mw.EndTime, Valid: !mw.EndTime.IsZero()}, mw.CreatedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) EndMaintenanceWindow(ctx context.Context, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, s.q("UPDATE maintenance_windows SET end_time = CURRENT_TIMESTAMP WHERE id = ?"), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SQLStore) DeleteMaintenanceWindow(ctx context.Context, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, s.q("DELETE FROM maintenance_windows WHERE id = ?"), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.dialect.ResetSequenceOnEmpty(s.db, "maintenance_windows")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQLStore) PruneExpiredMaintenanceWindows(ctx context.Context, retention time.Duration) (int64, error) {
|
||||
cutoff := time.Now().Add(-retention)
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
s.q("DELETE FROM maintenance_windows WHERE end_time IS NOT NULL AND end_time < ?"),
|
||||
cutoff,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
func (s *SQLStore) IsMonitorInMaintenance(ctx context.Context, monitorID int) (bool, error) {
|
||||
var count int
|
||||
err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM maintenance_windows
|
||||
WHERE type = 'maintenance'
|
||||
AND start_time <= CURRENT_TIMESTAMP
|
||||
AND (end_time IS NULL OR end_time > CURRENT_TIMESTAMP)
|
||||
AND (monitor_id = 0 OR monitor_id = ?
|
||||
OR monitor_id IN (SELECT parent_id FROM sites WHERE id = ? AND parent_id > 0))`),
|
||||
monitorID, monitorID).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
|
||||
func TestPruneExpiredMaintenanceWindows(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Expired 10 days ago — should be pruned with 7d retention.
|
||||
old := models.MaintenanceWindow{
|
||||
MonitorID: 0,
|
||||
Title: "Old Window",
|
||||
Type: "maintenance",
|
||||
StartTime: now.Add(-11 * 24 * time.Hour),
|
||||
EndTime: now.Add(-10 * 24 * time.Hour),
|
||||
}
|
||||
if err := s.AddMaintenanceWindow(context.Background(), old); err != nil {
|
||||
t.Fatalf("AddMaintenanceWindow (old): %v", err)
|
||||
}
|
||||
|
||||
// Expired 1 day ago — within 7d retention, should survive.
|
||||
recent := models.MaintenanceWindow{
|
||||
MonitorID: 0,
|
||||
Title: "Recent Window",
|
||||
Type: "maintenance",
|
||||
StartTime: now.Add(-2 * 24 * time.Hour),
|
||||
EndTime: now.Add(-1 * 24 * time.Hour),
|
||||
}
|
||||
if err := s.AddMaintenanceWindow(context.Background(), recent); err != nil {
|
||||
t.Fatalf("AddMaintenanceWindow (recent): %v", err)
|
||||
}
|
||||
|
||||
// Ongoing — no end time, should survive.
|
||||
ongoing := models.MaintenanceWindow{
|
||||
MonitorID: 0,
|
||||
Title: "Ongoing Window",
|
||||
Type: "maintenance",
|
||||
StartTime: now.Add(-1 * time.Hour),
|
||||
}
|
||||
if err := s.AddMaintenanceWindow(context.Background(), ongoing); err != nil {
|
||||
t.Fatalf("AddMaintenanceWindow (ongoing): %v", err)
|
||||
}
|
||||
|
||||
pruned, err := s.PruneExpiredMaintenanceWindows(context.Background(), 7*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("PruneExpiredMaintenanceWindows: %v", err)
|
||||
}
|
||||
if pruned != 1 {
|
||||
t.Errorf("expected 1 pruned, got %d", pruned)
|
||||
}
|
||||
|
||||
all, err := s.GetAllMaintenanceWindows(context.Background(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllMaintenanceWindows: %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("expected 2 remaining windows, got %d", len(all))
|
||||
}
|
||||
for _, w := range all {
|
||||
if w.Title == "Old Window" {
|
||||
t.Error("old window should have been pruned")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOverlappingMaintenanceWindows(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
site := models.SiteConfig{Name: "web", URL: "https://example.com", Interval: 30}
|
||||
if err := s.AddSite(ctx, site); err != nil {
|
||||
t.Fatalf("AddSite: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
active := models.MaintenanceWindow{
|
||||
MonitorID: 1,
|
||||
Title: "Deploy v2",
|
||||
Type: "maintenance",
|
||||
StartTime: now.Add(-30 * time.Minute),
|
||||
EndTime: now.Add(30 * time.Minute),
|
||||
}
|
||||
if err := s.AddMaintenanceWindow(ctx, active); err != nil {
|
||||
t.Fatalf("AddMaintenanceWindow: %v", err)
|
||||
}
|
||||
|
||||
ended := models.MaintenanceWindow{
|
||||
MonitorID: 1,
|
||||
Title: "Old deploy",
|
||||
Type: "maintenance",
|
||||
StartTime: now.Add(-3 * time.Hour),
|
||||
EndTime: now.Add(-2 * time.Hour),
|
||||
}
|
||||
if err := s.AddMaintenanceWindow(ctx, ended); err != nil {
|
||||
t.Fatalf("AddMaintenanceWindow: %v", err)
|
||||
}
|
||||
|
||||
t.Run("same monitor overlaps", func(t *testing.T) {
|
||||
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(overlaps) != 1 {
|
||||
t.Fatalf("expected 1 overlap, got %d", len(overlaps))
|
||||
}
|
||||
if overlaps[0].Title != "Deploy v2" {
|
||||
t.Errorf("expected 'Deploy v2', got %q", overlaps[0].Title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different monitor no overlap", func(t *testing.T) {
|
||||
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 99, now, now.Add(1*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(overlaps) != 0 {
|
||||
t.Errorf("expected 0 overlaps, got %d", len(overlaps))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("global window overlaps all", func(t *testing.T) {
|
||||
global := models.MaintenanceWindow{
|
||||
MonitorID: 0,
|
||||
Title: "Global freeze",
|
||||
Type: "maintenance",
|
||||
StartTime: now.Add(-10 * time.Minute),
|
||||
EndTime: now.Add(2 * time.Hour),
|
||||
}
|
||||
if err := s.AddMaintenanceWindow(ctx, global); err != nil {
|
||||
t.Fatalf("AddMaintenanceWindow: %v", err)
|
||||
}
|
||||
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, now.Add(1*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(overlaps) != 2 {
|
||||
t.Errorf("expected 2 overlaps (specific + global), got %d", len(overlaps))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("indefinite window overlaps", func(t *testing.T) {
|
||||
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(overlaps) < 1 {
|
||||
t.Error("expected at least 1 overlap for indefinite window")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ended window excluded", func(t *testing.T) {
|
||||
overlaps, err := s.GetOverlappingMaintenanceWindows(ctx, 1, now.Add(-4*time.Hour), now.Add(-3*time.Hour))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(overlaps) != 0 {
|
||||
t.Errorf("expected 0 overlaps for past range, got %d", len(overlaps))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,9 +3,7 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
)
|
||||
@@ -74,60 +72,6 @@ func TestSiteCRUD(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertCRUD(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.AddAlert(context.Background(), "Discord", "discord", map[string]string{"url": "https://example.com/hook"}); err != nil {
|
||||
t.Fatalf("AddAlert: %v", err)
|
||||
}
|
||||
|
||||
alerts, err := s.GetAllAlerts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllAlerts: %v", err)
|
||||
}
|
||||
if len(alerts) != 1 {
|
||||
t.Fatalf("expected 1 alert, got %d", len(alerts))
|
||||
}
|
||||
if alerts[0].Type != "discord" {
|
||||
t.Errorf("expected type 'discord', got '%s'", alerts[0].Type)
|
||||
}
|
||||
if alerts[0].Settings["url"] != "https://example.com/hook" {
|
||||
t.Errorf("settings url mismatch")
|
||||
}
|
||||
|
||||
a, err := s.GetAlert(context.Background(), alerts[0].ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlert: %v", err)
|
||||
}
|
||||
if a.Name != "Discord" {
|
||||
t.Errorf("expected name 'Discord', got '%s'", a.Name)
|
||||
}
|
||||
|
||||
if err := s.UpdateAlert(context.Background(), a.ID, "Slack", "slack", map[string]string{"url": "https://slack.com/hook"}); err != nil {
|
||||
t.Fatalf("UpdateAlert: %v", err)
|
||||
}
|
||||
|
||||
a, err = s.GetAlert(context.Background(), a.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAlert: %v", err)
|
||||
}
|
||||
if a.Type != "slack" {
|
||||
t.Errorf("expected type 'slack', got '%s'", a.Type)
|
||||
}
|
||||
|
||||
if err := s.DeleteAlert(context.Background(), a.ID); err != nil {
|
||||
t.Fatalf("DeleteAlert: %v", err)
|
||||
}
|
||||
|
||||
alerts, err = s.GetAllAlerts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllAlerts: %v", err)
|
||||
}
|
||||
if len(alerts) != 0 {
|
||||
t.Fatalf("expected 0 alerts after delete, got %d", len(alerts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserCRUD(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
@@ -301,89 +245,6 @@ func TestImportData_NilUsersPreservesExisting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckHistory(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
if err := s.SaveCheck(context.Background(), 1, 5000000, true); err != nil {
|
||||
t.Fatalf("SaveCheck: %v", err)
|
||||
}
|
||||
if err := s.SaveCheck(context.Background(), 1, 10000000, false); err != nil {
|
||||
t.Fatalf("SaveCheck: %v", err)
|
||||
}
|
||||
if err := s.SaveCheck(context.Background(), 2, 3000000, true); err != nil {
|
||||
t.Fatalf("SaveCheck site 2: %v", err)
|
||||
}
|
||||
|
||||
history, err := s.LoadAllHistory(context.Background(), 10)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAllHistory: %v", err)
|
||||
}
|
||||
if len(history[1]) != 2 {
|
||||
t.Fatalf("expected 2 records for site 1, got %d", len(history[1]))
|
||||
}
|
||||
if len(history[2]) != 1 {
|
||||
t.Fatalf("expected 1 record for site 2, got %d", len(history[2]))
|
||||
}
|
||||
|
||||
upCount := 0
|
||||
for _, r := range history[1] {
|
||||
if r.IsUp {
|
||||
upCount++
|
||||
}
|
||||
}
|
||||
if upCount != 1 {
|
||||
t.Errorf("expected 1 up record for site 1, got %d", upCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSiteCascade(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
site := models.SiteConfig{Name: "Cascade Test", URL: "https://example.com", Interval: 30}
|
||||
if err := s.AddSite(context.Background(), site); err != nil {
|
||||
t.Fatalf("AddSite: %v", err)
|
||||
}
|
||||
sites, _ := s.GetSites(context.Background())
|
||||
siteID := sites[0].ID
|
||||
|
||||
if err := s.SaveCheck(context.Background(), siteID, 1000, true); err != nil {
|
||||
t.Fatalf("SaveCheck: %v", err)
|
||||
}
|
||||
if err := s.SaveStateChange(context.Background(), siteID, "UP", "DOWN", "timeout"); err != nil {
|
||||
t.Fatalf("SaveStateChange: %v", err)
|
||||
}
|
||||
mw := models.MaintenanceWindow{
|
||||
MonitorID: siteID,
|
||||
Title: "Test MW",
|
||||
Type: "maintenance",
|
||||
StartTime: time.Now(),
|
||||
}
|
||||
if err := s.AddMaintenanceWindow(context.Background(), mw); err != nil {
|
||||
t.Fatalf("AddMaintenanceWindow: %v", err)
|
||||
}
|
||||
|
||||
if err := s.DeleteSite(context.Background(), siteID); err != nil {
|
||||
t.Fatalf("DeleteSite: %v", err)
|
||||
}
|
||||
|
||||
history, _ := s.LoadAllHistory(context.Background(), 100)
|
||||
if len(history[siteID]) != 0 {
|
||||
t.Errorf("expected 0 check_history rows, got %d", len(history[siteID]))
|
||||
}
|
||||
|
||||
changes, _ := s.GetStateChanges(context.Background(), siteID, 100)
|
||||
if len(changes) != 0 {
|
||||
t.Errorf("expected 0 state_changes rows, got %d", len(changes))
|
||||
}
|
||||
|
||||
windows, _ := s.GetActiveMaintenanceWindows(context.Background())
|
||||
for _, w := range windows {
|
||||
if w.MonitorID == siteID {
|
||||
t.Errorf("orphaned maintenance window found: id=%d", w.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneLogs(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
@@ -407,7 +268,7 @@ func TestPruneLogs(t *testing.T) {
|
||||
// LoadLogs ordering ties when rows share a created_at second).
|
||||
present := make(map[string]bool, len(logs))
|
||||
for _, l := range logs {
|
||||
present[l] = true
|
||||
present[l.Message] = true
|
||||
}
|
||||
if !present[fmt.Sprintf("log %d", maxLogRows+50-1)] {
|
||||
t.Error("newest log was pruned")
|
||||
@@ -416,134 +277,3 @@ func TestPruneLogs(t *testing.T) {
|
||||
t.Error("oldest log survived prune")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneCheckHistory(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
for i := 0; i < maxCheckHistory+5; i++ {
|
||||
if err := s.SaveCheck(context.Background(), 1, int64(i), true); err != nil {
|
||||
t.Fatalf("SaveCheck site 1: %v", err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := s.SaveCheck(context.Background(), 2, int64(i), true); err != nil {
|
||||
t.Fatalf("SaveCheck site 2: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.PruneCheckHistory(context.Background()); err != nil {
|
||||
t.Fatalf("PruneCheckHistory: %v", err)
|
||||
}
|
||||
|
||||
history, err := s.LoadAllHistory(context.Background(), maxCheckHistory*2)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAllHistory: %v", err)
|
||||
}
|
||||
if len(history[1]) != maxCheckHistory {
|
||||
t.Errorf("site 1: expected %d rows after prune, got %d", maxCheckHistory, len(history[1]))
|
||||
}
|
||||
if len(history[2]) != 3 {
|
||||
t.Errorf("site 2: expected 3 rows untouched, got %d", len(history[2]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneExpiredMaintenanceWindows(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Expired 10 days ago — should be pruned with 7d retention.
|
||||
old := models.MaintenanceWindow{
|
||||
MonitorID: 0,
|
||||
Title: "Old Window",
|
||||
Type: "maintenance",
|
||||
StartTime: now.Add(-11 * 24 * time.Hour),
|
||||
EndTime: now.Add(-10 * 24 * time.Hour),
|
||||
}
|
||||
if err := s.AddMaintenanceWindow(context.Background(), old); err != nil {
|
||||
t.Fatalf("AddMaintenanceWindow (old): %v", err)
|
||||
}
|
||||
|
||||
// Expired 1 day ago — within 7d retention, should survive.
|
||||
recent := models.MaintenanceWindow{
|
||||
MonitorID: 0,
|
||||
Title: "Recent Window",
|
||||
Type: "maintenance",
|
||||
StartTime: now.Add(-2 * 24 * time.Hour),
|
||||
EndTime: now.Add(-1 * 24 * time.Hour),
|
||||
}
|
||||
if err := s.AddMaintenanceWindow(context.Background(), recent); err != nil {
|
||||
t.Fatalf("AddMaintenanceWindow (recent): %v", err)
|
||||
}
|
||||
|
||||
// Ongoing — no end time, should survive.
|
||||
ongoing := models.MaintenanceWindow{
|
||||
MonitorID: 0,
|
||||
Title: "Ongoing Window",
|
||||
Type: "maintenance",
|
||||
StartTime: now.Add(-1 * time.Hour),
|
||||
}
|
||||
if err := s.AddMaintenanceWindow(context.Background(), ongoing); err != nil {
|
||||
t.Fatalf("AddMaintenanceWindow (ongoing): %v", err)
|
||||
}
|
||||
|
||||
pruned, err := s.PruneExpiredMaintenanceWindows(context.Background(), 7*24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("PruneExpiredMaintenanceWindows: %v", err)
|
||||
}
|
||||
if pruned != 1 {
|
||||
t.Errorf("expected 1 pruned, got %d", pruned)
|
||||
}
|
||||
|
||||
all, err := s.GetAllMaintenanceWindows(context.Background(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllMaintenanceWindows: %v", err)
|
||||
}
|
||||
if len(all) != 2 {
|
||||
t.Fatalf("expected 2 remaining windows, got %d", len(all))
|
||||
}
|
||||
for _, w := range all {
|
||||
if w.Title == "Old Window" {
|
||||
t.Error("old window should have been pruned")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ImportData must encrypt alert settings (like AddAlert/UpdateAlert) so a
|
||||
// restore with UPTOP_ENCRYPTION_KEY set never lands secrets in plaintext.
|
||||
func TestImportData_EncryptsAlertSettings(t *testing.T) {
|
||||
s := newTestStore(t)
|
||||
enc, err := NewEncryptor(strings.Repeat("ab", 32)) // 64 hex chars = 32 bytes
|
||||
if err != nil {
|
||||
t.Fatalf("NewEncryptor: %v", err)
|
||||
}
|
||||
s.SetEncryptor(enc)
|
||||
|
||||
backup := models.Backup{
|
||||
Alerts: []models.AlertConfig{
|
||||
{ID: 1, Name: "tg", Type: "telegram", Settings: map[string]string{"token": "123:SECRET", "chat_id": "42"}},
|
||||
},
|
||||
}
|
||||
if err := s.ImportData(context.Background(), backup); err != nil {
|
||||
t.Fatalf("ImportData: %v", err)
|
||||
}
|
||||
|
||||
var raw string
|
||||
if err := s.db.QueryRow("SELECT settings FROM alerts WHERE id = 1").Scan(&raw); err != nil {
|
||||
t.Fatalf("query settings: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(raw, encryptedPrefix) {
|
||||
t.Errorf("imported settings not encrypted: %q", raw)
|
||||
}
|
||||
if strings.Contains(raw, "SECRET") {
|
||||
t.Errorf("plaintext secret found in stored column: %q", raw)
|
||||
}
|
||||
|
||||
alerts, err := s.GetAllAlerts(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetAllAlerts: %v", err)
|
||||
}
|
||||
if len(alerts) != 1 || alerts[0].Settings["token"] != "123:SECRET" {
|
||||
t.Errorf("decrypt round-trip failed: %+v", alerts)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +61,13 @@ type Store interface {
|
||||
|
||||
// Logs
|
||||
SaveLog(ctx context.Context, message string) error
|
||||
LoadLogs(ctx context.Context, limit int) ([]string, error)
|
||||
LoadLogs(ctx context.Context, limit int) ([]models.LogEntry, error)
|
||||
PruneLogs(ctx context.Context) error
|
||||
|
||||
// Maintenance Windows
|
||||
GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error)
|
||||
GetAllMaintenanceWindows(ctx context.Context, limit int) ([]models.MaintenanceWindow, error)
|
||||
GetOverlappingMaintenanceWindows(ctx context.Context, monitorID int, startTime, endTime time.Time) ([]models.MaintenanceWindow, error)
|
||||
AddMaintenanceWindow(ctx context.Context, mw models.MaintenanceWindow) error
|
||||
EndMaintenanceWindow(ctx context.Context, id int) error
|
||||
DeleteMaintenanceWindow(ctx context.Context, id int) error
|
||||
|
||||
@@ -212,8 +212,8 @@ func (m *BaseMock) SaveLog(ctx context.Context, message string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]string, error) { return nil, nil }
|
||||
func (m *BaseMock) PruneLogs(_ context.Context) error { return nil }
|
||||
func (m *BaseMock) LoadLogs(_ context.Context, _ int) ([]models.LogEntry, error) { return nil, nil }
|
||||
func (m *BaseMock) PruneLogs(_ context.Context) error { return nil }
|
||||
|
||||
func (m *BaseMock) GetActiveMaintenanceWindows(ctx context.Context) ([]models.MaintenanceWindow, error) {
|
||||
if m.GetActiveMaintenanceWindowsFunc != nil {
|
||||
@@ -229,6 +229,10 @@ func (m *BaseMock) GetAllMaintenanceWindows(ctx context.Context, limit int) ([]m
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *BaseMock) GetOverlappingMaintenanceWindows(_ context.Context, _ int, _, _ time.Time) ([]models.MaintenanceWindow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *BaseMock) AddMaintenanceWindow(_ context.Context, _ models.MaintenanceWindow) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (m Model) latencyChart(latencies []time.Duration, statuses []bool, width, height int) string {
|
||||
if len(latencies) == 0 || width < 20 || height < 1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var minMs, maxMs, sumMs int64
|
||||
minMs = latencies[0].Milliseconds()
|
||||
maxMs = minMs
|
||||
upCount := 0
|
||||
for i, l := range latencies {
|
||||
ms := l.Milliseconds()
|
||||
if i < len(statuses) && !statuses[i] {
|
||||
continue
|
||||
}
|
||||
if ms < minMs {
|
||||
minMs = ms
|
||||
}
|
||||
if ms > maxMs {
|
||||
maxMs = ms
|
||||
}
|
||||
sumMs += ms
|
||||
upCount++
|
||||
}
|
||||
var avgMs int64
|
||||
if upCount > 0 {
|
||||
avgMs = sumMs / int64(upCount)
|
||||
}
|
||||
|
||||
maxLabel := fmt.Sprintf("%dms", maxMs)
|
||||
minLabel := fmt.Sprintf("%dms", minMs)
|
||||
labelW := len(maxLabel)
|
||||
if len(minLabel) > labelW {
|
||||
labelW = len(minLabel)
|
||||
}
|
||||
labelW += 1
|
||||
|
||||
chartW := width - labelW
|
||||
if chartW > len(latencies) {
|
||||
chartW = len(latencies)
|
||||
}
|
||||
if chartW < 10 {
|
||||
chartW = 10
|
||||
}
|
||||
|
||||
samples := latencies
|
||||
sampledStatuses := statuses
|
||||
if len(samples) > chartW {
|
||||
samples = samples[len(samples)-chartW:]
|
||||
if len(sampledStatuses) > chartW {
|
||||
sampledStatuses = sampledStatuses[len(sampledStatuses)-chartW:]
|
||||
}
|
||||
}
|
||||
|
||||
spread := maxMs - minMs
|
||||
if spread == 0 {
|
||||
spread = 1
|
||||
}
|
||||
totalLevels := height * 8
|
||||
|
||||
levels := make([]int, len(samples))
|
||||
for i, l := range samples {
|
||||
ms := l.Milliseconds()
|
||||
if i < len(sampledStatuses) && !sampledStatuses[i] {
|
||||
levels[i] = 0
|
||||
continue
|
||||
}
|
||||
lvl := int(float64(ms-minMs) / float64(spread) * float64(totalLevels-1))
|
||||
if lvl < 1 && ms > 0 {
|
||||
lvl = 1
|
||||
}
|
||||
if lvl >= totalLevels {
|
||||
lvl = totalLevels - 1
|
||||
}
|
||||
levels[i] = lvl
|
||||
}
|
||||
|
||||
labelStyle := m.st.subtleStyle
|
||||
|
||||
var rows []string
|
||||
for row := height - 1; row >= 0; row-- {
|
||||
var label string
|
||||
switch row {
|
||||
case height - 1:
|
||||
label = fmt.Sprintf("%*s", labelW, maxLabel)
|
||||
case 0:
|
||||
label = fmt.Sprintf("%*s", labelW, minLabel)
|
||||
default:
|
||||
label = strings.Repeat(" ", labelW)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(labelStyle.Render(label))
|
||||
|
||||
rowBase := row * 8
|
||||
for i := range samples {
|
||||
fill := levels[i] - rowBase
|
||||
if fill <= 0 {
|
||||
sb.WriteString(" ")
|
||||
continue
|
||||
}
|
||||
if fill > 8 {
|
||||
fill = 8
|
||||
}
|
||||
ch := string(sparkChars[fill-1])
|
||||
|
||||
isDown := i < len(sampledStatuses) && !sampledStatuses[i]
|
||||
if isDown {
|
||||
sb.WriteString(m.st.dangerStyle.Render(ch))
|
||||
} else {
|
||||
sb.WriteString(m.latencyStyle(samples[i].Milliseconds(), nil).Render(ch))
|
||||
}
|
||||
}
|
||||
rows = append(rows, sb.String())
|
||||
}
|
||||
|
||||
stats := fmt.Sprintf("%*s Min %s Avg %s Max %s",
|
||||
labelW, "",
|
||||
m.fmtLatency(time.Duration(minMs)*time.Millisecond),
|
||||
m.fmtLatency(time.Duration(avgMs)*time.Millisecond),
|
||||
m.fmtLatency(time.Duration(maxMs)*time.Millisecond))
|
||||
rows = append(rows, stats)
|
||||
|
||||
return strings.Join(rows, "\n")
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package tui
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
nodeOnlineThreshold = 60 * time.Second
|
||||
nodeStaleThreshold = 5 * time.Minute
|
||||
|
||||
uptimeGoodPct = 99.0
|
||||
uptimeExcellentPct = 99.9
|
||||
uptimeWarnPct = 95.0
|
||||
uptimePrecisionPct = 99.99
|
||||
|
||||
stateHistoryLookback = 30 * 24 * time.Hour
|
||||
stateHistoryDays = 30
|
||||
stateHistoryLimit = 100
|
||||
|
||||
httpErrorThreshold = 400
|
||||
errorDetailMaxLen = 30
|
||||
)
|
||||
@@ -8,13 +8,14 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/store"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func loadCollapsed(s store.Store) map[int]bool {
|
||||
func loadCollapsed(ctx context.Context, s store.Store) map[int]bool {
|
||||
m := make(map[int]bool)
|
||||
raw, err := s.GetPreference(context.Background(), "collapsed_groups")
|
||||
raw, err := s.GetPreference(ctx, "collapsed_groups")
|
||||
if err != nil || raw == "" {
|
||||
return m
|
||||
}
|
||||
@@ -49,7 +50,7 @@ func writeCmd(op string, fn func() error) tea.Cmd {
|
||||
}
|
||||
}
|
||||
|
||||
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool) []models.Site {
|
||||
func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool, sortCol int, sortAsc bool) []models.Site {
|
||||
var groups, ungrouped []models.Site
|
||||
children := make(map[int][]models.Site)
|
||||
for _, s := range allSites {
|
||||
@@ -62,14 +63,32 @@ func sortSitesForDisplay(allSites []models.Site, collapsed map[int]bool) []model
|
||||
}
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool { return groups[i].ID < groups[j].ID })
|
||||
|
||||
sortSlice := func(s []models.Site) {
|
||||
sort.Slice(s, func(i, j int) bool { return s[i].ID < s[j].ID })
|
||||
sort.SliceStable(s, func(i, j int) bool {
|
||||
var less bool
|
||||
switch sortCol {
|
||||
case sortName:
|
||||
less = strings.ToLower(s[i].Name) < strings.ToLower(s[j].Name)
|
||||
case sortLatency:
|
||||
less = s[i].Latency < s[j].Latency
|
||||
default:
|
||||
less = siteOrder(s[i]) < siteOrder(s[j])
|
||||
}
|
||||
if !sortAsc {
|
||||
return less
|
||||
}
|
||||
return !less
|
||||
})
|
||||
}
|
||||
|
||||
for pid := range children {
|
||||
c := children[pid]
|
||||
sort.Slice(c, func(i, j int) bool { return c[i].ID < c[j].ID })
|
||||
sort.SliceStable(c, func(i, j int) bool { return siteOrder(c[i]) < siteOrder(c[j]) })
|
||||
sortSlice(c)
|
||||
children[pid] = c
|
||||
}
|
||||
sort.Slice(ungrouped, func(i, j int) bool { return ungrouped[i].ID < ungrouped[j].ID })
|
||||
sort.SliceStable(ungrouped, func(i, j int) bool { return siteOrder(ungrouped[i]) < siteOrder(ungrouped[j]) })
|
||||
sortSlice(ungrouped)
|
||||
|
||||
var ordered []models.Site
|
||||
for _, g := range groups {
|
||||
@@ -99,14 +118,14 @@ func filterSites(sites []models.Site, needle string) []models.Site {
|
||||
// separately via loadTabDataCmd.
|
||||
func (m *Model) refreshLive() {
|
||||
allSites := m.engine.GetAllSites()
|
||||
ordered := sortSitesForDisplay(allSites, m.collapsed)
|
||||
ordered := sortSitesForDisplay(allSites, m.collapsed, m.sortColumn, m.sortAsc)
|
||||
if m.filterText != "" {
|
||||
ordered = filterSites(ordered, m.filterText)
|
||||
}
|
||||
m.sites = ordered
|
||||
m.refreshLogContent()
|
||||
|
||||
if m.currentTab == 0 && m.selectedID != 0 {
|
||||
if m.currentTab == tabMonitors && m.selectedID != 0 {
|
||||
for i, s := range m.sites {
|
||||
if s.ID == m.selectedID {
|
||||
m.cursor = i
|
||||
@@ -118,7 +137,7 @@ func (m *Model) refreshLive() {
|
||||
}
|
||||
|
||||
func (m *Model) syncSelectedID() {
|
||||
if m.currentTab == 0 && m.cursor < len(m.sites) {
|
||||
if m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||
m.selectedID = m.sites[m.cursor].ID
|
||||
}
|
||||
}
|
||||
@@ -144,9 +163,9 @@ func (m *Model) loadTabDataCmd() tea.Cmd {
|
||||
m.tabSeq++
|
||||
seq := m.tabSeq
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
isAdmin := m.isAdmin
|
||||
return func() tea.Msg {
|
||||
ctx := context.Background()
|
||||
alerts, err := st.GetAllAlerts(ctx)
|
||||
if err != nil {
|
||||
return tabDataMsg{seq: seq, err: err}
|
||||
@@ -173,8 +192,20 @@ func (m *Model) loadTabDataCmd() tea.Cmd {
|
||||
// goroutine. View renders the cached result rather than querying the DB.
|
||||
func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
|
||||
eng := m.engine
|
||||
ctx := m.ctx
|
||||
var currentStatus models.Status
|
||||
for _, s := range m.sites {
|
||||
if s.ID == siteID {
|
||||
currentStatus = s.Status
|
||||
break
|
||||
}
|
||||
}
|
||||
return func() tea.Msg {
|
||||
return detailDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, 5)}
|
||||
changes := eng.GetStateChanges(ctx, siteID, 5)
|
||||
now := time.Now()
|
||||
allChanges := eng.GetStateChangesSince(ctx, siteID, now.Add(-stateHistoryLookback))
|
||||
daily := monitor.ComputeDailyBreakdown(allChanges, currentStatus, stateHistoryDays, now)
|
||||
return detailDataMsg{siteID: siteID, changes: changes, dailyDays: daily}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,8 +213,9 @@ func (m *Model) loadDetailCmd(siteID int) tea.Cmd {
|
||||
// the UI goroutine.
|
||||
func (m *Model) loadHistoryCmd(siteID int) tea.Cmd {
|
||||
eng := m.engine
|
||||
ctx := m.ctx
|
||||
return func() tea.Msg {
|
||||
return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(siteID, 100)}
|
||||
return historyDataMsg{siteID: siteID, changes: eng.GetStateChanges(ctx, siteID, stateHistoryLimit)}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,12 +224,13 @@ func (m *Model) loadHistoryCmd(siteID int) tea.Cmd {
|
||||
// can be recognized and dropped.
|
||||
func (m *Model) loadSLACmd(siteID, periodIdx int) tea.Cmd {
|
||||
eng := m.engine
|
||||
ctx := m.ctx
|
||||
since := time.Now().Add(-slaPeriods[periodIdx].duration)
|
||||
return func() tea.Msg {
|
||||
return slaDataMsg{
|
||||
siteID: siteID,
|
||||
periodIdx: periodIdx,
|
||||
changes: eng.GetStateChangesSince(siteID, since),
|
||||
changes: eng.GetStateChangesSince(ctx, siteID, since),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestSortSitesForDisplay_GroupsFirst(t *testing.T) {
|
||||
{SiteConfig: models.SiteConfig{ID: 1, Name: "group-a", Type: "group"}, SiteState: models.SiteState{Status: "UP"}},
|
||||
{SiteConfig: models.SiteConfig{ID: 2, Name: "child", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}},
|
||||
}
|
||||
result := sortSitesForDisplay(sites, nil)
|
||||
result := sortSitesForDisplay(sites, nil, sortStatus, false)
|
||||
if len(result) != 3 {
|
||||
t.Fatalf("expected 3 sites, got %d", len(result))
|
||||
}
|
||||
@@ -34,7 +34,7 @@ func TestSortSitesForDisplay_CollapsedHidesChildren(t *testing.T) {
|
||||
{SiteConfig: models.SiteConfig{ID: 3, Name: "child-2", Type: "http", ParentID: 1}, SiteState: models.SiteState{Status: "UP"}},
|
||||
}
|
||||
collapsed := map[int]bool{1: true}
|
||||
result := sortSitesForDisplay(sites, collapsed)
|
||||
result := sortSitesForDisplay(sites, collapsed, sortStatus, false)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("collapsed group should hide children, got %d items", len(result))
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func TestSortSitesForDisplay_StatusOrdering(t *testing.T) {
|
||||
{SiteConfig: models.SiteConfig{ID: 2, Name: "down-site", Type: "http"}, SiteState: models.SiteState{Status: "DOWN"}},
|
||||
{SiteConfig: models.SiteConfig{ID: 3, Name: "late-site", Type: "http"}, SiteState: models.SiteState{Status: "LATE"}},
|
||||
}
|
||||
result := sortSitesForDisplay(sites, nil)
|
||||
result := sortSitesForDisplay(sites, nil, sortStatus, false)
|
||||
if result[0].Status != "DOWN" {
|
||||
t.Errorf("DOWN should sort first, got %s", result[0].Status)
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func classifyError(errorReason string, siteType string, statusCode int) ErrorCat
|
||||
if strings.HasPrefix(lower, "http ") || strings.Contains(lower, "keyword not found") {
|
||||
return ErrCatHTTP
|
||||
}
|
||||
if statusCode >= 400 {
|
||||
if statusCode >= httpErrorThreshold {
|
||||
return ErrCatHTTP
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
|
||||
}
|
||||
}
|
||||
if detail == "" {
|
||||
detail = limitStr(errorReason, 30)
|
||||
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||
}
|
||||
case ErrCatTCP:
|
||||
for _, keyword := range []string{"connection refused", "connection reset", "no route to host", "network unreachable"} {
|
||||
@@ -155,7 +155,7 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
|
||||
}
|
||||
}
|
||||
if detail == "" {
|
||||
detail = limitStr(errorReason, 30)
|
||||
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||
}
|
||||
case ErrCatTLS:
|
||||
for _, keyword := range []string{"certificate expired", "certificate has expired", "handshake failure", "unknown authority"} {
|
||||
@@ -166,16 +166,16 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
|
||||
}
|
||||
if detail == "" && strings.Contains(lower, "x509:") {
|
||||
idx := strings.Index(lower, "x509:")
|
||||
detail = limitStr(errorReason[idx:], 30)
|
||||
detail = limitStr(errorReason[idx:], errorDetailMaxLen)
|
||||
}
|
||||
if detail == "" {
|
||||
detail = limitStr(errorReason, 30)
|
||||
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||
}
|
||||
case ErrCatHTTP:
|
||||
if statusCode > 0 {
|
||||
detail = fmt.Sprintf("HTTP %d", statusCode)
|
||||
} else {
|
||||
detail = limitStr(errorReason, 30)
|
||||
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||
}
|
||||
case ErrCatTimeout:
|
||||
if strings.Contains(lower, "i/o timeout") {
|
||||
@@ -187,12 +187,12 @@ func extractDetail(errorReason string, cat ErrorCategory, statusCode int) string
|
||||
if strings.Contains(lower, "no icmp response") {
|
||||
detail = "no response"
|
||||
} else {
|
||||
detail = limitStr(errorReason, 30)
|
||||
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||
}
|
||||
case ErrCatPrivate:
|
||||
detail = "private IP blocked"
|
||||
default:
|
||||
detail = limitStr(errorReason, 30)
|
||||
detail = limitStr(errorReason, errorDetailMaxLen)
|
||||
}
|
||||
|
||||
return detail
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type latencyBucket struct {
|
||||
label string
|
||||
min int64
|
||||
max int64
|
||||
count int
|
||||
}
|
||||
|
||||
func (m Model) latencyHistogram(latencies []time.Duration, statuses []bool, width int) string {
|
||||
if len(latencies) == 0 || width < 30 {
|
||||
return ""
|
||||
}
|
||||
|
||||
buckets := []latencyBucket{
|
||||
{"0-50ms", 0, 50, 0},
|
||||
{"50-100ms", 50, 100, 0},
|
||||
{"100-200ms", 100, 200, 0},
|
||||
{"200-500ms", 200, 500, 0},
|
||||
{"500ms+", 500, 999999, 0},
|
||||
}
|
||||
|
||||
for i, l := range latencies {
|
||||
if i < len(statuses) && !statuses[i] {
|
||||
continue
|
||||
}
|
||||
ms := l.Milliseconds()
|
||||
for j := range buckets {
|
||||
if ms >= buckets[j].min && ms < buckets[j].max {
|
||||
buckets[j].count++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
maxCount := 0
|
||||
for _, b := range buckets {
|
||||
if b.count > maxCount {
|
||||
maxCount = b.count
|
||||
}
|
||||
}
|
||||
if maxCount == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
labelW := 10
|
||||
countW := len(fmt.Sprintf("%d", maxCount))
|
||||
barW := width - labelW - countW - 4
|
||||
if barW < 5 {
|
||||
barW = 5
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, b := range buckets {
|
||||
fill := 0
|
||||
if maxCount > 0 {
|
||||
fill = b.count * barW / maxCount
|
||||
}
|
||||
|
||||
var barColor lipgloss.Style
|
||||
switch {
|
||||
case b.min < 100:
|
||||
barColor = m.st.specialStyle
|
||||
case b.min < 500:
|
||||
barColor = m.st.warnStyle
|
||||
default:
|
||||
barColor = m.st.dangerStyle
|
||||
}
|
||||
|
||||
bar := barColor.Render(strings.Repeat("█", fill))
|
||||
if fill < barW {
|
||||
bar += m.st.subtleStyle.Render(strings.Repeat("░", barW-fill))
|
||||
}
|
||||
|
||||
label := fmt.Sprintf("%*s", labelW, b.label)
|
||||
count := fmt.Sprintf("%*d", countW, b.count)
|
||||
sb.WriteString(" " + m.st.subtleStyle.Render(label) + " " + bar + " " + count + "\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
)
|
||||
|
||||
// tabRefreshTTL bounds how often the DB-backed tab data (alerts, users, nodes,
|
||||
@@ -34,8 +35,9 @@ type tabDataMsg struct {
|
||||
// on entry and refreshed on the tab-data cadence so View never touches the
|
||||
// database.
|
||||
type detailDataMsg struct {
|
||||
siteID int
|
||||
changes []models.StateChange
|
||||
siteID int
|
||||
changes []models.StateChange
|
||||
dailyDays []monitor.DayReport
|
||||
}
|
||||
|
||||
// historyDataMsg carries the full state-change history for the history view.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) titledPanel(title, content string, width int, focused bool) string {
|
||||
borderColor := m.theme.Border
|
||||
titleColor := m.theme.Muted
|
||||
if focused {
|
||||
borderColor = m.theme.Accent
|
||||
titleColor = m.theme.Accent
|
||||
}
|
||||
|
||||
bc := lipgloss.NewStyle().Foreground(borderColor)
|
||||
tc := lipgloss.NewStyle().Foreground(titleColor).Bold(true)
|
||||
|
||||
innerW := width - 2
|
||||
if innerW < 10 {
|
||||
innerW = 10
|
||||
}
|
||||
|
||||
titleRendered := tc.Render(" " + title + " ")
|
||||
titleLen := len([]rune(title)) + 2
|
||||
fillLen := innerW - titleLen - 1
|
||||
if fillLen < 0 {
|
||||
fillLen = 0
|
||||
}
|
||||
|
||||
top := bc.Render("╭─") + titleRendered + bc.Render(strings.Repeat("─", fillLen)+"╮")
|
||||
|
||||
contentStyle := lipgloss.NewStyle().Width(innerW).MaxWidth(innerW)
|
||||
inner := contentStyle.Render(content)
|
||||
|
||||
var lines []string
|
||||
lines = append(lines, top)
|
||||
for _, line := range strings.Split(inner, "\n") {
|
||||
lines = append(lines, bc.Render("│")+line+strings.Repeat(" ", max(0, innerW-lipgloss.Width(line)))+bc.Render("│"))
|
||||
}
|
||||
lines = append(lines, bc.Render("╰"+strings.Repeat("─", innerW)+"╯"))
|
||||
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -17,6 +17,17 @@ func parseHex(hex string) (r, g, b uint8) {
|
||||
return
|
||||
}
|
||||
|
||||
func trueColorHex(c lipgloss.TerminalColor) string {
|
||||
switch v := c.(type) {
|
||||
case lipgloss.CompleteColor:
|
||||
return v.TrueColor
|
||||
case lipgloss.Color:
|
||||
return string(v)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func dimColor(hex string, brightness float64) lipgloss.Color {
|
||||
r, g, b := parseHex(hex)
|
||||
f := 0.3 + brightness*0.7
|
||||
@@ -27,35 +38,36 @@ func dimColor(hex string, brightness float64) lipgloss.Color {
|
||||
))
|
||||
}
|
||||
|
||||
func withBg(s lipgloss.Style, bg lipgloss.Color) lipgloss.Style {
|
||||
if bg != "" {
|
||||
func withBg(s lipgloss.Style, bg lipgloss.TerminalColor) lipgloss.Style {
|
||||
if bg != nil {
|
||||
return s.Background(bg)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (m Model) latencyStyle(ms int64, bg lipgloss.Color) lipgloss.Style {
|
||||
var hex string
|
||||
func (m Model) latencyStyle(ms int64, bg lipgloss.TerminalColor) lipgloss.Style {
|
||||
var base lipgloss.TerminalColor
|
||||
var t float64
|
||||
switch {
|
||||
case ms < 200:
|
||||
hex = m.st.sparkSuccess
|
||||
base = m.st.sparkSuccess
|
||||
t = float64(ms) / 200
|
||||
case ms < 500:
|
||||
hex = m.st.sparkWarning
|
||||
base = m.st.sparkWarning
|
||||
t = float64(ms-200) / 300
|
||||
default:
|
||||
hex = m.st.sparkDanger
|
||||
base = m.st.sparkDanger
|
||||
t = float64(ms-500) / 1500
|
||||
if t > 1 {
|
||||
t = 1
|
||||
}
|
||||
}
|
||||
hex := trueColorHex(base)
|
||||
s := lipgloss.NewStyle().Foreground(dimColor(hex, t))
|
||||
return withBg(s, bg)
|
||||
}
|
||||
|
||||
func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.Color) string {
|
||||
func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, width int, bg lipgloss.TerminalColor) string {
|
||||
if len(latencies) == 0 {
|
||||
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
|
||||
}
|
||||
@@ -103,7 +115,7 @@ func (m Model) latencySparkline(latencies []time.Duration, statuses []bool, widt
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.Color) string {
|
||||
func (m Model) heartbeatSparkline(statuses []bool, width int, bg lipgloss.TerminalColor) string {
|
||||
if len(statuses) == 0 {
|
||||
return withBg(m.st.subtleStyle, bg).Render(strings.Repeat("·", width))
|
||||
}
|
||||
@@ -143,7 +155,7 @@ func resolveSparklineIndex(x, sparkWidth, dataLen int) int {
|
||||
return offset + (x - padding)
|
||||
}
|
||||
|
||||
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.Color) string {
|
||||
func (m Model) groupSparkline(groupID int, width int, bg lipgloss.TerminalColor) string {
|
||||
allSites := m.engine.GetAllSites()
|
||||
var childStatuses [][]bool
|
||||
for _, s := range allSites {
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func TestLatencySparkline_Empty(t *testing.T) {
|
||||
got := styledModel.latencySparkline(nil, nil, 10, "")
|
||||
got := styledModel.latencySparkline(nil, nil, 10, nil)
|
||||
if !strings.Contains(got, "··········") {
|
||||
t.Errorf("empty sparkline should be dots, got %q", got)
|
||||
}
|
||||
@@ -17,7 +19,7 @@ func TestLatencySparkline_Empty(t *testing.T) {
|
||||
func TestLatencySparkline_SingleValue(t *testing.T) {
|
||||
latencies := []time.Duration{100 * time.Millisecond}
|
||||
statuses := []bool{true}
|
||||
got := styledModel.latencySparkline(latencies, statuses, 5, "")
|
||||
got := styledModel.latencySparkline(latencies, statuses, 5, nil)
|
||||
if len(got) == 0 {
|
||||
t.Error("sparkline should not be empty")
|
||||
}
|
||||
@@ -33,7 +35,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) {
|
||||
latencies[i] = time.Duration(i*50) * time.Millisecond
|
||||
statuses[i] = true
|
||||
}
|
||||
got := styledModel.latencySparkline(latencies, statuses, 5, "")
|
||||
got := styledModel.latencySparkline(latencies, statuses, 5, nil)
|
||||
if len(got) == 0 {
|
||||
t.Error("sparkline should not be empty")
|
||||
}
|
||||
@@ -45,7 +47,7 @@ func TestLatencySparkline_WidthTruncation(t *testing.T) {
|
||||
func TestLatencySparkline_RelativeHeight(t *testing.T) {
|
||||
latencies := []time.Duration{10 * time.Millisecond, 50 * time.Millisecond, 10 * time.Millisecond}
|
||||
statuses := []bool{true, true, true}
|
||||
out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, ""))
|
||||
out := stripANSI(styledModel.latencySparkline(latencies, statuses, 3, nil))
|
||||
runes := []rune(out)
|
||||
if len(runes) < 3 {
|
||||
t.Fatalf("expected 3 runes, got %d", len(runes))
|
||||
@@ -57,14 +59,14 @@ func TestLatencySparkline_RelativeHeight(t *testing.T) {
|
||||
|
||||
func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
|
||||
st := newStyles(themeFlexokiDark)
|
||||
st.sparkSuccess = "#00ff00"
|
||||
st.sparkWarning = "#ffff00"
|
||||
st.sparkDanger = "#ff0000"
|
||||
st.sparkSuccess = lipgloss.Color("#00ff00")
|
||||
st.sparkWarning = lipgloss.Color("#ffff00")
|
||||
st.sparkDanger = lipgloss.Color("#ff0000")
|
||||
m := Model{st: st}
|
||||
|
||||
green := m.latencyStyle(50, "")
|
||||
yellow := m.latencyStyle(300, "")
|
||||
red := m.latencyStyle(800, "")
|
||||
green := m.latencyStyle(50, nil)
|
||||
yellow := m.latencyStyle(300, nil)
|
||||
red := m.latencyStyle(800, nil)
|
||||
|
||||
gfg := green.GetForeground()
|
||||
yfg := yellow.GetForeground()
|
||||
@@ -77,11 +79,11 @@ func TestLatencyStyle_BandsProduceDifferentColors(t *testing.T) {
|
||||
|
||||
func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
|
||||
st := newStyles(themeFlexokiDark)
|
||||
st.sparkSuccess = "#00ff00"
|
||||
st.sparkSuccess = lipgloss.Color("#00ff00")
|
||||
m := Model{st: st}
|
||||
|
||||
dim := m.latencyStyle(10, "")
|
||||
bright := m.latencyStyle(190, "")
|
||||
dim := m.latencyStyle(10, nil)
|
||||
bright := m.latencyStyle(190, nil)
|
||||
|
||||
if dim.GetForeground() == bright.GetForeground() {
|
||||
t.Error("10ms and 190ms should have different brightness within green band")
|
||||
@@ -91,7 +93,7 @@ func TestLatencyStyle_BrightnessVariesWithinBand(t *testing.T) {
|
||||
func TestLatencySparkline_OutputWidth(t *testing.T) {
|
||||
latencies := []time.Duration{100 * time.Millisecond, 200 * time.Millisecond, 300 * time.Millisecond}
|
||||
statuses := []bool{true, true, true}
|
||||
got := styledModel.latencySparkline(latencies, statuses, 5, "")
|
||||
got := styledModel.latencySparkline(latencies, statuses, 5, nil)
|
||||
count := utf8.RuneCountInString(stripANSI(got))
|
||||
if count != 5 {
|
||||
t.Errorf("expected 5 rune-width output, got %d from %q", count, got)
|
||||
@@ -116,7 +118,7 @@ func stripANSI(s string) string {
|
||||
}
|
||||
|
||||
func TestHeartbeatSparkline_Empty(t *testing.T) {
|
||||
got := styledModel.heartbeatSparkline(nil, 10, "")
|
||||
got := styledModel.heartbeatSparkline(nil, 10, nil)
|
||||
if !strings.Contains(got, "··········") {
|
||||
t.Errorf("empty heartbeat should be dots, got %q", got)
|
||||
}
|
||||
@@ -124,7 +126,7 @@ func TestHeartbeatSparkline_Empty(t *testing.T) {
|
||||
|
||||
func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
||||
statuses := []bool{true, false, true, true, false}
|
||||
got := styledModel.heartbeatSparkline(statuses, 5, "")
|
||||
got := styledModel.heartbeatSparkline(statuses, 5, nil)
|
||||
if len(got) == 0 {
|
||||
t.Error("heartbeat sparkline should not be empty")
|
||||
}
|
||||
@@ -132,7 +134,7 @@ func TestHeartbeatSparkline_Mixed(t *testing.T) {
|
||||
|
||||
func TestHeartbeatSparkline_PaddedWidth(t *testing.T) {
|
||||
statuses := []bool{true, true}
|
||||
got := styledModel.heartbeatSparkline(statuses, 5, "")
|
||||
got := styledModel.heartbeatSparkline(statuses, 5, nil)
|
||||
if !strings.Contains(got, "···") {
|
||||
t.Errorf("should have dot padding for width > data, got %q", got)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"sort"
|
||||
@@ -185,7 +184,7 @@ func (m Model) viewAlertsTab() string {
|
||||
nameW := widths[2]
|
||||
cfgW := widths[4]
|
||||
|
||||
return m.renderTable(
|
||||
tbl := m.renderTable(
|
||||
headers,
|
||||
len(m.alerts),
|
||||
func(start, end int) [][]string {
|
||||
@@ -206,6 +205,90 @@ func (m Model) viewAlertsTab() string {
|
||||
},
|
||||
widths, nil,
|
||||
)
|
||||
|
||||
var totalSent, totalFail int
|
||||
for _, a := range m.alerts {
|
||||
h := m.engine.GetAlertHealth(a.ID)
|
||||
totalSent += h.SendCount
|
||||
totalFail += h.FailCount
|
||||
}
|
||||
types := make(map[string]bool)
|
||||
for _, a := range m.alerts {
|
||||
types[a.Type] = true
|
||||
}
|
||||
failLabel := "failures"
|
||||
if totalFail == 1 {
|
||||
failLabel = "failure"
|
||||
}
|
||||
summary := fmt.Sprintf("%d channels · %d types · %d sent · %d %s",
|
||||
len(m.alerts), len(types), totalSent, totalFail, failLabel)
|
||||
|
||||
var detail string
|
||||
if m.cursor < len(m.alerts) {
|
||||
a := m.alerts[m.cursor]
|
||||
h := m.engine.GetAlertHealth(a.ID)
|
||||
detail = m.alertSelectedDetail(a, h)
|
||||
}
|
||||
|
||||
return tbl + "\n " + detail + "\n " + m.st.subtleStyle.Render(summary)
|
||||
}
|
||||
|
||||
func (m Model) alertSelectedDetail(a models.AlertConfig, h monitor.AlertHealth) string {
|
||||
dot := m.st.subtleStyle.Render(" · ")
|
||||
label := m.st.subtleStyle
|
||||
|
||||
parts := []string{fmtAlertType(a.Type)}
|
||||
parts = append(parts, m.fmtAlertConfigFull(a))
|
||||
|
||||
if !h.LastSendAt.IsZero() {
|
||||
if h.LastSendOK {
|
||||
parts = append(parts, m.st.specialStyle.Render("●")+" "+label.Render("sent")+" "+m.fmtTimeAgo(h.LastSendAt))
|
||||
} else {
|
||||
parts = append(parts, m.st.dangerStyle.Render("●")+" "+label.Render("failed")+" "+m.fmtTimeAgo(h.LastSendAt))
|
||||
}
|
||||
}
|
||||
if h.SendCount > 0 {
|
||||
parts = append(parts, label.Render(fmt.Sprintf("%d sent, %d failed", h.SendCount, h.FailCount)))
|
||||
}
|
||||
|
||||
return strings.Join(parts, dot)
|
||||
}
|
||||
|
||||
func (m Model) fmtAlertConfigFull(alert models.AlertConfig) string {
|
||||
switch alert.Type {
|
||||
case "email":
|
||||
host := alert.Settings["host"]
|
||||
to := alert.Settings["to"]
|
||||
if host != "" && to != "" {
|
||||
return fmt.Sprintf("%s → %s", host, to)
|
||||
}
|
||||
if host != "" {
|
||||
return host
|
||||
}
|
||||
return m.st.subtleStyle.Render("—")
|
||||
case "ntfy":
|
||||
topic := alert.Settings["topic"]
|
||||
url := alert.Settings["url"]
|
||||
if url != "" && topic != "" {
|
||||
return fmt.Sprintf("%s/%s", url, topic)
|
||||
}
|
||||
return m.st.subtleStyle.Render("—")
|
||||
case "telegram":
|
||||
if id := alert.Settings["chat_id"]; id != "" {
|
||||
return fmt.Sprintf("chat:%s", id)
|
||||
}
|
||||
return m.st.subtleStyle.Render("—")
|
||||
case "gotify":
|
||||
if url := alert.Settings["url"]; url != "" {
|
||||
return url
|
||||
}
|
||||
return m.st.subtleStyle.Render("—")
|
||||
default:
|
||||
if val, ok := alert.Settings["url"]; ok && val != "" {
|
||||
return maskWebhookURL(val)
|
||||
}
|
||||
return m.st.subtleStyle.Render("—")
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) viewAlertDetailPanel() string {
|
||||
@@ -518,15 +601,16 @@ func (m *Model) submitAlertForm() tea.Cmd {
|
||||
}
|
||||
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
id := m.editID
|
||||
name, aType := d.Name, d.AlertType
|
||||
m.state = stateDashboard
|
||||
if id > 0 {
|
||||
return writeCmd("Update alert", func() error {
|
||||
return st.UpdateAlert(context.Background(), id, name, aType, settings)
|
||||
return st.UpdateAlert(ctx, id, name, aType, settings)
|
||||
})
|
||||
}
|
||||
return writeCmd("Add alert", func() error {
|
||||
return st.AddAlert(context.Background(), name, aType, settings)
|
||||
return st.AddAlert(ctx, name, aType, settings)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ package tui
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type logSeverity int
|
||||
@@ -15,8 +18,8 @@ const (
|
||||
severitySystem
|
||||
)
|
||||
|
||||
func classifyLog(line string) logSeverity {
|
||||
lower := strings.ToLower(line)
|
||||
func classifyLog(msg string) logSeverity {
|
||||
lower := strings.ToLower(msg)
|
||||
switch {
|
||||
case strings.Contains(lower, "confirmed down"),
|
||||
strings.Contains(lower, "is down"),
|
||||
@@ -63,67 +66,49 @@ func (m Model) renderLogTag(sev logSeverity) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) renderLogLine(line string) string {
|
||||
sev := classifyLog(line)
|
||||
func (m Model) renderLogLine(entry models.LogEntry) string {
|
||||
sev := classifyLog(entry.Message)
|
||||
tag := m.renderLogTag(sev)
|
||||
|
||||
ts := ""
|
||||
msg := line
|
||||
if len(line) > 10 && line[0] == '[' {
|
||||
if idx := strings.Index(line, "]"); idx > 0 && idx < 12 {
|
||||
ts = m.st.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)
|
||||
ts := m.st.subtleStyle.Render(entry.CreatedAt.Local().Format("01/02 15:04"))
|
||||
return fmt.Sprintf(" %s %s %s", ts, tag, entry.Message)
|
||||
}
|
||||
|
||||
func (m Model) viewLogsFullscreen() string {
|
||||
header := m.st.subtleStyle.Render(" Logs") + "\n" + m.divider()
|
||||
|
||||
filterLabel := m.st.subtleStyle.Render("[f] All")
|
||||
if m.logFilterImportant {
|
||||
filterLabel = m.st.subtleStyle.Render("[f] Important only")
|
||||
}
|
||||
countLabel := m.st.subtleStyle.Render(fmt.Sprintf("%d/%d", m.logShown, m.logTotal))
|
||||
footer := m.divider() + "\n " + m.st.subtleStyle.Render("[Esc] Back") + " " + filterLabel + " " + countLabel
|
||||
|
||||
m.logViewport.Width = m.termWidth - chromePadH
|
||||
m.logViewport.Height = m.termHeight - 8
|
||||
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(
|
||||
header + "\n" + m.logViewport.View() + "\n" + footer)
|
||||
}
|
||||
|
||||
// refreshLogContent rebuilds the log viewport from the full engine log list,
|
||||
// filtering before windowing so the entry count and "(n hidden)" reflect all
|
||||
// logs, not just the visible viewport slice.
|
||||
func (m *Model) refreshLogContent() {
|
||||
var rendered []string
|
||||
total := 0
|
||||
shown := 0
|
||||
|
||||
for _, line := range m.engine.GetLogs() {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
for _, entry := range m.engine.GetLogs() {
|
||||
if strings.TrimSpace(entry.Message) == "" {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
sev := classifyLog(line)
|
||||
sev := classifyLog(entry.Message)
|
||||
if m.logFilterImportant && !isImportantLog(sev) {
|
||||
continue
|
||||
}
|
||||
shown++
|
||||
rendered = append(rendered, m.renderLogLine(line))
|
||||
rendered = append(rendered, m.renderLogLine(entry))
|
||||
}
|
||||
|
||||
m.logTotal = total
|
||||
m.logShown = shown
|
||||
m.logViewport.SetContent(strings.Join(rendered, "\n"))
|
||||
}
|
||||
|
||||
func (m Model) viewLogsTab() string {
|
||||
if m.logTotal == 0 {
|
||||
return m.emptyState("No log entries yet.", "Logs appear as monitors run checks")
|
||||
}
|
||||
|
||||
filterLabel := "All"
|
||||
if m.logFilterImportant {
|
||||
filterLabel = "Important"
|
||||
}
|
||||
|
||||
header := m.st.subtleStyle.Render(fmt.Sprintf(
|
||||
" %d entries Filter: %s", m.logShown, filterLabel))
|
||||
|
||||
if m.logFilterImportant && m.logShown < m.logTotal {
|
||||
header += m.st.subtleStyle.Render(fmt.Sprintf(" (%d hidden)", m.logTotal-m.logShown))
|
||||
}
|
||||
|
||||
return "\n" + header + "\n\n" + m.logViewport.View()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) renderCompactLogLine(entry models.LogEntry, maxW int) string {
|
||||
sev := classifyLog(entry.Message)
|
||||
|
||||
var tag string
|
||||
switch sev {
|
||||
case severityDown:
|
||||
tag = m.st.dangerStyle.Render("▼")
|
||||
case severityUp:
|
||||
tag = m.st.specialStyle.Render("▲")
|
||||
case severityWarn:
|
||||
tag = m.st.warnStyle.Render("◆")
|
||||
case severitySystem:
|
||||
tag = m.st.titleStyle.Render("●")
|
||||
default:
|
||||
tag = m.st.subtleStyle.Render("·")
|
||||
}
|
||||
|
||||
ts := entry.CreatedAt.Local().Format("01/02 15:04")
|
||||
|
||||
msg := entry.Message
|
||||
msg = strings.TrimPrefix(msg, "Monitor ")
|
||||
msg = strings.TrimPrefix(msg, "Push ")
|
||||
|
||||
prefixW := len(ts) + 4
|
||||
msgW := maxW - prefixW
|
||||
if msgW < 5 {
|
||||
msgW = 5
|
||||
}
|
||||
msg = limitStr(msg, msgW)
|
||||
|
||||
return " " + m.st.subtleStyle.Render(ts) + " " + tag + " " + msg
|
||||
}
|
||||
|
||||
func (m Model) viewLogsSidebar(width, maxLines int) string {
|
||||
logs := m.engine.GetLogs()
|
||||
if len(logs) == 0 {
|
||||
return m.st.subtleStyle.Render(" No logs yet")
|
||||
}
|
||||
|
||||
sidebarStyle := lipgloss.NewStyle().Width(width).MaxWidth(width)
|
||||
|
||||
var all []string
|
||||
for _, entry := range logs {
|
||||
if strings.TrimSpace(entry.Message) == "" {
|
||||
continue
|
||||
}
|
||||
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
|
||||
continue
|
||||
}
|
||||
all = append(all, m.renderCompactLogLine(entry, width))
|
||||
}
|
||||
|
||||
start := m.logScrollOffset
|
||||
if start > len(all) {
|
||||
start = len(all)
|
||||
}
|
||||
end := start + maxLines
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
visible := all[start:end]
|
||||
|
||||
return sidebarStyle.Render(strings.Join(visible, "\n"))
|
||||
}
|
||||
|
||||
func (m *Model) scrollLogs(delta int) {
|
||||
logs := m.engine.GetLogs()
|
||||
total := 0
|
||||
for _, entry := range logs {
|
||||
if strings.TrimSpace(entry.Message) == "" {
|
||||
continue
|
||||
}
|
||||
if m.logFilterImportant && !isImportantLog(classifyLog(entry.Message)) {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
}
|
||||
|
||||
m.logScrollOffset += delta
|
||||
if m.logScrollOffset < 0 {
|
||||
m.logScrollOffset = 0
|
||||
}
|
||||
if m.logScrollOffset > total-1 {
|
||||
m.logScrollOffset = total - 1
|
||||
}
|
||||
if m.logScrollOffset < 0 {
|
||||
m.logScrollOffset = 0
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -107,7 +106,7 @@ func (m Model) viewMaintTab() string {
|
||||
monW := widths[3]
|
||||
timeW := widths[5]
|
||||
|
||||
return m.renderTable(
|
||||
tbl := m.renderTable(
|
||||
headers,
|
||||
len(m.maintenanceWindows),
|
||||
func(start, end int) [][]string {
|
||||
@@ -130,6 +129,21 @@ func (m Model) viewMaintTab() string {
|
||||
widths,
|
||||
nil,
|
||||
)
|
||||
|
||||
now := time.Now()
|
||||
var active, scheduled, ended int
|
||||
for _, mw := range m.maintenanceWindows {
|
||||
if mw.StartTime.After(now) {
|
||||
scheduled++
|
||||
} else if !mw.EndTime.IsZero() && mw.EndTime.Before(now) {
|
||||
ended++
|
||||
} else {
|
||||
active++
|
||||
}
|
||||
}
|
||||
summary := fmt.Sprintf("%d active · %d scheduled · %d ended", active, scheduled, ended)
|
||||
|
||||
return tbl + "\n " + m.st.subtleStyle.Render(summary)
|
||||
}
|
||||
|
||||
func (m *Model) initMaintHuhForm() tea.Cmd {
|
||||
@@ -239,8 +253,13 @@ func (m *Model) submitMaintForm() tea.Cmd {
|
||||
}
|
||||
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
m.state = stateDashboard
|
||||
return writeCmd("Add maintenance window", func() error {
|
||||
return st.AddMaintenanceWindow(context.Background(), mw)
|
||||
overlaps, _ := st.GetOverlappingMaintenanceWindows(ctx, mw.MonitorID, mw.StartTime, mw.EndTime)
|
||||
if len(overlaps) > 0 {
|
||||
_ = st.SaveLog(ctx, fmt.Sprintf("Overlap: new window %q overlaps with existing %q", mw.Title, overlaps[0].Title))
|
||||
}
|
||||
return st.AddMaintenanceWindow(ctx, mw)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -20,7 +22,7 @@ func (m Model) viewNodesTab() string {
|
||||
}
|
||||
nameW := widths[0]
|
||||
|
||||
return m.renderTable(
|
||||
tbl := m.renderTable(
|
||||
headers,
|
||||
len(m.nodes),
|
||||
func(start, end int) [][]string {
|
||||
@@ -48,6 +50,30 @@ func (m Model) viewNodesTab() string {
|
||||
widths,
|
||||
nil,
|
||||
)
|
||||
|
||||
var online int
|
||||
regions := make(map[string]bool)
|
||||
var leader string
|
||||
for _, n := range m.nodes {
|
||||
if time.Since(n.LastSeen) < nodeOnlineThreshold {
|
||||
online++
|
||||
}
|
||||
if n.Region != "" {
|
||||
regions[n.Region] = true
|
||||
}
|
||||
if n.Name == "leader" {
|
||||
leader = n.ID
|
||||
}
|
||||
}
|
||||
parts := []string{fmt.Sprintf("%d/%d online", online, len(m.nodes))}
|
||||
if leader != "" {
|
||||
parts = append(parts, fmt.Sprintf("leader: %s", leader))
|
||||
}
|
||||
if len(regions) > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d regions", len(regions)))
|
||||
}
|
||||
|
||||
return tbl + "\n " + m.st.subtleStyle.Render(strings.Join(parts, " · "))
|
||||
}
|
||||
|
||||
func (m Model) fmtNodeStatus(lastSeen time.Time) string {
|
||||
@@ -55,10 +81,10 @@ func (m Model) fmtNodeStatus(lastSeen time.Time) string {
|
||||
return m.st.subtleStyle.Render("UNKNOWN")
|
||||
}
|
||||
ago := time.Since(lastSeen)
|
||||
if ago < 60*time.Second {
|
||||
if ago < nodeOnlineThreshold {
|
||||
return m.st.specialStyle.Render("ONLINE")
|
||||
}
|
||||
if ago < 5*time.Minute {
|
||||
if ago < nodeStaleThreshold {
|
||||
return m.st.warnStyle.Render("STALE")
|
||||
}
|
||||
return m.st.dangerStyle.Render("OFFLINE")
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m Model) viewSettingsTab() string {
|
||||
maxSections := 2
|
||||
if m.isAdmin {
|
||||
maxSections = 3
|
||||
}
|
||||
|
||||
sections := []string{"Alerts", "Nodes"}
|
||||
if m.isAdmin {
|
||||
sections = append(sections, "Users")
|
||||
}
|
||||
_ = maxSections
|
||||
|
||||
var tabs []string
|
||||
for i, name := range sections {
|
||||
var rendered string
|
||||
if i == m.settingsSection {
|
||||
rendered = m.st.activeTab.Render(name)
|
||||
} else {
|
||||
rendered = m.st.inactiveTab.Render(name)
|
||||
}
|
||||
tabs = append(tabs, m.zones.Mark(fmt.Sprintf("section-%d", i), rendered))
|
||||
}
|
||||
header := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
|
||||
|
||||
var content string
|
||||
switch m.settingsSection {
|
||||
case sectionAlerts:
|
||||
content = m.viewAlertsTab()
|
||||
case sectionNodes:
|
||||
content = m.viewNodesTab()
|
||||
case sectionUsers:
|
||||
if m.isAdmin {
|
||||
content = m.viewUsersTab()
|
||||
}
|
||||
}
|
||||
|
||||
return header + "\n" + content
|
||||
}
|
||||
|
||||
func (m *Model) switchSettingsSection(idx int) {
|
||||
max := 1
|
||||
if m.isAdmin {
|
||||
max = 2
|
||||
}
|
||||
if idx > max {
|
||||
idx = 0
|
||||
}
|
||||
if idx < 0 {
|
||||
idx = max
|
||||
}
|
||||
m.settingsSection = idx
|
||||
m.cursor = 0
|
||||
m.tableOffset = 0
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -82,8 +81,12 @@ func (m Model) computeLayout() tableLayout {
|
||||
var widths []int
|
||||
var fixed int
|
||||
|
||||
cw := m.contentWidth
|
||||
if cw == 0 {
|
||||
cw = m.termWidth
|
||||
}
|
||||
for _, c := range siteColumns {
|
||||
if c.minTerm > 0 && m.termWidth < c.minTerm {
|
||||
if c.minTerm > 0 && cw < c.minTerm {
|
||||
continue
|
||||
}
|
||||
active = append(active, c.key)
|
||||
@@ -102,9 +105,37 @@ func (m Model) computeLayout() tableLayout {
|
||||
}
|
||||
}
|
||||
|
||||
sortColMap := map[int]colKey{
|
||||
sortStatus: colStatus,
|
||||
sortName: colName,
|
||||
sortLatency: colLatency,
|
||||
}
|
||||
sortableKeys := map[colKey]string{
|
||||
colStatus: "sort-status",
|
||||
colName: "sort-name",
|
||||
colLatency: "sort-latency",
|
||||
}
|
||||
if sortedKey, ok := sortColMap[m.sortColumn]; ok {
|
||||
arrow := "▼"
|
||||
if m.sortAsc {
|
||||
arrow = "▲"
|
||||
}
|
||||
for i, k := range active {
|
||||
if k == sortedKey {
|
||||
headers[i] = headers[i] + arrow
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, k := range active {
|
||||
if zoneID, ok := sortableKeys[k]; ok {
|
||||
headers[i] = m.zones.Mark(zoneID, headers[i])
|
||||
}
|
||||
}
|
||||
|
||||
numCols := len(headers)
|
||||
borderOverhead := 2 + (numCols - 1)
|
||||
avail := m.termWidth - chromePadH - 2 - borderOverhead - fixed
|
||||
avail := cw - chromePadH - 2 - borderOverhead - fixed
|
||||
if avail < 20 {
|
||||
avail = 20
|
||||
}
|
||||
@@ -204,7 +235,7 @@ func (m Model) viewSitesTab() string {
|
||||
for i := start; i < end; i++ {
|
||||
site := m.sites[i]
|
||||
rowIdx := i - start
|
||||
var rowBg lipgloss.Color
|
||||
var rowBg lipgloss.TerminalColor
|
||||
if i == m.cursor {
|
||||
rowBg = m.theme.SelectedBg
|
||||
} else if rowIdx%2 == 1 {
|
||||
@@ -582,10 +613,11 @@ func (m *Model) submitSiteForm() tea.Cmd {
|
||||
}
|
||||
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
m.state = stateDashboard
|
||||
if m.editID > 0 {
|
||||
m.engine.UpdateSiteConfig(cfg)
|
||||
return writeCmd("Update site", func() error { return st.UpdateSite(context.Background(), cfg) })
|
||||
return writeCmd("Update site", func() error { return st.UpdateSite(ctx, cfg) })
|
||||
}
|
||||
return writeCmd("Add site", func() error { return st.AddSite(context.Background(), cfg) })
|
||||
return writeCmd("Add site", func() error { return st.AddSite(ctx, cfg) })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
@@ -114,15 +113,16 @@ func (m *Model) initUserHuhForm() tea.Cmd {
|
||||
func (m *Model) submitUserForm() tea.Cmd {
|
||||
d := m.userFormData
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
id := m.editID
|
||||
username, key, role := d.Username, d.PublicKey, d.Role
|
||||
m.state = stateUsers
|
||||
m.state = stateDashboard
|
||||
if id > 0 {
|
||||
return writeCmd("Update user", func() error {
|
||||
return st.UpdateUser(context.Background(), id, username, key, role)
|
||||
return st.UpdateUser(ctx, id, username, key, role)
|
||||
})
|
||||
}
|
||||
return writeCmd("Add user", func() error {
|
||||
return st.AddUser(context.Background(), username, key, role)
|
||||
return st.AddUser(ctx, username, key, role)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,7 +13,11 @@ const (
|
||||
)
|
||||
|
||||
func (m Model) isWide() bool {
|
||||
return m.termWidth >= wideBreakpoint
|
||||
w := m.contentWidth
|
||||
if w == 0 {
|
||||
w = m.termWidth
|
||||
}
|
||||
return w >= wideBreakpoint
|
||||
}
|
||||
|
||||
func (m Model) renderTable(headers []string, items int, buildRows func(start, end int) [][]string, colWidths []int, styleOverride StyleOverride) string {
|
||||
@@ -35,7 +39,11 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
||||
}
|
||||
borderOverhead := 2 + len(colWidths) - 1
|
||||
tableWidth := colTotal + borderOverhead
|
||||
maxWidth := m.termWidth - chromePadH - 2
|
||||
cw := m.contentWidth
|
||||
if cw == 0 {
|
||||
cw = m.termWidth
|
||||
}
|
||||
maxWidth := cw - chromePadH - 2
|
||||
if tableWidth > maxWidth {
|
||||
tableWidth = maxWidth
|
||||
}
|
||||
@@ -44,8 +52,7 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
||||
}
|
||||
|
||||
t := table.New().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderStyle(m.st.tableBorderStyle).
|
||||
Border(lipgloss.HiddenBorder()).
|
||||
Width(tableWidth).
|
||||
Headers(headers...).
|
||||
Rows(rows...).
|
||||
@@ -86,5 +93,5 @@ func (m Model) renderTable(headers []string, items int, buildRows func(start, en
|
||||
return base
|
||||
})
|
||||
|
||||
return "\n" + t.Render()
|
||||
return t.Render()
|
||||
}
|
||||
|
||||
@@ -5,35 +5,43 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func cc(hex, ansi string) lipgloss.CompleteColor {
|
||||
return lipgloss.CompleteColor{
|
||||
TrueColor: hex,
|
||||
ANSI256: hex,
|
||||
ANSI: ansi,
|
||||
}
|
||||
}
|
||||
|
||||
type Theme struct {
|
||||
Name string
|
||||
|
||||
// Base layers
|
||||
Bg lipgloss.Color
|
||||
Surface lipgloss.Color
|
||||
Panel lipgloss.Color
|
||||
Border lipgloss.Color
|
||||
Bg lipgloss.TerminalColor
|
||||
Surface lipgloss.TerminalColor
|
||||
Panel lipgloss.TerminalColor
|
||||
Border lipgloss.TerminalColor
|
||||
|
||||
// Text
|
||||
Fg lipgloss.Color
|
||||
Muted lipgloss.Color
|
||||
Subtle lipgloss.Color
|
||||
Fg lipgloss.TerminalColor
|
||||
Muted lipgloss.TerminalColor
|
||||
Subtle lipgloss.TerminalColor
|
||||
|
||||
// Semantic
|
||||
Success lipgloss.Color
|
||||
Warning lipgloss.Color
|
||||
Stale lipgloss.Color
|
||||
Danger lipgloss.Color
|
||||
Info lipgloss.Color
|
||||
Accent lipgloss.Color
|
||||
Purple lipgloss.Color
|
||||
Success lipgloss.TerminalColor
|
||||
Warning lipgloss.TerminalColor
|
||||
Stale lipgloss.TerminalColor
|
||||
Danger lipgloss.TerminalColor
|
||||
Info lipgloss.TerminalColor
|
||||
Accent lipgloss.TerminalColor
|
||||
Purple lipgloss.TerminalColor
|
||||
|
||||
// Table
|
||||
ZebraBg lipgloss.Color
|
||||
ZebraBg lipgloss.TerminalColor
|
||||
|
||||
// Selection
|
||||
SelectedFg lipgloss.Color
|
||||
SelectedBg lipgloss.Color
|
||||
SelectedFg lipgloss.TerminalColor
|
||||
SelectedBg lipgloss.TerminalColor
|
||||
}
|
||||
|
||||
var themes = []Theme{
|
||||
@@ -46,107 +54,107 @@ var themes = []Theme{
|
||||
|
||||
var themeFlexokiDark = Theme{
|
||||
Name: "Flexoki Dark",
|
||||
Bg: "#1C1B1A",
|
||||
Surface: "#282726",
|
||||
Panel: "#343331",
|
||||
Border: "#575653",
|
||||
Fg: "#CECDC3",
|
||||
Muted: "#878580",
|
||||
Subtle: "#6F6E69",
|
||||
Success: "#879A39",
|
||||
Warning: "#D0A215",
|
||||
Stale: "#DA702C",
|
||||
Danger: "#D14D41",
|
||||
Info: "#4385BE",
|
||||
Accent: "#3AA99F",
|
||||
Purple: "#8B7EC8",
|
||||
ZebraBg: "#222120",
|
||||
SelectedFg: "#FFFCF0",
|
||||
SelectedBg: "#403E3C",
|
||||
Bg: cc("#1C1B1A", ""),
|
||||
Surface: cc("#282726", ""),
|
||||
Panel: cc("#343331", ""),
|
||||
Border: cc("#575653", "8"),
|
||||
Fg: cc("#CECDC3", "15"),
|
||||
Muted: cc("#878580", "7"),
|
||||
Subtle: cc("#6F6E69", "7"),
|
||||
Success: cc("#879A39", "10"),
|
||||
Warning: cc("#D0A215", "11"),
|
||||
Stale: cc("#DA702C", "3"),
|
||||
Danger: cc("#D14D41", "9"),
|
||||
Info: cc("#4385BE", "12"),
|
||||
Accent: cc("#3AA99F", "14"),
|
||||
Purple: cc("#8B7EC8", "13"),
|
||||
ZebraBg: cc("#222120", ""),
|
||||
SelectedFg: cc("#FFFCF0", "15"),
|
||||
SelectedBg: cc("#403E3C", "4"),
|
||||
}
|
||||
|
||||
var themeTokyoNight = Theme{
|
||||
Name: "Tokyo Night",
|
||||
Bg: "#1a1b26",
|
||||
Surface: "#24283b",
|
||||
Panel: "#292e42",
|
||||
Border: "#3b4261",
|
||||
Fg: "#c0caf5",
|
||||
Muted: "#a9b1d6",
|
||||
Subtle: "#565f89",
|
||||
Success: "#9ece6a",
|
||||
Warning: "#e0af68",
|
||||
Stale: "#ff9e64",
|
||||
Danger: "#f7768e",
|
||||
Info: "#7aa2f7",
|
||||
Accent: "#7dcfff",
|
||||
Purple: "#bb9af7",
|
||||
ZebraBg: "#1c1d28",
|
||||
SelectedFg: "#c0caf5",
|
||||
SelectedBg: "#292e42",
|
||||
Bg: cc("#1a1b26", ""),
|
||||
Surface: cc("#24283b", ""),
|
||||
Panel: cc("#292e42", ""),
|
||||
Border: cc("#3b4261", "8"),
|
||||
Fg: cc("#c0caf5", "15"),
|
||||
Muted: cc("#a9b1d6", "7"),
|
||||
Subtle: cc("#565f89", "7"),
|
||||
Success: cc("#9ece6a", "10"),
|
||||
Warning: cc("#e0af68", "11"),
|
||||
Stale: cc("#ff9e64", "3"),
|
||||
Danger: cc("#f7768e", "9"),
|
||||
Info: cc("#7aa2f7", "12"),
|
||||
Accent: cc("#7dcfff", "14"),
|
||||
Purple: cc("#bb9af7", "13"),
|
||||
ZebraBg: cc("#1c1d28", ""),
|
||||
SelectedFg: cc("#c0caf5", "15"),
|
||||
SelectedBg: cc("#292e42", "4"),
|
||||
}
|
||||
|
||||
var themeGruvbox = Theme{
|
||||
Name: "Gruvbox",
|
||||
Bg: "#282828",
|
||||
Surface: "#3c3836",
|
||||
Panel: "#504945",
|
||||
Border: "#665c54",
|
||||
Fg: "#ebdbb2",
|
||||
Muted: "#bdae93",
|
||||
Subtle: "#7c6f64",
|
||||
Success: "#b8bb26",
|
||||
Warning: "#fabd2f",
|
||||
Stale: "#fe8019",
|
||||
Danger: "#fb4934",
|
||||
Info: "#83a598",
|
||||
Accent: "#8ec07c",
|
||||
Purple: "#d3869b",
|
||||
ZebraBg: "#2a2a2a",
|
||||
SelectedFg: "#fbf1c7",
|
||||
SelectedBg: "#504945",
|
||||
Bg: cc("#282828", ""),
|
||||
Surface: cc("#3c3836", ""),
|
||||
Panel: cc("#504945", ""),
|
||||
Border: cc("#665c54", "8"),
|
||||
Fg: cc("#ebdbb2", "15"),
|
||||
Muted: cc("#bdae93", "7"),
|
||||
Subtle: cc("#7c6f64", "7"),
|
||||
Success: cc("#b8bb26", "10"),
|
||||
Warning: cc("#fabd2f", "11"),
|
||||
Stale: cc("#fe8019", "3"),
|
||||
Danger: cc("#fb4934", "9"),
|
||||
Info: cc("#83a598", "12"),
|
||||
Accent: cc("#8ec07c", "14"),
|
||||
Purple: cc("#d3869b", "13"),
|
||||
ZebraBg: cc("#2a2a2a", ""),
|
||||
SelectedFg: cc("#fbf1c7", "15"),
|
||||
SelectedBg: cc("#504945", "4"),
|
||||
}
|
||||
|
||||
var themeCatppuccinMocha = Theme{
|
||||
Name: "Catppuccin Mocha",
|
||||
Bg: "#1e1e2e",
|
||||
Surface: "#313244",
|
||||
Panel: "#45475a",
|
||||
Border: "#585b70",
|
||||
Fg: "#cdd6f4",
|
||||
Muted: "#a6adc8",
|
||||
Subtle: "#6c7086",
|
||||
Success: "#a6e3a1",
|
||||
Warning: "#f9e2af",
|
||||
Stale: "#fab387",
|
||||
Danger: "#f38ba8",
|
||||
Info: "#89b4fa",
|
||||
Accent: "#94e2d5",
|
||||
Purple: "#cba6f7",
|
||||
ZebraBg: "#232334",
|
||||
SelectedFg: "#cdd6f4",
|
||||
SelectedBg: "#45475a",
|
||||
Bg: cc("#1e1e2e", ""),
|
||||
Surface: cc("#313244", ""),
|
||||
Panel: cc("#45475a", ""),
|
||||
Border: cc("#585b70", "8"),
|
||||
Fg: cc("#cdd6f4", "15"),
|
||||
Muted: cc("#a6adc8", "7"),
|
||||
Subtle: cc("#6c7086", "7"),
|
||||
Success: cc("#a6e3a1", "10"),
|
||||
Warning: cc("#f9e2af", "11"),
|
||||
Stale: cc("#fab387", "3"),
|
||||
Danger: cc("#f38ba8", "9"),
|
||||
Info: cc("#89b4fa", "12"),
|
||||
Accent: cc("#94e2d5", "14"),
|
||||
Purple: cc("#cba6f7", "13"),
|
||||
ZebraBg: cc("#232334", ""),
|
||||
SelectedFg: cc("#cdd6f4", "15"),
|
||||
SelectedBg: cc("#45475a", "4"),
|
||||
}
|
||||
|
||||
var themeNord = Theme{
|
||||
Name: "Nord",
|
||||
Bg: "#2e3440",
|
||||
Surface: "#3b4252",
|
||||
Panel: "#434c5e",
|
||||
Border: "#4c566a",
|
||||
Fg: "#d8dee9",
|
||||
Muted: "#d8dee9",
|
||||
Subtle: "#4c566a",
|
||||
Success: "#a3be8c",
|
||||
Warning: "#ebcb8b",
|
||||
Stale: "#d08770",
|
||||
Danger: "#bf616a",
|
||||
Info: "#81a1c1",
|
||||
Accent: "#88c0d0",
|
||||
Purple: "#b48ead",
|
||||
ZebraBg: "#323845",
|
||||
SelectedFg: "#eceff4",
|
||||
SelectedBg: "#434c5e",
|
||||
Bg: cc("#2e3440", ""),
|
||||
Surface: cc("#3b4252", ""),
|
||||
Panel: cc("#434c5e", ""),
|
||||
Border: cc("#4c566a", "8"),
|
||||
Fg: cc("#d8dee9", "15"),
|
||||
Muted: cc("#d8dee9", "7"),
|
||||
Subtle: cc("#4c566a", "7"),
|
||||
Success: cc("#a3be8c", "10"),
|
||||
Warning: cc("#ebcb8b", "11"),
|
||||
Stale: cc("#d08770", "3"),
|
||||
Danger: cc("#bf616a", "9"),
|
||||
Info: cc("#81a1c1", "12"),
|
||||
Accent: cc("#88c0d0", "14"),
|
||||
Purple: cc("#b48ead", "13"),
|
||||
ZebraBg: cc("#323845", ""),
|
||||
SelectedFg: cc("#eceff4", "15"),
|
||||
SelectedBg: cc("#434c5e", "4"),
|
||||
}
|
||||
|
||||
func (t Theme) HuhTheme() *huh.Theme {
|
||||
|
||||
@@ -30,9 +30,9 @@ type styles struct {
|
||||
activeTab lipgloss.Style
|
||||
inactiveTab lipgloss.Style
|
||||
|
||||
sparkSuccess string
|
||||
sparkWarning string
|
||||
sparkDanger string
|
||||
sparkSuccess lipgloss.TerminalColor
|
||||
sparkWarning lipgloss.TerminalColor
|
||||
sparkDanger lipgloss.TerminalColor
|
||||
|
||||
tableHeaderStyle lipgloss.Style
|
||||
tableCellStyle lipgloss.Style
|
||||
@@ -46,23 +46,23 @@ type styles struct {
|
||||
|
||||
func newStyles(t Theme) *styles {
|
||||
return &styles{
|
||||
subtleStyle: lipgloss.NewStyle().Foreground(t.Subtle),
|
||||
subtleStyle: lipgloss.NewStyle().Foreground(t.Subtle).Faint(true),
|
||||
specialStyle: lipgloss.NewStyle().Foreground(t.Success),
|
||||
warnStyle: lipgloss.NewStyle().Foreground(t.Warning),
|
||||
staleStyle: lipgloss.NewStyle().Foreground(t.Stale),
|
||||
dangerStyle: lipgloss.NewStyle().Foreground(t.Danger),
|
||||
warnStyle: lipgloss.NewStyle().Foreground(t.Warning).Bold(true),
|
||||
staleStyle: lipgloss.NewStyle().Foreground(t.Stale).Faint(true),
|
||||
dangerStyle: lipgloss.NewStyle().Foreground(t.Danger).Bold(true),
|
||||
titleStyle: lipgloss.NewStyle().Foreground(t.Accent).Bold(true),
|
||||
activeTab: lipgloss.NewStyle().Background(t.Surface).Foreground(t.Accent).Bold(true).Padding(0, 1),
|
||||
inactiveTab: lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted),
|
||||
inactiveTab: lipgloss.NewStyle().Padding(0, 1).Foreground(t.Muted).Faint(true),
|
||||
|
||||
sparkSuccess: string(t.Success),
|
||||
sparkWarning: string(t.Warning),
|
||||
sparkDanger: string(t.Danger),
|
||||
sparkSuccess: t.Success,
|
||||
sparkWarning: t.Warning,
|
||||
sparkDanger: t.Danger,
|
||||
|
||||
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),
|
||||
tableBorderStyle: lipgloss.NewStyle().Foreground(t.Border).Faint(true),
|
||||
tableZebraStyle: lipgloss.NewStyle().Padding(0, 1).Background(t.ZebraBg),
|
||||
|
||||
siteGroupStyle: lipgloss.NewStyle().Padding(0, 1).Bold(true).Foreground(t.Accent),
|
||||
@@ -84,6 +84,31 @@ const (
|
||||
detailSparkWidth = 40
|
||||
)
|
||||
|
||||
const (
|
||||
tabMonitors = 0
|
||||
tabMaint = 1
|
||||
tabSettings = 2
|
||||
)
|
||||
|
||||
const (
|
||||
sectionAlerts = 0
|
||||
sectionNodes = 1
|
||||
sectionUsers = 2
|
||||
)
|
||||
|
||||
const (
|
||||
panelMonitors = 0
|
||||
panelLogs = 1
|
||||
panelDetail = 2
|
||||
)
|
||||
|
||||
const (
|
||||
sortStatus = 0
|
||||
sortName = 1
|
||||
sortLatency = 2
|
||||
sortMax = 3
|
||||
)
|
||||
|
||||
type sessionState int
|
||||
|
||||
const (
|
||||
@@ -102,16 +127,22 @@ const (
|
||||
)
|
||||
|
||||
type Model struct {
|
||||
state sessionState
|
||||
currentTab int
|
||||
cursor int
|
||||
selectedID int
|
||||
tableOffset int
|
||||
maxTableRows int
|
||||
termWidth int
|
||||
termHeight int
|
||||
editID int
|
||||
editToken string
|
||||
state sessionState
|
||||
currentTab int
|
||||
settingsSection int
|
||||
cursor int
|
||||
selectedID int
|
||||
sortColumn int
|
||||
sortAsc bool
|
||||
tableOffset int
|
||||
maxTableRows int
|
||||
termWidth int
|
||||
termHeight int
|
||||
contentWidth int
|
||||
focusedPanel int
|
||||
logScrollOffset int
|
||||
editID int
|
||||
editToken string
|
||||
|
||||
huhForm *huh.Form
|
||||
siteFormData *siteFormData
|
||||
@@ -144,6 +175,7 @@ type Model struct {
|
||||
deleteName string
|
||||
deleteTab int
|
||||
|
||||
ctx context.Context
|
||||
collapsed map[int]bool
|
||||
store store.Store
|
||||
engine *monitor.Engine
|
||||
@@ -165,9 +197,12 @@ type Model struct {
|
||||
lastTabLoad time.Time // last dispatch of loadTabDataCmd (throttle)
|
||||
tabSeq int // seq of the newest issued tab-data load
|
||||
|
||||
// detail-panel state-change history, loaded on enter so View does no DB IO
|
||||
logsOpen bool
|
||||
detailOpen bool
|
||||
detailChanges []models.StateChange
|
||||
detailChangesSiteID int
|
||||
detailDailyDays []monitor.DayReport
|
||||
detailViewport viewport.Model
|
||||
|
||||
filterMode bool
|
||||
filterText string
|
||||
@@ -180,14 +215,14 @@ type Model struct {
|
||||
version string
|
||||
}
|
||||
|
||||
func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version string) Model {
|
||||
func InitialModel(ctx context.Context, isAdmin bool, s store.Store, eng *monitor.Engine, version string) Model {
|
||||
vpLogs := viewport.New(100, 20)
|
||||
vpLogs.SetContent("Waiting for logs...")
|
||||
z := zone.New()
|
||||
spring := harmonica.NewSpring(harmonica.FPS(10), 6.0, 0.4)
|
||||
collapsed := loadCollapsed(s)
|
||||
collapsed := loadCollapsed(ctx, s)
|
||||
|
||||
themeName, _ := s.GetPreference(context.Background(), "theme")
|
||||
themeName, _ := s.GetPreference(ctx, "theme")
|
||||
theme := themeByName(themeName)
|
||||
themeIdx := 0
|
||||
for i, t := range themes {
|
||||
@@ -197,7 +232,10 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
|
||||
}
|
||||
}
|
||||
|
||||
detailPref, _ := s.GetPreference(ctx, "detail_open")
|
||||
|
||||
return Model{
|
||||
ctx: ctx,
|
||||
state: stateDashboard,
|
||||
logViewport: vpLogs,
|
||||
maxTableRows: 5,
|
||||
@@ -210,6 +248,8 @@ func InitialModel(isAdmin bool, s store.Store, eng *monitor.Engine, version stri
|
||||
theme: theme,
|
||||
themeIndex: themeIdx,
|
||||
st: newStyles(theme),
|
||||
logsOpen: true,
|
||||
detailOpen: detailPref == "true",
|
||||
demoMode: os.Getenv("UPTOP_DEMO") == "1",
|
||||
version: version,
|
||||
sparkTooltipIdx: -1,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +27,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
m.detailChanges = msg.changes
|
||||
m.detailChangesSiteID = msg.siteID
|
||||
m.detailDailyDays = msg.dailyDays
|
||||
return m, nil
|
||||
case historyDataMsg:
|
||||
if msg.siteID != m.historySiteID {
|
||||
@@ -53,6 +53,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.state == stateFormSite || m.state == stateFormAlert || m.state == stateFormUser || m.state == stateFormMaint {
|
||||
return m.handleFormMsg(msg)
|
||||
}
|
||||
if m.state == stateLogs {
|
||||
return m.handleLogsFullscreen(msg)
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case tea.MouseMsg:
|
||||
@@ -75,34 +78,32 @@ func (m *Model) handleConfirmDelete(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// writeDoneMsg reload converges the UI back to the DB state (and the
|
||||
// engine poll loop re-adds a site that is still in the DB).
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
id := m.deleteID
|
||||
var cmd tea.Cmd
|
||||
switch m.deleteTab {
|
||||
case 0:
|
||||
cmd = writeCmd("Delete site", func() error { return st.DeleteSite(context.Background(), id) })
|
||||
case tabMonitors:
|
||||
cmd = writeCmd("Delete site", func() error { return st.DeleteSite(ctx, id) })
|
||||
m.engine.RemoveSite(id)
|
||||
m.adjustCursor(len(m.sites) - 1)
|
||||
case 1:
|
||||
cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(context.Background(), id) })
|
||||
m.adjustCursor(len(m.alerts) - 1)
|
||||
case 4:
|
||||
cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(context.Background(), id) })
|
||||
case tabMaint:
|
||||
cmd = writeCmd("Delete maintenance window", func() error { return st.DeleteMaintenanceWindow(ctx, id) })
|
||||
m.adjustCursor(len(m.maintenanceWindows) - 1)
|
||||
case 5:
|
||||
cmd = writeCmd("Delete user", func() error { return st.DeleteUser(context.Background(), id) })
|
||||
m.adjustCursor(len(m.users) - 1)
|
||||
case tabSettings:
|
||||
switch m.settingsSection {
|
||||
case sectionAlerts:
|
||||
cmd = writeCmd("Delete alert", func() error { return st.DeleteAlert(ctx, id) })
|
||||
m.adjustCursor(len(m.alerts) - 1)
|
||||
case sectionUsers:
|
||||
cmd = writeCmd("Delete user", func() error { return st.DeleteUser(ctx, id) })
|
||||
m.adjustCursor(len(m.users) - 1)
|
||||
}
|
||||
}
|
||||
m.refreshLive()
|
||||
m.state = stateDashboard
|
||||
if m.deleteTab == 5 {
|
||||
m.state = stateUsers
|
||||
}
|
||||
return m, cmd
|
||||
case "n", "N", "esc":
|
||||
m.state = stateDashboard
|
||||
if m.deleteTab == 5 {
|
||||
m.state = stateUsers
|
||||
}
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
}
|
||||
@@ -117,9 +118,6 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if keyMsg.String() == "esc" {
|
||||
m.huhForm = nil
|
||||
m.state = stateDashboard
|
||||
if m.currentTab == 5 {
|
||||
m.state = stateUsers
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
@@ -148,11 +146,16 @@ func (m *Model) handleFormMsg(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
const detailInlineHeight = 12
|
||||
|
||||
func (m *Model) recalcLayout() {
|
||||
chrome := chromeBase
|
||||
if m.filterMode || m.filterText != "" {
|
||||
chrome++
|
||||
}
|
||||
if m.detailOpen && m.currentTab == tabMonitors {
|
||||
chrome += detailInlineHeight
|
||||
}
|
||||
m.maxTableRows = m.termHeight - chrome
|
||||
if m.maxTableRows < 1 {
|
||||
m.maxTableRows = 1
|
||||
@@ -232,14 +235,49 @@ func (m *Model) handleTabData(msg tabDataMsg) (tea.Model, tea.Cmd) {
|
||||
// surfaces through the engine log (picked up by the next refreshLive).
|
||||
func (m *Model) testAlertCmd(id int, name string) tea.Cmd {
|
||||
eng := m.engine
|
||||
ctx := m.ctx
|
||||
return func() tea.Msg {
|
||||
if err := eng.TestAlert(id); err != nil {
|
||||
if err := eng.TestAlert(ctx, id); err != nil {
|
||||
eng.AddLog(fmt.Sprintf("Test alert failed (%s): %v", name, err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) handleLogsFullscreen(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc", "q":
|
||||
m.state = stateDashboard
|
||||
m.focusedPanel = panelLogs
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "f":
|
||||
m.logFilterImportant = !m.logFilterImportant
|
||||
m.refreshLogContent()
|
||||
case "up", "k":
|
||||
m.logViewport.ScrollUp(1)
|
||||
case "down", "j":
|
||||
m.logViewport.ScrollDown(1)
|
||||
case "pgup":
|
||||
m.logViewport.ScrollUp(m.logViewport.Height)
|
||||
case "pgdown":
|
||||
m.logViewport.ScrollDown(m.logViewport.Height)
|
||||
}
|
||||
case tea.MouseMsg:
|
||||
switch msg.Button {
|
||||
case tea.MouseButtonWheelUp:
|
||||
m.logViewport.ScrollUp(3)
|
||||
case tea.MouseButtonWheelDown:
|
||||
m.logViewport.ScrollDown(3)
|
||||
}
|
||||
case tickMsg:
|
||||
m.refreshLogContent()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if m.state == stateHistory {
|
||||
switch msg.Button {
|
||||
@@ -265,7 +303,7 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
if m.state != stateDashboard && m.state != stateLogs && m.state != stateUsers {
|
||||
if m.state != stateDashboard {
|
||||
return m, nil
|
||||
}
|
||||
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
|
||||
@@ -275,11 +313,11 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
if m.state == stateLogs {
|
||||
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||
if msg.Button == tea.MouseButtonWheelUp {
|
||||
m.logViewport.ScrollUp(3)
|
||||
m.scrollLogs(-3)
|
||||
} else {
|
||||
m.logViewport.ScrollDown(3)
|
||||
m.scrollLogs(3)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
@@ -301,6 +339,9 @@ func (m *Model) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
m.syncSelectedID()
|
||||
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
@@ -325,7 +366,7 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m.handleSLAKey(msg)
|
||||
case stateAlertDetail:
|
||||
return m.handleAlertDetailKey(msg)
|
||||
case stateDashboard, stateLogs, stateUsers:
|
||||
case stateDashboard:
|
||||
return m.handleDashboardKey(msg)
|
||||
}
|
||||
return m, nil
|
||||
@@ -365,6 +406,18 @@ func (m *Model) handleFilterKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m *Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "up", "k":
|
||||
m.detailViewport.ScrollUp(1)
|
||||
return m, nil
|
||||
case "down", "j":
|
||||
m.detailViewport.ScrollDown(1)
|
||||
return m, nil
|
||||
case "pgup":
|
||||
m.detailViewport.ScrollUp(m.detailViewport.Height / 2)
|
||||
return m, nil
|
||||
case "pgdown":
|
||||
m.detailViewport.ScrollDown(m.detailViewport.Height / 2)
|
||||
return m, nil
|
||||
case "esc":
|
||||
if m.sparkTooltipIdx >= 0 {
|
||||
m.sparkTooltipIdx = -1
|
||||
@@ -521,100 +574,200 @@ func (m *Model) handleAlertDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
switch msg.String() {
|
||||
case "q":
|
||||
return m, tea.Quit
|
||||
case "/":
|
||||
if m.currentTab == 0 {
|
||||
if m.currentTab == tabMonitors {
|
||||
m.filterMode = true
|
||||
m.recalcLayout()
|
||||
return m, nil
|
||||
}
|
||||
case "f":
|
||||
if m.state == stateLogs {
|
||||
m.logFilterImportant = !m.logFilterImportant
|
||||
m.refreshLogContent()
|
||||
return m, nil
|
||||
case ">", ".":
|
||||
if m.currentTab == tabMonitors {
|
||||
m.sortColumn = (m.sortColumn + 1) % sortMax
|
||||
m.sortAsc = false
|
||||
m.refreshLive()
|
||||
}
|
||||
case "<", ",":
|
||||
if m.currentTab == tabMonitors {
|
||||
m.sortColumn = (m.sortColumn - 1 + sortMax) % sortMax
|
||||
m.sortAsc = false
|
||||
m.refreshLive()
|
||||
}
|
||||
case "r":
|
||||
if m.currentTab == tabMonitors {
|
||||
m.sortAsc = !m.sortAsc
|
||||
m.refreshLive()
|
||||
}
|
||||
case "tab":
|
||||
m.switchTab(m.currentTab + 1)
|
||||
case "pgup", "pgdown":
|
||||
if m.state == stateLogs {
|
||||
m.logViewport, cmd = m.logViewport.Update(msg)
|
||||
return m, cmd
|
||||
case "left":
|
||||
if m.currentTab == tabSettings {
|
||||
m.switchSettingsSection(m.settingsSection - 1)
|
||||
}
|
||||
case "right":
|
||||
if m.currentTab == tabSettings {
|
||||
m.switchSettingsSection(m.settingsSection + 1)
|
||||
}
|
||||
case "l":
|
||||
switch m.currentTab {
|
||||
case tabSettings:
|
||||
m.switchSettingsSection(m.settingsSection + 1)
|
||||
case tabMonitors:
|
||||
if m.focusedPanel == panelLogs {
|
||||
m.logsOpen = false
|
||||
m.focusedPanel = panelMonitors
|
||||
} else {
|
||||
m.logsOpen = true
|
||||
m.focusedPanel = panelLogs
|
||||
}
|
||||
m.recalcLayout()
|
||||
}
|
||||
case "up", "k":
|
||||
if m.state == stateLogs {
|
||||
m.logViewport.ScrollUp(1)
|
||||
} else if m.cursor > 0 {
|
||||
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||
m.scrollLogs(-1)
|
||||
return m, nil
|
||||
}
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
if m.cursor < m.tableOffset {
|
||||
m.tableOffset = m.cursor
|
||||
}
|
||||
m.syncSelectedID()
|
||||
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
}
|
||||
case "down", "j":
|
||||
if m.state == stateLogs {
|
||||
m.logViewport.ScrollDown(1)
|
||||
} else {
|
||||
max := m.currentListLen() - 1
|
||||
if m.cursor < max {
|
||||
m.cursor++
|
||||
if m.cursor >= m.tableOffset+m.maxTableRows {
|
||||
m.tableOffset++
|
||||
}
|
||||
m.syncSelectedID()
|
||||
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||
m.scrollLogs(1)
|
||||
return m, nil
|
||||
}
|
||||
max := m.currentListLen() - 1
|
||||
if m.cursor < max {
|
||||
m.cursor++
|
||||
if m.cursor >= m.tableOffset+m.maxTableRows {
|
||||
m.tableOffset++
|
||||
}
|
||||
m.syncSelectedID()
|
||||
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
}
|
||||
case "n":
|
||||
return m.handleNewItem()
|
||||
case "e", "enter":
|
||||
case "enter":
|
||||
if m.currentTab == tabMonitors && m.focusedPanel == panelLogs {
|
||||
m.refreshLogContent()
|
||||
m.logViewport.GotoTop()
|
||||
m.state = stateLogs
|
||||
return m, nil
|
||||
}
|
||||
if m.currentTab == tabMonitors && len(m.sites) > 0 {
|
||||
m.state = stateDetail
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
return m.handleEditItem()
|
||||
case "e":
|
||||
return m.handleEditItem()
|
||||
case "t":
|
||||
if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||
if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
|
||||
a := m.alerts[m.cursor]
|
||||
return m, m.testAlertCmd(a.ID, a.Name)
|
||||
}
|
||||
case " ":
|
||||
if m.currentTab == 0 && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
|
||||
if m.currentTab == tabMonitors && len(m.sites) > 0 && m.sites[m.cursor].Type == "group" {
|
||||
gid := m.sites[m.cursor].ID
|
||||
m.collapsed[gid] = !m.collapsed[gid]
|
||||
payload := collapsedJSON(m.collapsed)
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
m.refreshLive()
|
||||
return m, writeCmd("Save collapsed groups", func() error {
|
||||
return st.SetPreference(context.Background(), "collapsed_groups", payload)
|
||||
return st.SetPreference(ctx, "collapsed_groups", payload)
|
||||
})
|
||||
}
|
||||
case "p":
|
||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||
if m.currentTab == tabMonitors && len(m.sites) > 0 {
|
||||
id := m.sites[m.cursor].ID
|
||||
paused := m.engine.ToggleSitePause(id)
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
m.refreshLive()
|
||||
return m, writeCmd("Update pause state", func() error {
|
||||
return st.UpdateSitePaused(context.Background(), id, paused)
|
||||
return st.UpdateSitePaused(ctx, id, paused)
|
||||
})
|
||||
}
|
||||
case "i":
|
||||
if m.currentTab == 0 && len(m.sites) > 0 {
|
||||
m.state = stateDetail
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
} else if m.currentTab == 1 && len(m.alerts) > 0 {
|
||||
if m.currentTab == tabMonitors && len(m.sites) > 0 {
|
||||
m.detailOpen = !m.detailOpen
|
||||
m.recalcLayout()
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
open := m.detailOpen
|
||||
var cmd tea.Cmd
|
||||
if m.detailOpen {
|
||||
cmd = m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
saveCmd := writeCmd("Save detail preference", func() error {
|
||||
v := "false"
|
||||
if open {
|
||||
v = "true"
|
||||
}
|
||||
return st.SetPreference(ctx, "detail_open", v)
|
||||
})
|
||||
if cmd != nil {
|
||||
return m, tea.Batch(cmd, saveCmd)
|
||||
}
|
||||
return m, saveCmd
|
||||
} else if m.currentTab == tabSettings && m.settingsSection == sectionAlerts && len(m.alerts) > 0 {
|
||||
m.state = stateAlertDetail
|
||||
}
|
||||
case "esc":
|
||||
if m.currentTab == tabMonitors {
|
||||
if m.focusedPanel != panelMonitors {
|
||||
m.focusedPanel = panelMonitors
|
||||
} else if m.detailOpen {
|
||||
m.detailOpen = false
|
||||
m.recalcLayout()
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
return m, writeCmd("Save detail preference", func() error {
|
||||
return st.SetPreference(ctx, "detail_open", "false")
|
||||
})
|
||||
}
|
||||
}
|
||||
case "h":
|
||||
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||
site := m.sites[m.cursor]
|
||||
m.historySiteName = site.Name
|
||||
m.historySiteID = site.ID
|
||||
m.historyChanges = nil
|
||||
m.historyViewport = viewport.New(
|
||||
m.termWidth-chromePadH,
|
||||
m.termHeight-10,
|
||||
)
|
||||
m.historyViewport.SetContent("\n Loading state history...")
|
||||
m.state = stateHistory
|
||||
return m, m.loadHistoryCmd(site.ID)
|
||||
}
|
||||
case "s":
|
||||
if m.detailOpen && m.currentTab == tabMonitors && m.cursor < len(m.sites) {
|
||||
return m, m.openSLAView(m.sites[m.cursor])
|
||||
}
|
||||
case "x":
|
||||
if m.currentTab == 4 && len(m.maintenanceWindows) > 0 {
|
||||
if m.currentTab == tabMaint && len(m.maintenanceWindows) > 0 {
|
||||
mw := m.maintenanceWindows[m.cursor]
|
||||
now := time.Now()
|
||||
isActive := !mw.StartTime.After(now) && (mw.EndTime.IsZero() || mw.EndTime.After(now))
|
||||
if isActive {
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
id := mw.ID
|
||||
m.refreshLive()
|
||||
return m, writeCmd("End maintenance", func() error {
|
||||
return st.EndMaintenanceWindow(context.Background(), id)
|
||||
return st.EndMaintenanceWindow(ctx, id)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -623,9 +776,10 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.theme = themes[m.themeIndex]
|
||||
m.st = newStyles(m.theme)
|
||||
st := m.store
|
||||
ctx := m.ctx
|
||||
name := m.theme.Name
|
||||
return m, writeCmd("Save theme", func() error {
|
||||
return st.SetPreference(context.Background(), "theme", name)
|
||||
return st.SetPreference(ctx, "theme", name)
|
||||
})
|
||||
case "d":
|
||||
return m.handleDeleteItem()
|
||||
@@ -637,19 +791,22 @@ func (m *Model) handleNewItem() (tea.Model, tea.Cmd) {
|
||||
m.editID = 0
|
||||
m.editToken = ""
|
||||
switch m.currentTab {
|
||||
case 0:
|
||||
case tabMonitors:
|
||||
m.state = stateFormSite
|
||||
return m, m.initSiteHuhForm()
|
||||
case 1:
|
||||
m.state = stateFormAlert
|
||||
return m, m.initAlertHuhForm()
|
||||
case 4:
|
||||
case tabMaint:
|
||||
m.state = stateFormMaint
|
||||
return m, m.initMaintHuhForm()
|
||||
case 5:
|
||||
if m.isAdmin {
|
||||
m.state = stateFormUser
|
||||
return m, m.initUserHuhForm()
|
||||
case tabSettings:
|
||||
switch m.settingsSection {
|
||||
case sectionAlerts:
|
||||
m.state = stateFormAlert
|
||||
return m, m.initAlertHuhForm()
|
||||
case sectionUsers:
|
||||
if m.isAdmin {
|
||||
m.state = stateFormUser
|
||||
return m, m.initUserHuhForm()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
@@ -657,24 +814,27 @@ func (m *Model) handleNewItem() (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
|
||||
switch m.currentTab {
|
||||
case 0:
|
||||
case tabMonitors:
|
||||
if len(m.sites) > 0 {
|
||||
m.editID = m.sites[m.cursor].ID
|
||||
m.editToken = m.sites[m.cursor].Token
|
||||
m.state = stateFormSite
|
||||
return m, m.initSiteHuhForm()
|
||||
}
|
||||
case 1:
|
||||
if len(m.alerts) > 0 {
|
||||
m.editID = m.alerts[m.cursor].ID
|
||||
m.state = stateFormAlert
|
||||
return m, m.initAlertHuhForm()
|
||||
}
|
||||
case 5:
|
||||
if m.isAdmin && len(m.users) > 0 {
|
||||
m.editID = m.users[m.cursor].ID
|
||||
m.state = stateFormUser
|
||||
return m, m.initUserHuhForm()
|
||||
case tabSettings:
|
||||
switch m.settingsSection {
|
||||
case sectionAlerts:
|
||||
if len(m.alerts) > 0 {
|
||||
m.editID = m.alerts[m.cursor].ID
|
||||
m.state = stateFormAlert
|
||||
return m, m.initAlertHuhForm()
|
||||
}
|
||||
case sectionUsers:
|
||||
if m.isAdmin && len(m.users) > 0 {
|
||||
m.editID = m.users[m.cursor].ID
|
||||
m.state = stateFormUser
|
||||
return m, m.initUserHuhForm()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
@@ -682,43 +842,43 @@ func (m *Model) handleEditItem() (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m *Model) handleDeleteItem() (tea.Model, tea.Cmd) {
|
||||
switch m.currentTab {
|
||||
case 0:
|
||||
case tabMonitors:
|
||||
if len(m.sites) > 0 {
|
||||
m.deleteID = m.sites[m.cursor].ID
|
||||
m.deleteName = m.sites[m.cursor].Name
|
||||
m.deleteTab = 0
|
||||
m.deleteTab = tabMonitors
|
||||
m.state = stateConfirmDelete
|
||||
}
|
||||
case 1:
|
||||
if len(m.alerts) > 0 {
|
||||
m.deleteID = m.alerts[m.cursor].ID
|
||||
m.deleteName = m.alerts[m.cursor].Name
|
||||
m.deleteTab = 1
|
||||
m.state = stateConfirmDelete
|
||||
}
|
||||
case 4:
|
||||
case tabMaint:
|
||||
if len(m.maintenanceWindows) > 0 {
|
||||
m.deleteID = m.maintenanceWindows[m.cursor].ID
|
||||
m.deleteName = m.maintenanceWindows[m.cursor].Title
|
||||
m.deleteTab = 4
|
||||
m.deleteTab = tabMaint
|
||||
m.state = stateConfirmDelete
|
||||
}
|
||||
case 5:
|
||||
if m.isAdmin && len(m.users) > 0 {
|
||||
m.deleteID = m.users[m.cursor].ID
|
||||
m.deleteName = m.users[m.cursor].Username
|
||||
m.deleteTab = 5
|
||||
m.state = stateConfirmDelete
|
||||
case tabSettings:
|
||||
switch m.settingsSection {
|
||||
case sectionAlerts:
|
||||
if len(m.alerts) > 0 {
|
||||
m.deleteID = m.alerts[m.cursor].ID
|
||||
m.deleteName = m.alerts[m.cursor].Name
|
||||
m.deleteTab = tabSettings
|
||||
m.state = stateConfirmDelete
|
||||
}
|
||||
case sectionUsers:
|
||||
if m.isAdmin && len(m.users) > 0 {
|
||||
m.deleteID = m.users[m.cursor].ID
|
||||
m.deleteName = m.users[m.cursor].Username
|
||||
m.deleteTab = tabSettings
|
||||
m.state = stateConfirmDelete
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
tabCount := 5
|
||||
if m.isAdmin {
|
||||
tabCount = 6
|
||||
}
|
||||
tabCount := tabSettings + 1
|
||||
for i := 0; i < tabCount; i++ {
|
||||
if m.zones.Get(fmt.Sprintf("tab-%d", i)).InBounds(msg) {
|
||||
m.switchTab(i)
|
||||
@@ -726,6 +886,58 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentTab == tabSettings {
|
||||
maxSec := 1
|
||||
if m.isAdmin {
|
||||
maxSec = 2
|
||||
}
|
||||
for i := 0; i <= maxSec; i++ {
|
||||
if m.zones.Get(fmt.Sprintf("section-%d", i)).InBounds(msg) {
|
||||
m.switchSettingsSection(i)
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.currentTab == tabMonitors {
|
||||
sortZones := []struct {
|
||||
zone string
|
||||
col int
|
||||
}{
|
||||
{"sort-status", sortStatus},
|
||||
{"sort-name", sortName},
|
||||
{"sort-latency", sortLatency},
|
||||
}
|
||||
for _, sz := range sortZones {
|
||||
if m.zones.Get(sz.zone).InBounds(msg) {
|
||||
if m.sortColumn == sz.col {
|
||||
m.sortAsc = !m.sortAsc
|
||||
} else {
|
||||
m.sortColumn = sz.col
|
||||
m.sortAsc = false
|
||||
}
|
||||
m.refreshLive()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
if m.zones.Get("panel-monitors").InBounds(msg) {
|
||||
m.focusedPanel = panelMonitors
|
||||
} else if m.zones.Get("panel-logs").InBounds(msg) {
|
||||
if m.focusedPanel == panelLogs {
|
||||
m.logsOpen = false
|
||||
m.focusedPanel = panelMonitors
|
||||
m.recalcLayout()
|
||||
} else {
|
||||
m.focusedPanel = panelLogs
|
||||
}
|
||||
return m, nil
|
||||
} else if m.detailOpen && m.zones.Get("panel-detail").InBounds(msg) {
|
||||
m.focusedPanel = panelDetail
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
prefix, listLen := m.currentZonePrefix()
|
||||
end := m.tableOffset + m.maxTableRows
|
||||
if end > listLen {
|
||||
@@ -735,6 +947,9 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
if m.zones.Get(fmt.Sprintf("%s-%d", prefix, i)).InBounds(msg) {
|
||||
m.cursor = i
|
||||
m.syncSelectedID()
|
||||
if m.detailOpen && m.currentTab == tabMonitors {
|
||||
return m, m.loadDetailCmd(m.sites[m.cursor].ID)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
@@ -743,24 +958,14 @@ func (m *Model) handleClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
|
||||
func (m *Model) switchTab(idx int) {
|
||||
maxTabs := 4
|
||||
if m.isAdmin {
|
||||
maxTabs = 5
|
||||
}
|
||||
maxTabs := tabSettings
|
||||
if idx > maxTabs {
|
||||
idx = 0
|
||||
}
|
||||
m.currentTab = idx
|
||||
m.cursor = 0
|
||||
m.tableOffset = 0
|
||||
switch idx {
|
||||
case 2:
|
||||
m.state = stateLogs
|
||||
case 5:
|
||||
m.state = stateUsers
|
||||
default:
|
||||
m.state = stateDashboard
|
||||
}
|
||||
m.state = stateDashboard
|
||||
}
|
||||
|
||||
func (m *Model) adjustCursor(_ int) {
|
||||
@@ -791,30 +996,36 @@ func (m *Model) submitForm() tea.Cmd {
|
||||
|
||||
func (m Model) currentListLen() int {
|
||||
switch m.currentTab {
|
||||
case 1:
|
||||
return len(m.alerts)
|
||||
case 3:
|
||||
return len(m.nodes)
|
||||
case 4:
|
||||
return len(m.maintenanceWindows)
|
||||
case 5:
|
||||
return len(m.users)
|
||||
default:
|
||||
case tabMonitors:
|
||||
return len(m.sites)
|
||||
case tabMaint:
|
||||
return len(m.maintenanceWindows)
|
||||
case tabSettings:
|
||||
switch m.settingsSection {
|
||||
case sectionAlerts:
|
||||
return len(m.alerts)
|
||||
case sectionNodes:
|
||||
return len(m.nodes)
|
||||
case sectionUsers:
|
||||
return len(m.users)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (m Model) currentZonePrefix() (string, int) {
|
||||
switch m.currentTab {
|
||||
case 0:
|
||||
case tabMonitors:
|
||||
return "site", len(m.sites)
|
||||
case 1:
|
||||
return "alert", len(m.alerts)
|
||||
case 4:
|
||||
case tabMaint:
|
||||
return "maint", len(m.maintenanceWindows)
|
||||
case 5:
|
||||
return "user", len(m.users)
|
||||
default:
|
||||
return "site", 0
|
||||
case tabSettings:
|
||||
switch m.settingsSection {
|
||||
case sectionAlerts:
|
||||
return "alert", len(m.alerts)
|
||||
case sectionUsers:
|
||||
return "user", len(m.users)
|
||||
}
|
||||
}
|
||||
return "site", 0
|
||||
}
|
||||
|
||||
@@ -300,7 +300,7 @@ func TestWriteDoneMsg_LogsErrorAndReloads(t *testing.T) {
|
||||
mm := updated.(Model)
|
||||
found := false
|
||||
for _, line := range mm.engine.GetLogs() {
|
||||
if strings.Contains(line, "Delete site failed: boom") {
|
||||
if strings.Contains(line.Message, "Delete site failed: boom") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
)
|
||||
|
||||
func (m Model) uptimeTimeline(days []monitor.DayReport, width int) string {
|
||||
if len(days) == 0 {
|
||||
return m.st.subtleStyle.Render("No uptime data")
|
||||
}
|
||||
|
||||
maxDays := width - 10
|
||||
if maxDays < 10 {
|
||||
maxDays = 10
|
||||
}
|
||||
|
||||
display := days
|
||||
if len(display) > maxDays {
|
||||
display = display[len(display)-maxDays:]
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
for _, d := range display {
|
||||
ch := "█"
|
||||
switch {
|
||||
case d.UptimePct >= uptimeGoodPct:
|
||||
sb.WriteString(m.st.specialStyle.Render(ch))
|
||||
case d.UptimePct >= uptimeWarnPct:
|
||||
sb.WriteString(m.st.warnStyle.Render(ch))
|
||||
case d.UptimePct > 0:
|
||||
sb.WriteString(m.st.dangerStyle.Render(ch))
|
||||
default:
|
||||
sb.WriteString(m.st.subtleStyle.Render("░"))
|
||||
}
|
||||
}
|
||||
|
||||
pct := days[len(days)-1].UptimePct
|
||||
pctStyle := m.st.specialStyle
|
||||
if pct < uptimeGoodPct {
|
||||
pctStyle = m.st.dangerStyle
|
||||
} else if pct < uptimeExcellentPct {
|
||||
pctStyle = m.st.warnStyle
|
||||
}
|
||||
sb.WriteString(" " + pctStyle.Render(fmt.Sprintf("%.2f%%", pct)))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -92,6 +92,8 @@ func (m Model) View() string {
|
||||
return ""
|
||||
case stateDetail:
|
||||
return m.zones.Scan(m.viewDetailPanel())
|
||||
case stateLogs:
|
||||
return m.viewLogsFullscreen()
|
||||
case stateHistory:
|
||||
return m.viewHistoryPanel()
|
||||
case stateSLA:
|
||||
@@ -130,7 +132,7 @@ func (m Model) computeStats() dashboardStats {
|
||||
}
|
||||
}
|
||||
for _, n := range m.nodes {
|
||||
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > 5*time.Minute {
|
||||
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) > nodeStaleThreshold {
|
||||
s.offlineNodes++
|
||||
}
|
||||
}
|
||||
@@ -151,20 +153,52 @@ func (m Model) viewDashboard() string {
|
||||
|
||||
var content string
|
||||
switch m.currentTab {
|
||||
case 0:
|
||||
content = m.viewSitesTab()
|
||||
case 1:
|
||||
content = m.viewAlertsTab()
|
||||
case 2:
|
||||
content = m.viewLogsTab()
|
||||
case 3:
|
||||
content = m.viewNodesTab()
|
||||
case 4:
|
||||
content = m.viewMaintTab()
|
||||
case 5:
|
||||
if m.isAdmin {
|
||||
content = m.viewUsersTab()
|
||||
case tabMonitors:
|
||||
showSidebar := m.termWidth >= wideBreakpoint && m.logsOpen
|
||||
if showSidebar {
|
||||
availW := m.termWidth - chromePadH
|
||||
leftW := availW * 70 / 100
|
||||
rightW := availW - leftW
|
||||
m.contentWidth = leftW - 2
|
||||
monitors := m.viewSitesTab()
|
||||
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, leftW, m.focusedPanel == panelMonitors))
|
||||
sidebarContent := m.viewLogsSidebar(rightW-2, m.maxTableRows)
|
||||
logPanel := m.zones.Mark("panel-logs", m.titledPanel("Logs", sidebarContent, rightW, m.focusedPanel == panelLogs))
|
||||
top := lipgloss.JoinHorizontal(lipgloss.Top, monPanel, logPanel)
|
||||
if m.detailOpen {
|
||||
site := ""
|
||||
if m.cursor < len(m.sites) {
|
||||
site = m.sites[m.cursor].Name
|
||||
}
|
||||
detail := m.viewDetailInline(availW - 2)
|
||||
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
||||
content = top + "\n" + detailPanel
|
||||
} else {
|
||||
content = top
|
||||
}
|
||||
} else {
|
||||
m.contentWidth = m.termWidth - 2
|
||||
monitors := m.viewSitesTab()
|
||||
availW := m.termWidth - chromePadH
|
||||
monPanel := m.zones.Mark("panel-monitors", m.titledPanel("Monitors", monitors, availW, m.focusedPanel == panelMonitors))
|
||||
if m.detailOpen {
|
||||
site := ""
|
||||
if m.cursor < len(m.sites) {
|
||||
site = m.sites[m.cursor].Name
|
||||
}
|
||||
detail := m.viewDetailInline(availW - 2)
|
||||
detailPanel := m.zones.Mark("panel-detail", m.titledPanel(site, detail, availW, m.focusedPanel == panelDetail))
|
||||
content = monPanel + "\n" + detailPanel
|
||||
} else {
|
||||
content = monPanel
|
||||
}
|
||||
}
|
||||
case tabMaint:
|
||||
m.contentWidth = m.termWidth
|
||||
content = m.viewMaintTab()
|
||||
case tabSettings:
|
||||
m.contentWidth = m.termWidth
|
||||
content = m.viewSettingsTab()
|
||||
}
|
||||
|
||||
content = strings.TrimSpace(content)
|
||||
@@ -177,13 +211,19 @@ func (m Model) viewDashboard() string {
|
||||
availHeight = 5
|
||||
}
|
||||
|
||||
contentHeight := availHeight - lipgloss.Height(header) - lipgloss.Height(footer)
|
||||
divW := m.termWidth - chromePadH
|
||||
if divW < 40 {
|
||||
divW = 40
|
||||
}
|
||||
tabDivider := m.st.subtleStyle.Render(strings.Repeat("─", divW))
|
||||
|
||||
contentHeight := availHeight - lipgloss.Height(header) - 1 - lipgloss.Height(footer)
|
||||
if contentHeight < 1 {
|
||||
contentHeight = 1
|
||||
}
|
||||
paddedContent := lipgloss.NewStyle().Height(contentHeight).MaxHeight(contentHeight).Render(content)
|
||||
|
||||
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, paddedContent, footer))
|
||||
return outerPad.Render(lipgloss.JoinVertical(lipgloss.Top, header, tabDivider, paddedContent, footer))
|
||||
}
|
||||
|
||||
type tabEntry struct {
|
||||
@@ -193,15 +233,21 @@ type tabEntry struct {
|
||||
}
|
||||
|
||||
func (m Model) renderTabBar(stats dashboardStats) string {
|
||||
tabs := []tabEntry{
|
||||
{"Sites", stats.totalMonitors, stats.downCount + stats.lateCount},
|
||||
{"Alerts", len(m.alerts), 0},
|
||||
{"Logs", 0, 0},
|
||||
{"Nodes", len(m.nodes), stats.offlineNodes},
|
||||
{"Maint", len(m.maintenanceWindows), stats.activeMaint},
|
||||
settingsCount := len(m.alerts) + len(m.nodes)
|
||||
settingsWarn := stats.offlineNodes
|
||||
for _, a := range m.alerts {
|
||||
h := m.engine.GetAlertHealth(a.ID)
|
||||
if !h.LastSendOK && !h.LastSendAt.IsZero() {
|
||||
settingsWarn++
|
||||
}
|
||||
}
|
||||
if m.isAdmin {
|
||||
tabs = append(tabs, tabEntry{"Users", len(m.users), 0})
|
||||
settingsCount += len(m.users)
|
||||
}
|
||||
tabs := []tabEntry{
|
||||
{"Monitors", stats.totalMonitors, stats.downCount + stats.lateCount},
|
||||
{"Maint", len(m.maintenanceWindows), stats.activeMaint},
|
||||
{"Settings", settingsCount, settingsWarn},
|
||||
}
|
||||
|
||||
countStyle := lipgloss.NewStyle().Foreground(m.theme.Muted)
|
||||
@@ -250,7 +296,7 @@ func (m Model) renderFooter(stats dashboardStats) string {
|
||||
if len(m.nodes) > 0 {
|
||||
online := 0
|
||||
for _, n := range m.nodes {
|
||||
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < 60*time.Second {
|
||||
if !n.LastSeen.IsZero() && time.Since(n.LastSeen) < nodeOnlineThreshold {
|
||||
online++
|
||||
}
|
||||
}
|
||||
@@ -264,24 +310,38 @@ func (m Model) renderFooter(stats dashboardStats) string {
|
||||
|
||||
var keys string
|
||||
switch m.currentTab {
|
||||
case 0:
|
||||
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [Space]Collapse [T]Theme [Tab]Switch [q]Quit"
|
||||
case 1:
|
||||
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [T]Theme [Tab]Switch [q]Quit"
|
||||
case 2:
|
||||
keys = "[↑/↓]Scroll [PgUp/PgDn]Page [f]Filter [T]Theme [Tab]Switch [q]Quit"
|
||||
case 4:
|
||||
case tabMonitors:
|
||||
if m.focusedPanel == panelLogs {
|
||||
keys = "[↑/↓]Scroll [Enter]Expand [l/Esc]Back [T]Theme [q]Quit"
|
||||
} else if m.detailOpen {
|
||||
keys = "[i]Close [Enter]Expand [h]History [s]SLA [e]Edit [l]Logs [↑/↓]Select [T]Theme [q]Quit"
|
||||
} else {
|
||||
keys = "[/]Filter [i]Info [Enter]Detail [</>]Sort [r]Reverse [n]New [e]Edit [d]Del [l]Logs [T]Theme [Tab]Switch [q]Quit"
|
||||
}
|
||||
case tabMaint:
|
||||
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
|
||||
case 5:
|
||||
keys = "[n]Add [d]Revoke [T]Theme [Tab]Switch [q]Quit"
|
||||
case tabSettings:
|
||||
switch m.settingsSection {
|
||||
case sectionAlerts:
|
||||
keys = "[n]New [e]Edit [i]Info [d]Del [t]Test [←/→]Section [T]Theme [Tab]Switch [q]Quit"
|
||||
case sectionUsers:
|
||||
keys = "[n]Add [d]Revoke [←/→]Section [T]Theme [Tab]Switch [q]Quit"
|
||||
default:
|
||||
keys = "[←/→]Section [T]Theme [Tab]Switch [q]Quit"
|
||||
}
|
||||
default:
|
||||
keys = "[T]Theme [Tab]Switch [q]Quit"
|
||||
}
|
||||
|
||||
ver := m.st.subtleStyle.Render("v" + m.version)
|
||||
footer := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
||||
if m.filterText != "" && m.currentTab == 0 {
|
||||
footer = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
||||
line := statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
||||
if m.filterText != "" && m.currentTab == tabMonitors {
|
||||
line = m.st.subtleStyle.Render(fmt.Sprintf("filter: %s", m.filterText)) + " " + statusLine + " " + m.st.subtleStyle.Render(keys) + " " + ver
|
||||
}
|
||||
return footer
|
||||
|
||||
divW := m.termWidth - chromePadH
|
||||
if divW < 40 {
|
||||
divW = 40
|
||||
}
|
||||
return m.st.subtleStyle.Render(strings.Repeat("─", divW)) + "\n" + line
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
@@ -20,45 +19,120 @@ func (m Model) viewDetailPanel() string {
|
||||
hist, _ := m.engine.GetHistory(site.ID)
|
||||
|
||||
var b strings.Builder
|
||||
totalW := m.termWidth - chromePadH
|
||||
|
||||
var breadcrumb string
|
||||
if site.ParentID > 0 {
|
||||
for _, s := range m.sites {
|
||||
if s.ID == site.ParentID {
|
||||
breadcrumb = m.st.subtleStyle.Render(" Sites > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name)
|
||||
breadcrumb = m.st.subtleStyle.Render(" Monitors > "+s.Name+" > ") + m.st.titleStyle.Render(site.Name)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if breadcrumb == "" {
|
||||
breadcrumb = m.st.subtleStyle.Render(" Sites > ") + m.st.titleStyle.Render(site.Name)
|
||||
breadcrumb = m.st.subtleStyle.Render(" Monitors > ") + m.st.titleStyle.Render(site.Name)
|
||||
}
|
||||
b.WriteString(breadcrumb + "\n")
|
||||
b.WriteString(m.divider() + "\n")
|
||||
|
||||
row := func(label, value string) {
|
||||
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render(label), value)
|
||||
// Two-column layout for key info
|
||||
colW := (totalW - 4) / 2
|
||||
if colW < 30 {
|
||||
colW = 30
|
||||
}
|
||||
|
||||
section := func(label string) {
|
||||
b.WriteString("\n" + m.st.subtleStyle.Render(" "+label) + "\n")
|
||||
row := func(label, value string) string {
|
||||
return fmt.Sprintf(" %-16s %s", m.st.subtleStyle.Render(label), value)
|
||||
}
|
||||
|
||||
row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID)))
|
||||
divW := totalW - 4
|
||||
if divW < 20 {
|
||||
divW = 20
|
||||
}
|
||||
sectionDiv := m.st.subtleStyle.Render(strings.Repeat("─", divW))
|
||||
sectionHead := func(title string) string {
|
||||
return m.st.titleStyle.Render(" "+title) + " " + m.st.subtleStyle.Render(strings.Repeat("─", divW-len(title)-3))
|
||||
}
|
||||
|
||||
// Left column: endpoint details
|
||||
var left []string
|
||||
left = append(left, m.st.titleStyle.Render(" ENDPOINT"))
|
||||
left = append(left, row("Type", site.Type))
|
||||
if site.URL != "" {
|
||||
left = append(left, row("URL", limitStr(site.URL, colW-19)))
|
||||
}
|
||||
if site.Hostname != "" {
|
||||
left = append(left, row("Host", site.Hostname))
|
||||
}
|
||||
if site.Port > 0 {
|
||||
left = append(left, row("Port", strconv.Itoa(site.Port)))
|
||||
}
|
||||
left = append(left, row("Interval", fmt.Sprintf("%ds", site.Interval)))
|
||||
if site.MaxRetries > 0 {
|
||||
left = append(left, row("Retries", m.fmtRetries(site)))
|
||||
}
|
||||
if site.Regions != "" {
|
||||
left = append(left, row("Regions", site.Regions))
|
||||
}
|
||||
if site.Description != "" {
|
||||
left = append(left, row("Description", limitStr(site.Description, colW-19)))
|
||||
}
|
||||
|
||||
// Right column: status + timing + HTTP
|
||||
var right []string
|
||||
right = append(right, m.st.titleStyle.Render(" STATUS"))
|
||||
right = append(right, row("Status", m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))))
|
||||
right = append(right, row("Latency", m.fmtLatency(site.Latency)))
|
||||
right = append(right, row("Uptime", m.fmtUptime(hist.Statuses)))
|
||||
if !site.StatusChangedAt.IsZero() {
|
||||
dur := time.Since(site.StatusChangedAt)
|
||||
right = append(right, row("State Since", fmtDuration(dur)+" ago"))
|
||||
}
|
||||
if !site.LastCheck.IsZero() {
|
||||
right = append(right, row("Last Check", m.fmtTimeAgo(site.LastCheck)))
|
||||
}
|
||||
if !site.LastSuccessAt.IsZero() {
|
||||
right = append(right, row("Last Success", m.fmtTimeAgo(site.LastSuccessAt)))
|
||||
}
|
||||
|
||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp || site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||
errWidth := m.termWidth - chromePadH - 19
|
||||
if errWidth < 30 {
|
||||
errWidth = 30
|
||||
errW := colW - 19
|
||||
if errW < 20 {
|
||||
errW = 20
|
||||
}
|
||||
wrapped := lipgloss.NewStyle().Width(errWidth).Render(site.LastError)
|
||||
row("Error", m.st.dangerStyle.Render(wrapped))
|
||||
right = append(right, row("Error", m.st.dangerStyle.Render(limitStr(site.LastError, errW))))
|
||||
}
|
||||
|
||||
if site.Type == "http" && site.StatusCode > 0 {
|
||||
row("HTTP Code", strconv.Itoa(site.StatusCode))
|
||||
if site.Type == "http" {
|
||||
if site.StatusCode > 0 {
|
||||
right = append(right, row("HTTP Code", strconv.Itoa(site.StatusCode)))
|
||||
}
|
||||
codes := site.AcceptedCodes
|
||||
if codes == "" {
|
||||
codes = "200-299"
|
||||
}
|
||||
right = append(right, row("Codes", codes))
|
||||
right = append(right, row("SSL", m.fmtSSL(site)))
|
||||
if site.Method != "" && site.Method != "GET" {
|
||||
right = append(right, row("Method", site.Method))
|
||||
}
|
||||
}
|
||||
|
||||
// Pad shorter column
|
||||
for len(left) < len(right) {
|
||||
left = append(left, "")
|
||||
}
|
||||
for len(right) < len(left) {
|
||||
right = append(right, "")
|
||||
}
|
||||
|
||||
leftCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(left, "\n"))
|
||||
rightCol := lipgloss.NewStyle().Width(colW).Render(strings.Join(right, "\n"))
|
||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, leftCol, rightCol) + "\n")
|
||||
b.WriteString("\n" + sectionDiv + "\n")
|
||||
|
||||
// Connection chain (full width, only on errors)
|
||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp) && site.LastError != "" {
|
||||
chain := connectionChain(site.LastError, site.Type, site.StatusCode, strings.HasPrefix(site.URL, "https"))
|
||||
if len(chain) > 0 {
|
||||
@@ -87,81 +161,22 @@ func (m Model) viewDetailPanel() string {
|
||||
}
|
||||
}
|
||||
|
||||
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)")
|
||||
}
|
||||
|
||||
// Maintenance
|
||||
if m.isMonitorInMaintenance(site.ID) {
|
||||
for _, mw := range m.maintenanceWindows {
|
||||
if mw.Type == "maintenance" && (mw.MonitorID == 0 || mw.MonitorID == site.ID || mw.MonitorID == site.ParentID) {
|
||||
row("Maintenance", m.st.maintStyle.Render(mw.Title))
|
||||
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Maintenance"), m.st.maintStyle.Render(mw.Title))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section("ENDPOINT")
|
||||
row("Type", site.Type)
|
||||
// Push token
|
||||
if site.Type == "push" && site.Token != "" {
|
||||
row("Token", site.Token)
|
||||
row("Push", "curl -X POST -H 'Authorization: Bearer "+site.Token+"' <host>/api/push")
|
||||
}
|
||||
if site.URL != "" {
|
||||
row("URL", site.URL)
|
||||
}
|
||||
if site.Hostname != "" {
|
||||
row("Host", site.Hostname)
|
||||
}
|
||||
if site.Port > 0 {
|
||||
row("Port", strconv.Itoa(site.Port))
|
||||
}
|
||||
|
||||
section("TIMING")
|
||||
row("Interval", fmt.Sprintf("%ds", site.Interval))
|
||||
if site.Timeout > 0 {
|
||||
row("Timeout", fmt.Sprintf("%ds", site.Timeout))
|
||||
}
|
||||
row("Latency", m.fmtLatency(site.Latency))
|
||||
row("Uptime", m.fmtUptime(hist.Statuses))
|
||||
if !site.LastCheck.IsZero() {
|
||||
row("Last Check", m.fmtTimeAgo(site.LastCheck))
|
||||
}
|
||||
|
||||
if site.Type == "http" {
|
||||
section("HTTP")
|
||||
if site.Method != "" && site.Method != "GET" {
|
||||
row("Method", site.Method)
|
||||
}
|
||||
codes := site.AcceptedCodes
|
||||
if codes == "" {
|
||||
codes = "200-299"
|
||||
}
|
||||
row("Codes", codes)
|
||||
row("SSL", m.fmtSSL(site))
|
||||
if site.IgnoreTLS {
|
||||
row("TLS Verify", m.st.dangerStyle.Render("disabled"))
|
||||
}
|
||||
}
|
||||
|
||||
if site.MaxRetries > 0 || site.Regions != "" || site.Description != "" {
|
||||
section("CONFIG")
|
||||
if site.MaxRetries > 0 {
|
||||
row("Retries", m.fmtRetries(site))
|
||||
}
|
||||
if site.Regions != "" {
|
||||
row("Regions", site.Regions)
|
||||
}
|
||||
if site.Description != "" {
|
||||
row("Description", site.Description)
|
||||
}
|
||||
fmt.Fprintf(&b, " %-16s %s\n", m.st.subtleStyle.Render("Token"), site.Token)
|
||||
}
|
||||
|
||||
// Probe results
|
||||
probeResults := m.engine.GetProbeResults(site.ID)
|
||||
if len(probeResults) > 0 {
|
||||
nodeIDs := make([]string, 0, len(probeResults))
|
||||
@@ -169,7 +184,7 @@ func (m Model) viewDetailPanel() string {
|
||||
nodeIDs = append(nodeIDs, id)
|
||||
}
|
||||
sort.Strings(nodeIDs)
|
||||
b.WriteString("\n" + m.st.subtleStyle.Render(" PROBE RESULTS") + "\n")
|
||||
b.WriteString("\n" + sectionHead("PROBE RESULTS") + "\n")
|
||||
for _, nodeID := range nodeIDs {
|
||||
result := probeResults[nodeID]
|
||||
status := m.st.specialStyle.Render("UP")
|
||||
@@ -186,36 +201,30 @@ func (m Model) viewDetailPanel() string {
|
||||
}
|
||||
}
|
||||
|
||||
// Loaded on panel-enter (loadDetailCmd) and cached, so View does no DB IO.
|
||||
var stateChanges []models.StateChange
|
||||
if m.detailChangesSiteID == site.ID {
|
||||
stateChanges = m.detailChanges
|
||||
// Bottom two-column: graphs left, state changes right
|
||||
graphW := (totalW - 4) * 70 / 100
|
||||
changeW := totalW - 4 - graphW
|
||||
if graphW < 30 {
|
||||
graphW = 30
|
||||
}
|
||||
if len(stateChanges) > 0 {
|
||||
b.WriteString("\n" + m.st.subtleStyle.Render(" STATE CHANGES") + "\n")
|
||||
for i, sc := range stateChanges {
|
||||
ago := fmtDuration(time.Since(sc.ChangedAt))
|
||||
arrow := m.st.subtleStyle.Render(sc.FromStatus) + " → "
|
||||
if sc.ToStatus == string(models.StatusUp) {
|
||||
arrow += m.st.specialStyle.Render(sc.ToStatus)
|
||||
} else {
|
||||
arrow += m.st.dangerStyle.Render(sc.ToStatus)
|
||||
}
|
||||
line := fmt.Sprintf(" %s %s", arrow, m.st.subtleStyle.Render(ago+" ago"))
|
||||
if dur := computeOutageDuration(stateChanges, i); dur > 0 {
|
||||
line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur))
|
||||
}
|
||||
if sc.ErrorReason != "" && sc.ToStatus != string(models.StatusUp) {
|
||||
line += " " + m.st.dangerStyle.Render(sc.ErrorReason)
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
b.WriteString(" " + m.st.subtleStyle.Render("[h] History") + "\n")
|
||||
if changeW < 20 {
|
||||
changeW = 20
|
||||
}
|
||||
bottomColW := graphW
|
||||
|
||||
// Left: latency + histogram
|
||||
var graphLines []string
|
||||
sectionLabel := func(title string) string {
|
||||
return m.st.titleStyle.Render(" " + title)
|
||||
}
|
||||
|
||||
b.WriteString(m.divider() + "\n")
|
||||
graphLines = append(graphLines, sectionLabel("LATENCY"))
|
||||
if site.Type == "push" {
|
||||
b.WriteString(" " + m.zones.Mark("spark-heartbeat", m.heartbeatSparkline(hist.Statuses, detailSparkWidth, "")))
|
||||
sparkW := bottomColW - 4
|
||||
if sparkW > detailSparkWidth {
|
||||
sparkW = detailSparkWidth
|
||||
}
|
||||
graphLines = append(graphLines, " "+m.heartbeatSparkline(hist.Statuses, sparkW, nil))
|
||||
if len(hist.Statuses) > 0 {
|
||||
up := 0
|
||||
for _, s := range hist.Statuses {
|
||||
@@ -223,12 +232,15 @@ func (m Model) viewDetailPanel() string {
|
||||
up++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "\n %s %d/%d checks up",
|
||||
m.st.subtleStyle.Render("Heartbeats"),
|
||||
up, len(hist.Statuses))
|
||||
graphLines = append(graphLines, fmt.Sprintf(" %s %d/%d checks up",
|
||||
m.st.subtleStyle.Render("Heartbeats"), up, len(hist.Statuses)))
|
||||
}
|
||||
} else {
|
||||
b.WriteString(" " + m.zones.Mark("spark-latency", m.latencySparkline(hist.Latencies, hist.Statuses, detailSparkWidth, "")))
|
||||
sparkW := bottomColW - 4
|
||||
if sparkW > detailSparkWidth {
|
||||
sparkW = detailSparkWidth
|
||||
}
|
||||
graphLines = append(graphLines, " "+m.latencySparkline(hist.Latencies, hist.Statuses, sparkW, nil))
|
||||
var minL, maxL, total time.Duration
|
||||
count := 0
|
||||
for i, l := range hist.Latencies {
|
||||
@@ -247,60 +259,76 @@ func (m Model) viewDetailPanel() string {
|
||||
}
|
||||
if count > 0 {
|
||||
avg := total / time.Duration(count)
|
||||
fmt.Fprintf(&b, "\n %s %dms %s %dms %s %dms",
|
||||
graphLines = append(graphLines, fmt.Sprintf(" %s %dms %s %dms %s %dms",
|
||||
m.st.subtleStyle.Render("Min"), minL.Milliseconds(),
|
||||
m.st.subtleStyle.Render("Avg"), avg.Milliseconds(),
|
||||
m.st.subtleStyle.Render("Max"), maxL.Milliseconds())
|
||||
m.st.subtleStyle.Render("Max"), maxL.Milliseconds()))
|
||||
}
|
||||
}
|
||||
|
||||
if m.sparkTooltipIdx >= 0 {
|
||||
b.WriteString("\n" + m.renderSparkTooltip(site, hist, detailSparkWidth))
|
||||
if site.Type != "push" && len(hist.Latencies) > 5 {
|
||||
graphLines = append(graphLines, "")
|
||||
graphLines = append(graphLines, sectionLabel("DISTRIBUTION"))
|
||||
graphLines = append(graphLines, m.latencyHistogram(hist.Latencies, hist.Statuses, bottomColW))
|
||||
}
|
||||
|
||||
// Right: state changes
|
||||
var changeLines []string
|
||||
var stateChanges []models.StateChange
|
||||
if m.detailChangesSiteID == site.ID {
|
||||
stateChanges = m.detailChanges
|
||||
}
|
||||
changeLines = append(changeLines, sectionLabel("STATE CHANGES"))
|
||||
if len(stateChanges) > 0 {
|
||||
for i, sc := range stateChanges {
|
||||
from := m.fmtStatusWord(string(sc.FromStatus))
|
||||
to := m.fmtStatusWord(string(sc.ToStatus))
|
||||
ago := fmtDuration(time.Since(sc.ChangedAt))
|
||||
line := fmt.Sprintf(" %s → %s %s ago", from, to, ago)
|
||||
if sc.ToStatus == "UP" {
|
||||
dur := computeOutageDuration(stateChanges, i)
|
||||
if dur > 0 {
|
||||
line += " " + m.st.warnStyle.Render("outage "+fmtDuration(dur))
|
||||
}
|
||||
}
|
||||
if sc.ErrorReason != "" {
|
||||
line += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, changeW-30))
|
||||
}
|
||||
changeLines = append(changeLines, line)
|
||||
}
|
||||
} else {
|
||||
changeLines = append(changeLines, m.st.subtleStyle.Render(" No state changes"))
|
||||
}
|
||||
|
||||
// Pad and join
|
||||
for len(graphLines) < len(changeLines) {
|
||||
graphLines = append(graphLines, "")
|
||||
}
|
||||
for len(changeLines) < len(graphLines) {
|
||||
changeLines = append(changeLines, "")
|
||||
}
|
||||
|
||||
graphCol := lipgloss.NewStyle().Width(graphW).Render(strings.Join(graphLines, "\n"))
|
||||
changeCol := lipgloss.NewStyle().Width(changeW).Render(strings.Join(changeLines, "\n"))
|
||||
b.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, graphCol, changeCol) + "\n")
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.divider() + "\n")
|
||||
b.WriteString(m.st.subtleStyle.Render(" [q/Esc] Back [e] Edit [h] History [s] SLA [click] Inspect"))
|
||||
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) renderSparkTooltip(site models.Site, hist monitor.SiteHistory, sparkWidth int) string {
|
||||
idx := m.sparkTooltipIdx
|
||||
|
||||
var dataLen int
|
||||
if site.Type == "push" {
|
||||
dataLen = len(hist.Statuses)
|
||||
} else {
|
||||
dataLen = len(hist.Latencies)
|
||||
}
|
||||
if idx < 0 || idx >= dataLen {
|
||||
return ""
|
||||
}
|
||||
|
||||
var parts []string
|
||||
|
||||
checksAgo := dataLen - 1 - idx
|
||||
approxSecs := checksAgo * site.Interval
|
||||
if approxSecs == 0 {
|
||||
parts = append(parts, "latest")
|
||||
} else {
|
||||
parts = append(parts, "~"+fmtDuration(time.Duration(approxSecs)*time.Second)+" ago")
|
||||
}
|
||||
|
||||
if site.Type != "push" && idx < len(hist.Latencies) {
|
||||
parts = append(parts, m.fmtLatency(hist.Latencies[idx]))
|
||||
}
|
||||
|
||||
if idx < len(hist.Statuses) {
|
||||
if hist.Statuses[idx] {
|
||||
parts = append(parts, m.st.specialStyle.Render("UP"))
|
||||
} else {
|
||||
parts = append(parts, m.st.dangerStyle.Render("DOWN"))
|
||||
}
|
||||
}
|
||||
|
||||
sep := m.st.subtleStyle.Render(" | ")
|
||||
pos := m.st.subtleStyle.Render(fmt.Sprintf("[%d/%d]", idx+1, dataLen))
|
||||
return " " + strings.Join(parts, sep) + " " + pos
|
||||
// Wrap in a viewport for scrolling
|
||||
content := b.String()
|
||||
contentH := m.termHeight - 4
|
||||
if contentH < 10 {
|
||||
contentH = 10
|
||||
}
|
||||
lines := strings.Split(content, "\n")
|
||||
if len(lines) > contentH {
|
||||
m.detailViewport.SetContent(content)
|
||||
m.detailViewport.Width = totalW
|
||||
m.detailViewport.Height = contentH
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(m.detailViewport.View())
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().Padding(1, 2).Render(content)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/models"
|
||||
"gitea.lerkolabs.com/lerkolabs/uptop/internal/monitor"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const detailTwoColMinWidth = 80
|
||||
|
||||
func (m Model) viewDetailInline(width int) string {
|
||||
if m.cursor >= len(m.sites) {
|
||||
return ""
|
||||
}
|
||||
site := m.sites[m.cursor]
|
||||
hist, _ := m.engine.GetHistory(site.ID)
|
||||
|
||||
if width < detailTwoColMinWidth {
|
||||
return m.viewDetailSingleCol(site, hist, width)
|
||||
}
|
||||
return m.viewDetailTwoCol(site, hist, width)
|
||||
}
|
||||
|
||||
func (m Model) viewDetailTwoCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||
leftW := width * 55 / 100
|
||||
rightW := width - leftW - 3 // 3 for " │ " divider
|
||||
|
||||
left := m.detailLeftCol(site, hist, leftW)
|
||||
right := m.detailRightCol(site, hist, rightW)
|
||||
|
||||
leftLines := strings.Split(left, "\n")
|
||||
rightLines := strings.Split(right, "\n")
|
||||
|
||||
lineCount := len(leftLines)
|
||||
if len(rightLines) > lineCount {
|
||||
lineCount = len(rightLines)
|
||||
}
|
||||
for len(leftLines) < lineCount {
|
||||
leftLines = append(leftLines, "")
|
||||
}
|
||||
for len(rightLines) < lineCount {
|
||||
rightLines = append(rightLines, "")
|
||||
}
|
||||
|
||||
divChar := m.st.subtleStyle.Render("│")
|
||||
leftStyle := lipgloss.NewStyle().Width(leftW).MaxWidth(leftW)
|
||||
rightStyle := lipgloss.NewStyle().Width(rightW).MaxWidth(rightW)
|
||||
|
||||
var b strings.Builder
|
||||
for i := range lineCount {
|
||||
l := leftStyle.Render(leftLines[i])
|
||||
r := rightStyle.Render(rightLines[i])
|
||||
b.WriteString(l + " " + divChar + " " + r + "\n")
|
||||
}
|
||||
|
||||
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
|
||||
b.WriteString(" " + keys + "\n")
|
||||
|
||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) detailLeftCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||
var b strings.Builder
|
||||
|
||||
if len(hist.Latencies) > 0 {
|
||||
chartW := width - 2
|
||||
if chartW < 20 {
|
||||
chartW = 20
|
||||
}
|
||||
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
|
||||
if chart != "" {
|
||||
b.WriteString(chart + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
|
||||
timelineW := width - 2
|
||||
if timelineW < 20 {
|
||||
timelineW = 20
|
||||
}
|
||||
b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n")
|
||||
}
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func (m Model) detailRightCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||
dot := m.st.subtleStyle.Render(" · ")
|
||||
label := m.st.subtleStyle
|
||||
|
||||
var b strings.Builder
|
||||
|
||||
// Line 1: status + latency + last check
|
||||
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||
parts := []string{status}
|
||||
if site.Latency > 0 {
|
||||
parts = append(parts, m.fmtLatency(site.Latency))
|
||||
}
|
||||
if !site.LastCheck.IsZero() {
|
||||
parts = append(parts, m.fmtTimeAgo(site.LastCheck))
|
||||
}
|
||||
b.WriteString(strings.Join(parts, dot) + "\n")
|
||||
|
||||
// Line 2: type-specific details
|
||||
typeParts := m.detailTypeLine(site)
|
||||
if len(typeParts) > 0 {
|
||||
b.WriteString(strings.Join(typeParts, dot) + "\n")
|
||||
}
|
||||
|
||||
// Line 3: uptime + retries
|
||||
uptimeParts := []string{label.Render("Uptime") + " " + m.fmtUptime(hist.Statuses)}
|
||||
if site.Type != "group" && site.MaxRetries > 0 {
|
||||
uptimeParts = append(uptimeParts, label.Render("Retries")+" "+m.fmtRetries(site))
|
||||
}
|
||||
b.WriteString(strings.Join(uptimeParts, dot) + "\n")
|
||||
|
||||
// Error line (if down/broken)
|
||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
||||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||
errW := width - 8
|
||||
if errW < 20 {
|
||||
errW = 20
|
||||
}
|
||||
b.WriteString(label.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
||||
}
|
||||
|
||||
// Blank line before state changes
|
||||
b.WriteString("\n")
|
||||
|
||||
// State changes (one per line, compact)
|
||||
var stateChanges []models.StateChange
|
||||
if m.detailChangesSiteID == site.ID {
|
||||
stateChanges = m.detailChanges
|
||||
}
|
||||
if len(stateChanges) > 0 {
|
||||
limit := 5
|
||||
if len(stateChanges) < limit {
|
||||
limit = len(stateChanges)
|
||||
}
|
||||
for _, sc := range stateChanges[:limit] {
|
||||
ago := fmtDuration(time.Since(sc.ChangedAt))
|
||||
arrow := m.st.subtleStyle.Render("→")
|
||||
from := m.fmtStatusWord(sc.FromStatus)
|
||||
to := m.fmtStatusWord(sc.ToStatus)
|
||||
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
|
||||
if sc.ErrorReason != "" {
|
||||
reasonW := width - 30
|
||||
if reasonW < 15 {
|
||||
reasonW = 15
|
||||
}
|
||||
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, reasonW))
|
||||
}
|
||||
b.WriteString(entry + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func (m Model) detailTypeLine(site models.Site) []string {
|
||||
label := m.st.subtleStyle
|
||||
var parts []string
|
||||
|
||||
switch site.Type {
|
||||
case "http":
|
||||
if site.StatusCode > 0 {
|
||||
codeStr := fmt.Sprintf("HTTP %d", site.StatusCode)
|
||||
if site.StatusCode >= httpErrorThreshold {
|
||||
parts = append(parts, m.st.dangerStyle.Render(codeStr))
|
||||
} else {
|
||||
parts = append(parts, m.st.specialStyle.Render(codeStr))
|
||||
}
|
||||
}
|
||||
if site.CheckSSL && site.HasSSL {
|
||||
days := int(time.Until(site.CertExpiry).Hours() / 24)
|
||||
sslStr := fmt.Sprintf("SSL %dd", days)
|
||||
switch {
|
||||
case days <= 0:
|
||||
parts = append(parts, m.st.dangerStyle.Render("SSL EXPIRED"))
|
||||
case days <= site.ExpiryThreshold:
|
||||
parts = append(parts, m.st.warnStyle.Render(sslStr))
|
||||
default:
|
||||
parts = append(parts, m.st.specialStyle.Render(sslStr))
|
||||
}
|
||||
}
|
||||
if site.URL != "" {
|
||||
parts = append(parts, label.Render(limitStr(site.URL, 40)))
|
||||
}
|
||||
case "push":
|
||||
parts = append(parts, label.Render("Push"))
|
||||
if site.Interval > 0 {
|
||||
parts = append(parts, label.Render(fmt.Sprintf("every %s", fmtDuration(time.Duration(site.Interval)*time.Second))))
|
||||
}
|
||||
if !site.LastSuccessAt.IsZero() {
|
||||
parts = append(parts, label.Render("last")+" "+m.fmtTimeAgo(site.LastSuccessAt))
|
||||
}
|
||||
case "ping":
|
||||
parts = append(parts, label.Render("Ping"))
|
||||
if site.Hostname != "" {
|
||||
parts = append(parts, label.Render(site.Hostname))
|
||||
}
|
||||
case "port":
|
||||
parts = append(parts, label.Render("Port"))
|
||||
if site.Hostname != "" {
|
||||
target := site.Hostname
|
||||
if site.Port > 0 {
|
||||
target = fmt.Sprintf("%s:%d", site.Hostname, site.Port)
|
||||
}
|
||||
parts = append(parts, label.Render(target))
|
||||
}
|
||||
case "dns":
|
||||
parts = append(parts, label.Render("DNS"))
|
||||
if site.DNSResolveType != "" {
|
||||
parts = append(parts, label.Render(site.DNSResolveType))
|
||||
}
|
||||
if site.DNSServer != "" {
|
||||
parts = append(parts, label.Render(site.DNSServer))
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
// viewDetailSingleCol is the narrow-terminal fallback (original stacked layout).
|
||||
func (m Model) viewDetailSingleCol(site models.Site, hist monitor.SiteHistory, width int) string {
|
||||
var b strings.Builder
|
||||
dot := m.st.subtleStyle.Render(" · ")
|
||||
|
||||
status := m.fmtStatus(site.Status, site.Paused, m.isMonitorInMaintenance(site.ID))
|
||||
parts := []string{status}
|
||||
if site.Latency > 0 {
|
||||
parts = append(parts, m.fmtLatency(site.Latency))
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("Uptime %s", m.fmtUptime(hist.Statuses)))
|
||||
if !site.LastCheck.IsZero() {
|
||||
parts = append(parts, m.fmtTimeAgo(site.LastCheck))
|
||||
}
|
||||
b.WriteString(" " + strings.Join(parts, dot) + "\n")
|
||||
|
||||
if (site.Status == models.StatusDown || site.Status == models.StatusSSLExp ||
|
||||
site.Status == models.StatusLate || site.Status == models.StatusStale) && site.LastError != "" {
|
||||
errW := width - 12
|
||||
if errW < 20 {
|
||||
errW = 20
|
||||
}
|
||||
b.WriteString(" " + m.st.subtleStyle.Render("Error") + " " + m.st.dangerStyle.Render(limitStr(site.LastError, errW)) + "\n")
|
||||
}
|
||||
|
||||
var stateChanges []models.StateChange
|
||||
if m.detailChangesSiteID == site.ID {
|
||||
stateChanges = m.detailChanges
|
||||
}
|
||||
if len(stateChanges) > 0 {
|
||||
limit := 3
|
||||
if len(stateChanges) < limit {
|
||||
limit = len(stateChanges)
|
||||
}
|
||||
var scParts []string
|
||||
for _, sc := range stateChanges[:limit] {
|
||||
ago := fmtDuration(time.Since(sc.ChangedAt))
|
||||
arrow := m.st.subtleStyle.Render("→")
|
||||
from := m.fmtStatusWord(sc.FromStatus)
|
||||
to := m.fmtStatusWord(sc.ToStatus)
|
||||
entry := from + " " + arrow + " " + to + " " + m.st.subtleStyle.Render(ago+" ago")
|
||||
if sc.ErrorReason != "" {
|
||||
entry += " " + m.st.dangerStyle.Render(limitStr(sc.ErrorReason, 30))
|
||||
}
|
||||
scParts = append(scParts, entry)
|
||||
}
|
||||
b.WriteString(" " + strings.Join(scParts, dot) + "\n")
|
||||
}
|
||||
|
||||
if len(hist.Latencies) > 0 {
|
||||
chartW := width - 4
|
||||
if chartW < 20 {
|
||||
chartW = 20
|
||||
}
|
||||
chart := m.latencyChart(hist.Latencies, hist.Statuses, chartW, 3)
|
||||
if chart != "" {
|
||||
b.WriteString(chart + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
if len(m.detailDailyDays) > 0 && m.detailChangesSiteID == site.ID {
|
||||
timelineW := width - 4
|
||||
if timelineW < 20 {
|
||||
timelineW = 20
|
||||
}
|
||||
b.WriteString(" " + m.st.subtleStyle.Render("30d") + " " + m.uptimeTimeline(m.detailDailyDays, timelineW) + "\n")
|
||||
}
|
||||
|
||||
keys := m.st.subtleStyle.Render("[h] History [s] SLA [e] Edit [esc] Close")
|
||||
b.WriteString(" " + keys + "\n")
|
||||
|
||||
return lipgloss.NewStyle().Width(width).MaxWidth(width).Render(b.String())
|
||||
}
|
||||
|
||||
func (m Model) fmtStatusWord(status string) string {
|
||||
switch status {
|
||||
case "DOWN":
|
||||
return m.st.dangerStyle.Render("DOWN")
|
||||
case "UP":
|
||||
return m.st.specialStyle.Render("UP")
|
||||
default:
|
||||
return m.st.subtleStyle.Render(status)
|
||||
}
|
||||
}
|
||||
@@ -40,10 +40,10 @@ func (m Model) viewSLAPanel() string {
|
||||
}
|
||||
bar := m.uptimeBar(r.UptimePct, barWidth)
|
||||
uptimeColor := m.st.specialStyle
|
||||
if r.UptimePct < 99.9 {
|
||||
if r.UptimePct < uptimeExcellentPct {
|
||||
uptimeColor = m.st.warnStyle
|
||||
}
|
||||
if r.UptimePct < 99.0 {
|
||||
if r.UptimePct < uptimeGoodPct {
|
||||
uptimeColor = m.st.dangerStyle
|
||||
}
|
||||
fmt.Fprintf(&b, " %-16s %s %s\n", m.st.subtleStyle.Render("Uptime"), uptimeColor.Render(fmt.Sprintf("%s%%", fmtPct(r.UptimePct))), bar)
|
||||
@@ -94,10 +94,10 @@ func (m Model) buildSLADailyContent() string {
|
||||
pctStr := fmtPct(day.UptimePct) + "%"
|
||||
|
||||
color := m.st.specialStyle
|
||||
if day.UptimePct < 99.9 {
|
||||
if day.UptimePct < uptimeExcellentPct {
|
||||
color = m.st.warnStyle
|
||||
}
|
||||
if day.UptimePct < 99.0 {
|
||||
if day.UptimePct < uptimeGoodPct {
|
||||
color = m.st.dangerStyle
|
||||
}
|
||||
|
||||
@@ -128,7 +128,7 @@ func fmtPct(pct float64) string {
|
||||
if pct == 100 {
|
||||
return "100.00"
|
||||
}
|
||||
if pct >= 99.99 {
|
||||
if pct >= uptimePrecisionPct {
|
||||
return fmt.Sprintf("%.3f", pct)
|
||||
}
|
||||
return fmt.Sprintf("%.2f", pct)
|
||||
|
||||