Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
37bf443e29
|
|||
|
f53dfa1c4c
|
|||
|
4070691407
|
|||
|
6dfd56dcd4
|
|||
|
17b5557e23
|
|||
|
dc27547ffb
|
|||
|
83ec6bee42
|
|||
|
d538aad18e
|
|||
|
ab0a69d06b
|
@@ -52,3 +52,7 @@ jobs:
|
|||||||
GORELEASER_FORCE_TOKEN: gitea
|
GORELEASER_FORCE_TOKEN: gitea
|
||||||
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
GITEA_API_URL: http://gitea:3000/api/v1
|
GITEA_API_URL: http://gitea:3000/api/v1
|
||||||
|
|
||||||
|
# GitHub release relaying is handled by .github/workflows/mirror-release.yml,
|
||||||
|
# which runs on GitHub Actions when the push mirror delivers the tag and
|
||||||
|
# copies this run's Gitea release assets — no PAT needed on this side.
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "$RESPONSE" | jq -r '.body // empty' > /tmp/release-notes.md
|
# select() so an empty-string body produces an empty file — `// empty`
|
||||||
|
# treats "" as truthy and wrote a blank line, defeating this fallback.
|
||||||
|
echo "$RESPONSE" | jq -r '.body | select(. != null and . != "")' > /tmp/release-notes.md
|
||||||
|
|
||||||
if [ ! -s /tmp/release-notes.md ]; then
|
if [ ! -s /tmp/release-notes.md ]; then
|
||||||
echo "Release ${TAG} from [Gitea](https://gitea.lerkolabs.com/lerkolabs/uptop/releases/tag/${TAG})" > /tmp/release-notes.md
|
echo "Release ${TAG} from [Gitea](https://gitea.lerkolabs.com/lerkolabs/uptop/releases/tag/${TAG})" > /tmp/release-notes.md
|
||||||
@@ -62,8 +64,11 @@ jobs:
|
|||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
TAG: ${{ github.ref_name }}
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
|
PRERELEASE=""
|
||||||
|
case "$TAG" in *-*) PRERELEASE="--prerelease" ;; esac
|
||||||
gh release create "$TAG" \
|
gh release create "$TAG" \
|
||||||
--repo "$GITHUB_REPOSITORY" \
|
--repo "$GITHUB_REPOSITORY" \
|
||||||
--title "$TAG" \
|
--title "$TAG" \
|
||||||
--notes-file /tmp/release-notes.md \
|
--notes-file /tmp/release-notes.md \
|
||||||
|
$PRERELEASE \
|
||||||
/tmp/assets/*
|
/tmp/assets/*
|
||||||
|
|||||||
+5
-2
@@ -8,6 +8,7 @@ release:
|
|||||||
gitea:
|
gitea:
|
||||||
owner: lerkolabs
|
owner: lerkolabs
|
||||||
name: uptop
|
name: uptop
|
||||||
|
prerelease: auto
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- main: ./cmd/uptop
|
- main: ./cmd/uptop
|
||||||
@@ -58,5 +59,7 @@ nfpms:
|
|||||||
dst: /usr/share/doc/uptop/LICENSE
|
dst: /usr/share/doc/uptop/LICENSE
|
||||||
type: doc
|
type: doc
|
||||||
|
|
||||||
changelog:
|
# Changelog generation must stay enabled: the --release-notes flag is consumed
|
||||||
disable: true
|
# 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
|
||||||
|
# skips its own generation and uses the file.
|
||||||
|
|||||||
+8
-3
@@ -1,6 +1,11 @@
|
|||||||
ignore:
|
ignore:
|
||||||
# CVE-2026-41589: SCP path traversal in charmbracelet/wish.
|
# SCP path traversal in charmbracelet/wish — same flaw, two ids: grype has
|
||||||
|
# matched it as CVE-2026-41589 and as GHSA-xjvp-7243-rg9h depending on db
|
||||||
|
# version, and ignore matching is exact-id, so both stay listed.
|
||||||
# We only import wish/bubbletea for the SSH TUI server — the vulnerable
|
# We only import wish/bubbletea for the SSH TUI server — the vulnerable
|
||||||
# scp.Middleware / scp.NewFileSystemHandler symbols are never compiled in.
|
# scp.Middleware / scp.NewFileSystemHandler symbols are never compiled in
|
||||||
# No fix available for wish v1; v2 (charm.land/wish/v2) patched in 2.0.1.
|
# (govulncheck reachability agrees). No fix for wish v1; v2
|
||||||
|
# (charm.land/wish/v2 >= 2.0.1) requires the bubbletea-v2 stack migration,
|
||||||
|
# tracked in issue #126. Remove both entries when that lands.
|
||||||
- vulnerability: CVE-2026-41589
|
- vulnerability: CVE-2026-41589
|
||||||
|
- vulnerability: GHSA-xjvp-7243-rg9h
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# --- Stage 1: Builder ---
|
# --- Stage 1: Builder ---
|
||||||
FROM golang:1.26-alpine3.23@sha256:91eda9776261207ea25fd06b5b7fed8d397dd2c0a283e77f2ab6e91bfa71079d AS builder
|
FROM golang:1.26.4-alpine3.23@sha256:f23e8b227fb4493eabe03bede4d5a32d04092da71962f1fb79b5f7d1e6c2a17f AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ Built on [RDGames/go-upkeep](https://github.com/RDGames/go-upkeep). Rewritten fo
|
|||||||
- **SQLite or Postgres** — SQLite for single-node, Postgres for production
|
- **SQLite or Postgres** — SQLite for single-node, Postgres for production
|
||||||
- **Uptime Kuma import** — migrate from Kuma with one command
|
- **Uptime Kuma import** — migrate from Kuma with one command
|
||||||
|
|
||||||
|
> Group monitors roll up child status for display but don't fire their own alerts yet — attach alerts to the children.
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
@@ -79,10 +81,14 @@ services:
|
|||||||
# - UPTOP_ADMIN_KEY=ssh-ed25519 AAAA... you@host
|
# - UPTOP_ADMIN_KEY=ssh-ed25519 AAAA... you@host
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
sysctls:
|
||||||
|
- net.ipv4.ping_group_range=0 2147483647
|
||||||
```
|
```
|
||||||
|
|
||||||
First run: set `UPTOP_ADMIN_KEY` to your SSH public key, or attach to the container and add it in the Users tab.
|
First run: set `UPTOP_ADMIN_KEY` to your SSH public key, or attach to the container and add it in the Users tab.
|
||||||
|
|
||||||
|
The `sysctls` line enables unprivileged ICMP inside the container — without it, ping monitors get no response and silently report DOWN.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -162,6 +168,19 @@ Set `UPTOP_ENCRYPTION_KEY` to encrypt alert credentials (SMTP passwords, webhook
|
|||||||
|
|
||||||
Without this, credentials are stored as plaintext in the database. uptop warns on startup if unset. To encrypt credentials on an existing install, run `uptop migrate-secrets` with the key set.
|
Without this, credentials are stored as plaintext in the database. uptop warns on startup if unset. To encrypt credentials on an existing install, run `uptop migrate-secrets` with the key set.
|
||||||
|
|
||||||
|
### Data retention
|
||||||
|
|
||||||
|
uptop prunes its own history in the background — no external cleanup jobs needed:
|
||||||
|
|
||||||
|
| Data | Kept |
|
||||||
|
|---|---|
|
||||||
|
| 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) |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## Clustering
|
## Clustering
|
||||||
|
|
||||||
uptop supports three modes: **leader** (default single node), **follower** (HA failover — takes over if the leader goes down), and **probe** (stateless distributed checks from multiple regions).
|
uptop supports three modes: **leader** (default single node), **follower** (HA failover — takes over if the leader goes down), and **probe** (stateless distributed checks from multiple regions).
|
||||||
@@ -174,7 +193,7 @@ Export your Kuma backup JSON, then:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -X POST http://localhost:8080/api/import/kuma \
|
curl -X POST http://localhost:8080/api/import/kuma \
|
||||||
-H "X-Upkeep-Secret: your-secret" \
|
-H "X-Uptop-Secret: your-secret" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d @kuma-backup.json
|
-d @kuma-backup.json
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ split_commits = false
|
|||||||
protect_breaking_commits = false
|
protect_breaking_commits = false
|
||||||
filter_commits = false
|
filter_commits = false
|
||||||
tag_pattern = "v[0-9].*"
|
tag_pattern = "v[0-9].*"
|
||||||
|
# rc tags are pipeline rehearsals, not releases — without this, the final
|
||||||
|
# tag's notes would only cover commits since the last rc (near-empty for
|
||||||
|
# v0.1.0). Ignored tags fold their commits into the next real release.
|
||||||
|
ignore_tags = "v.*-rc.*"
|
||||||
topo_order = false
|
topo_order = false
|
||||||
sort_commits = "oldest"
|
sort_commits = "oldest"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -81,5 +81,5 @@ Set via `UPTOP_AGG_STRATEGY` on the leader.
|
|||||||
## Security
|
## 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 are unauthenticated.
|
||||||
- Secrets are sent in HTTP headers (`X-Upkeep-Secret`). Use TLS or a reverse proxy for production.
|
- 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.
|
- uptop warns on startup if the cluster secret is missing or if cluster mode is active without TLS.
|
||||||
|
|||||||
+31
-2
@@ -122,7 +122,7 @@ Groups can't nest inside other groups. A group is healthy when all its children
|
|||||||
|
|
||||||
## Alert types
|
## Alert types
|
||||||
|
|
||||||
All 9 providers work in the YAML. The `settings` map is different per type.
|
All 10 providers work in the YAML. The `settings` map is different per type.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Discord / Slack / Generic Webhook — just a URL
|
# Discord / Slack / Generic Webhook — just a URL
|
||||||
@@ -149,6 +149,9 @@ All 9 providers work in the YAML. The `settings` map is different per type.
|
|||||||
url: https://ntfy.sh
|
url: https://ntfy.sh
|
||||||
topic: my-alerts
|
topic: my-alerts
|
||||||
priority: "4"
|
priority: "4"
|
||||||
|
# for protected topics:
|
||||||
|
# username: user
|
||||||
|
# password: pass
|
||||||
|
|
||||||
# Telegram
|
# Telegram
|
||||||
- name: Telegram Ops
|
- name: Telegram Ops
|
||||||
@@ -178,6 +181,14 @@ All 9 providers work in the YAML. The `settings` map is different per type.
|
|||||||
url: https://gotify.example.com
|
url: https://gotify.example.com
|
||||||
token: app-token
|
token: app-token
|
||||||
priority: "8"
|
priority: "8"
|
||||||
|
|
||||||
|
# Opsgenie
|
||||||
|
- name: Opsgenie
|
||||||
|
type: opsgenie
|
||||||
|
settings:
|
||||||
|
api_key: your-api-key
|
||||||
|
priority: P2 # P1–P5, default P3
|
||||||
|
# eu: "true" # use the EU API endpoint
|
||||||
```
|
```
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
@@ -224,7 +235,25 @@ Monitors and alerts are matched by **name**. Names must be unique across the ent
|
|||||||
|
|
||||||
Apply is idempotent. Run it twice with the same file, second run changes nothing.
|
Apply is idempotent. Run it twice with the same file, second run changes nothing.
|
||||||
|
|
||||||
If something fails mid-apply, just fix the issue and run it again. It picks up where it left off.
|
Apply is **not atomic** — items are written one at a time, so an error mid-apply (bad value, lost DB connection, ctrl-C) leaves the items already written in place. That's safe to recover from: apply diffs against the database by name, so fix the issue and run it again — it converges the rest. Just don't run two applies against the same database at once.
|
||||||
|
|
||||||
|
## Backups and secrets
|
||||||
|
|
||||||
|
`uptop export` writes alert credentials (SMTP passwords, API tokens, webhook URLs) into the YAML in clear text — that's what makes the file restorable. Treat it like a secrets file.
|
||||||
|
|
||||||
|
The HTTP export endpoint redacts those same fields **by default**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# secrets show as ***REDACTED*** — fine for sharing or review
|
||||||
|
curl -H "X-Uptop-Secret: your-secret" \
|
||||||
|
"http://localhost:8080/api/backup/export"
|
||||||
|
|
||||||
|
# full backup you can actually restore from
|
||||||
|
curl -H "X-Uptop-Secret: your-secret" \
|
||||||
|
"http://localhost:8080/api/backup/export?redact_secrets=false"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restoring a redacted export imports the literal string `***REDACTED***` as your credentials. For real backups, pass `redact_secrets=false` or run `uptop export` on the host.
|
||||||
|
|
||||||
## Typical workflow
|
## Typical workflow
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func runFollowerLoop(ctx context.Context, cfg Config, eng *monitor.Engine) {
|
|||||||
|
|
||||||
req, _ := http.NewRequest("GET", cfg.PeerURL+"/api/health", nil)
|
req, _ := http.NewRequest("GET", cfg.PeerURL+"/api/health", nil)
|
||||||
if cfg.SharedKey != "" {
|
if cfg.SharedKey != "" {
|
||||||
req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
|
req.Header.Set("X-Uptop-Secret", cfg.SharedKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func TestFollowerLoop_SendsSecret(t *testing.T) {
|
|||||||
var receivedSecret string
|
var receivedSecret string
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
receivedSecret = r.Header.Get("X-Upkeep-Secret")
|
receivedSecret = r.Header.Get("X-Uptop-Secret")
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
w.WriteHeader(200)
|
w.WriteHeader(200)
|
||||||
w.Write([]byte("OK"))
|
w.Write([]byte("OK"))
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ func probeRegister(ctx context.Context, client *http.Client, cfg ProbeConfig) er
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
|
req.Header.Set("X-Uptop-Secret", cfg.SharedKey)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -108,7 +108,7 @@ func probeFetchAssignments(ctx context.Context, client *http.Client, cfg ProbeCo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
|
req.Header.Set("X-Uptop-Secret", cfg.SharedKey)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -180,7 +180,7 @@ func probeReportResults(ctx context.Context, client *http.Client, cfg ProbeConfi
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("X-Upkeep-Secret", cfg.SharedKey)
|
req.Header.Set("X-Uptop-Secret", cfg.SharedKey)
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package importer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeTemp(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "backup.json")
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadKumaFileMissingFile(t *testing.T) {
|
||||||
|
_, err := LoadKumaFile(filepath.Join(t.TempDir(), "nope.json"))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for missing file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadKumaFileMalformedInput(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"empty file", ""},
|
||||||
|
{"truncated JSON", `{"version": "1.23", "monitorList": [`},
|
||||||
|
{"not JSON", "definitely not json"},
|
||||||
|
{"wrong root type", `[1, 2, 3]`},
|
||||||
|
{"monitorList wrong type", `{"monitorList": {"a": 1}}`},
|
||||||
|
{"monitor field wrong type", `{"monitorList": [{"id": "not-an-int"}]}`},
|
||||||
|
{"notificationList wrong type", `{"notificationList": "oops"}`},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, err := LoadKumaFile(writeTemp(t, tc.body))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected parse error for %s", tc.name)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "parse JSON") {
|
||||||
|
t.Fatalf("expected wrapped parse error, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadKumaFileNullLists(t *testing.T) {
|
||||||
|
kb, err := LoadKumaFile(writeTemp(t, `{"version": "1.23", "monitorList": null, "notificationList": null}`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backup := ConvertKuma(kb)
|
||||||
|
if len(backup.Sites) != 0 || len(backup.Alerts) != 0 {
|
||||||
|
t.Fatalf("expected empty backup, got %d sites %d alerts", len(backup.Sites), len(backup.Alerts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertKumaSkipsMalformedNotificationConfig(t *testing.T) {
|
||||||
|
kb := &KumaBackup{
|
||||||
|
NotificationList: []KumaNotifEntry{
|
||||||
|
{ID: 1, Name: "broken", Config: "{not json"},
|
||||||
|
{ID: 2, Name: "good", Config: `{"type": "discord", "ntfyserverurl": "https://example.com/hook"}`},
|
||||||
|
},
|
||||||
|
MonitorList: []KumaMonitor{
|
||||||
|
{ID: 10, Name: "site", Type: "http", URL: "https://example.com", NotificationIDs: map[string]bool{"1": true}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
backup := ConvertKuma(kb)
|
||||||
|
if len(backup.Alerts) != 1 {
|
||||||
|
t.Fatalf("expected broken notification skipped, got %d alerts", len(backup.Alerts))
|
||||||
|
}
|
||||||
|
if backup.Alerts[0].Type != "discord" {
|
||||||
|
t.Fatalf("expected discord alert, got %q", backup.Alerts[0].Type)
|
||||||
|
}
|
||||||
|
if backup.Sites[0].AlertID != 0 {
|
||||||
|
t.Fatalf("site referencing skipped notification should keep AlertID 0, got %d", backup.Sites[0].AlertID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertKumaNtfyNotification(t *testing.T) {
|
||||||
|
kb := &KumaBackup{
|
||||||
|
NotificationList: []KumaNotifEntry{
|
||||||
|
{ID: 3, Name: "ntfy", Config: `{
|
||||||
|
"type": "ntfy",
|
||||||
|
"ntfyserverurl": "https://ntfy.example.com/",
|
||||||
|
"ntfytopic": "uptime",
|
||||||
|
"ntfyPriority": 4,
|
||||||
|
"ntfyAuthenticationMethod": "usernamePassword",
|
||||||
|
"ntfyusername": "u",
|
||||||
|
"ntfypassword": "p"
|
||||||
|
}`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
backup := ConvertKuma(kb)
|
||||||
|
if len(backup.Alerts) != 1 {
|
||||||
|
t.Fatalf("expected 1 alert, got %d", len(backup.Alerts))
|
||||||
|
}
|
||||||
|
a := backup.Alerts[0]
|
||||||
|
if a.Type != "ntfy" {
|
||||||
|
t.Fatalf("expected ntfy, got %q", a.Type)
|
||||||
|
}
|
||||||
|
if a.Settings["url"] != "https://ntfy.example.com" {
|
||||||
|
t.Fatalf("expected trailing slash trimmed, got %q", a.Settings["url"])
|
||||||
|
}
|
||||||
|
if a.Settings["topic"] != "uptime" || a.Settings["priority"] != "4" {
|
||||||
|
t.Fatalf("unexpected settings: %v", a.Settings)
|
||||||
|
}
|
||||||
|
if a.Settings["username"] != "u" || a.Settings["password"] != "p" {
|
||||||
|
t.Fatalf("expected credentials mapped, got %v", a.Settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertKumaUnknownNotificationFallsBackToWebhook(t *testing.T) {
|
||||||
|
kb := &KumaBackup{
|
||||||
|
NotificationList: []KumaNotifEntry{
|
||||||
|
{ID: 4, Name: "matrix", Config: `{"type": "matrix", "ntfyserverurl": "https://example.com/hook"}`},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
backup := ConvertKuma(kb)
|
||||||
|
if len(backup.Alerts) != 1 || backup.Alerts[0].Type != "webhook" {
|
||||||
|
t.Fatalf("expected webhook fallback, got %+v", backup.Alerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertKumaHTTPMonitor(t *testing.T) {
|
||||||
|
kb := &KumaBackup{
|
||||||
|
NotificationList: []KumaNotifEntry{
|
||||||
|
{ID: 1, Name: "hook", Config: `{"type": "slack", "ntfyserverurl": "https://example.com/hook"}`},
|
||||||
|
},
|
||||||
|
MonitorList: []KumaMonitor{{
|
||||||
|
ID: 7,
|
||||||
|
Name: "web",
|
||||||
|
Type: "http",
|
||||||
|
URL: "https://example.com",
|
||||||
|
Interval: 60,
|
||||||
|
Timeout: 30,
|
||||||
|
MaxRetries: 2,
|
||||||
|
Method: "GET",
|
||||||
|
AcceptedCodes: []string{"200", "301"},
|
||||||
|
IgnoreTLS: true,
|
||||||
|
ExpiryNotif: true,
|
||||||
|
Active: false,
|
||||||
|
NotificationIDs: map[string]bool{"1": true},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
backup := ConvertKuma(kb)
|
||||||
|
if len(backup.Sites) != 1 {
|
||||||
|
t.Fatalf("expected 1 site, got %d", len(backup.Sites))
|
||||||
|
}
|
||||||
|
s := backup.Sites[0]
|
||||||
|
if s.URL != "https://example.com" || !s.CheckSSL || !s.IgnoreTLS {
|
||||||
|
t.Fatalf("http fields not mapped: %+v", s)
|
||||||
|
}
|
||||||
|
if !s.Paused {
|
||||||
|
t.Fatal("inactive monitor should import paused")
|
||||||
|
}
|
||||||
|
if s.AcceptedCodes != "200,301" {
|
||||||
|
t.Fatalf("expected joined accepted codes, got %q", s.AcceptedCodes)
|
||||||
|
}
|
||||||
|
if s.AlertID != 1 {
|
||||||
|
t.Fatalf("expected alert mapped, got %d", s.AlertID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertKumaPushMonitorGetsToken(t *testing.T) {
|
||||||
|
kb := &KumaBackup{
|
||||||
|
MonitorList: []KumaMonitor{{ID: 1, Name: "push", Type: "push", Active: true}},
|
||||||
|
}
|
||||||
|
backup := ConvertKuma(kb)
|
||||||
|
token := backup.Sites[0].Token
|
||||||
|
if len(token) != 32 {
|
||||||
|
t.Fatalf("expected 32-char hex token, got %q", token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertKumaNonNumericNotificationID(t *testing.T) {
|
||||||
|
kb := &KumaBackup{
|
||||||
|
MonitorList: []KumaMonitor{{
|
||||||
|
ID: 1,
|
||||||
|
Name: "site",
|
||||||
|
Type: "http",
|
||||||
|
NotificationIDs: map[string]bool{"abc": true},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
backup := ConvertKuma(kb)
|
||||||
|
if backup.Sites[0].AlertID != 0 {
|
||||||
|
t.Fatalf("non-numeric notification ID should not map, got %d", backup.Sites[0].AlertID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertKumaGroupAndChildren(t *testing.T) {
|
||||||
|
kb := &KumaBackup{
|
||||||
|
MonitorList: []KumaMonitor{
|
||||||
|
{ID: 1, Name: "grp", Type: "group", Active: true},
|
||||||
|
{ID: 2, Name: "ping", Type: "ping", Hostname: "10.0.0.1", Parent: 1, Active: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
backup := ConvertKuma(kb)
|
||||||
|
if backup.Sites[0].Type != "group" {
|
||||||
|
t.Fatalf("expected group type, got %q", backup.Sites[0].Type)
|
||||||
|
}
|
||||||
|
if backup.Sites[1].ParentID != 1 || backup.Sites[1].Hostname != "10.0.0.1" {
|
||||||
|
t.Fatalf("child not mapped: %+v", backup.Sites[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -115,10 +115,14 @@ func newEngine(s store.Store, allowPrivateTargets bool) *Engine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetInsecureSkipVerify must be called before Start: the field is read by
|
||||||
|
// checker goroutines without synchronization.
|
||||||
func (e *Engine) SetInsecureSkipVerify(skip bool) {
|
func (e *Engine) SetInsecureSkipVerify(skip bool) {
|
||||||
e.insecureSkipVerify = skip
|
e.insecureSkipVerify = skip
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMaintRetention must be called before Start: the field is read by the
|
||||||
|
// maintenance prune goroutine without synchronization.
|
||||||
func (e *Engine) SetMaintRetention(d time.Duration) {
|
func (e *Engine) SetMaintRetention(d time.Duration) {
|
||||||
e.maintRetention = d
|
e.maintRetention = d
|
||||||
}
|
}
|
||||||
@@ -1043,6 +1047,8 @@ func (e *Engine) EnqueueProbeCheck(siteID int, nodeID string, latencyNs int64, i
|
|||||||
e.enqueueWrite(writeProbeCheck{siteID: siteID, nodeID: nodeID, latencyNs: latencyNs, isUp: isUp})
|
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) {
|
func (e *Engine) SetAggStrategy(strategy AggregationStrategy) {
|
||||||
e.aggStrategy = strategy
|
e.aggStrategy = strategy
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ func (s *Server) routes() http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) requireAuth(r *http.Request) bool {
|
func (s *Server) requireAuth(r *http.Request) bool {
|
||||||
return s.cfg.ClusterKey != "" && checkSecret(r.Header.Get("X-Upkeep-Secret"), s.cfg.ClusterKey)
|
return s.cfg.ClusterKey != "" && checkSecret(r.Header.Get("X-Uptop-Secret"), s.cfg.ClusterKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handlePush(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handlePush(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -159,7 +159,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Upkeep-Secret"), s.cfg.ClusterKey) {
|
if s.cfg.ClusterKey != "" && !checkSecret(r.Header.Get("X-Uptop-Secret"), s.cfg.ClusterKey) {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ func authReq(method, url, secret string, body []byte) (*http.Response, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if secret != "" {
|
if secret != "" {
|
||||||
req.Header.Set("X-Upkeep-Secret", secret)
|
req.Header.Set("X-Uptop-Secret", secret)
|
||||||
}
|
}
|
||||||
return http.DefaultClient.Do(req)
|
return http.DefaultClient.Do(req)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ func (m *Model) refreshLive() {
|
|||||||
ordered = filterSites(ordered, m.filterText)
|
ordered = filterSites(ordered, m.filterText)
|
||||||
}
|
}
|
||||||
m.sites = ordered
|
m.sites = ordered
|
||||||
m.logViewport.SetContent(strings.Join(m.engine.GetLogs(), "\n"))
|
m.refreshLogContent()
|
||||||
|
|
||||||
if m.currentTab == 0 && m.selectedID != 0 {
|
if m.currentTab == 0 && m.selectedID != 0 {
|
||||||
for i, s := range m.sites {
|
for i, s := range m.sites {
|
||||||
|
|||||||
+18
-12
@@ -82,18 +82,15 @@ func (m Model) renderLogLine(line string) string {
|
|||||||
return fmt.Sprintf(" %s %s", tag, msg)
|
return fmt.Sprintf(" %s %s", tag, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) viewLogsTab() string {
|
// refreshLogContent rebuilds the log viewport from the full engine log list,
|
||||||
content := m.logViewport.View()
|
// filtering before windowing so the entry count and "(n hidden)" reflect all
|
||||||
if strings.TrimSpace(content) == "" || content == "Waiting for logs..." {
|
// logs, not just the visible viewport slice.
|
||||||
return m.emptyState("No log entries yet.", "Logs appear as monitors run checks")
|
func (m *Model) refreshLogContent() {
|
||||||
}
|
|
||||||
|
|
||||||
lines := strings.Split(content, "\n")
|
|
||||||
var rendered []string
|
var rendered []string
|
||||||
total := 0
|
total := 0
|
||||||
shown := 0
|
shown := 0
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range m.engine.GetLogs() {
|
||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(line) == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -106,18 +103,27 @@ func (m Model) viewLogsTab() string {
|
|||||||
rendered = append(rendered, m.renderLogLine(line))
|
rendered = append(rendered, m.renderLogLine(line))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"
|
filterLabel := "All"
|
||||||
if m.logFilterImportant {
|
if m.logFilterImportant {
|
||||||
filterLabel = "Important"
|
filterLabel = "Important"
|
||||||
}
|
}
|
||||||
|
|
||||||
header := m.st.subtleStyle.Render(fmt.Sprintf(
|
header := m.st.subtleStyle.Render(fmt.Sprintf(
|
||||||
" %d entries Filter: %s", shown, filterLabel))
|
" %d entries Filter: %s", m.logShown, filterLabel))
|
||||||
|
|
||||||
if m.logFilterImportant && shown < total {
|
if m.logFilterImportant && m.logShown < m.logTotal {
|
||||||
header += m.st.subtleStyle.Render(fmt.Sprintf(" (%d hidden)", total-shown))
|
header += m.st.subtleStyle.Render(fmt.Sprintf(" (%d hidden)", m.logTotal-m.logShown))
|
||||||
}
|
}
|
||||||
|
|
||||||
m.logViewport.SetContent(strings.Join(rendered, "\n"))
|
|
||||||
return "\n" + header + "\n\n" + m.logViewport.View()
|
return "\n" + header + "\n\n" + m.logViewport.View()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ type Model struct {
|
|||||||
|
|
||||||
logViewport viewport.Model
|
logViewport viewport.Model
|
||||||
logFilterImportant bool
|
logFilterImportant bool
|
||||||
|
logTotal int
|
||||||
|
logShown int
|
||||||
|
|
||||||
historyViewport viewport.Model
|
historyViewport viewport.Model
|
||||||
historyChanges []models.StateChange
|
historyChanges []models.StateChange
|
||||||
|
|||||||
@@ -527,6 +527,7 @@ func (m *Model) handleDashboardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
case "f":
|
case "f":
|
||||||
if m.state == stateLogs {
|
if m.state == stateLogs {
|
||||||
m.logFilterImportant = !m.logFilterImportant
|
m.logFilterImportant = !m.logFilterImportant
|
||||||
|
m.refreshLogContent()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
case "tab":
|
case "tab":
|
||||||
|
|||||||
Reference in New Issue
Block a user