Harden production workflows and agent admission
This commit is contained in:
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -17,8 +17,16 @@ jobs:
|
|||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
|
# PH-021: cache npm so the pinned @redocly/cli version below is fetched
|
||||||
|
# once per cache hit; the version is pinned by exact tag in this file
|
||||||
|
# so the same artifact is used across runs.
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: web/package-lock.json
|
||||||
- name: Lint OpenAPI
|
- name: Lint OpenAPI
|
||||||
run: npx --yes @redocly/cli@1.34.6 lint api/openapi.yaml
|
# PH-021: @redocly/cli is not in package.json because it is a lint-only
|
||||||
|
# tool that does not ship with the web bundle. The exact version below
|
||||||
|
# is the reproducibility pin; do not float to "latest" or a range.
|
||||||
|
run: npx --yes @redocly/cli@2.11.1 lint api/openapi.yaml
|
||||||
|
|
||||||
agent:
|
agent:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -6,13 +6,13 @@ It is split into three independent applications:
|
|||||||
|
|
||||||
- `agent/` - a small Go binary installed on monitored hosts.
|
- `agent/` - a small Go binary installed on monitored hosts.
|
||||||
- `server/` - a Python/FastAPI backend that owns inventory, state, incidents, and notifications.
|
- `server/` - a Python/FastAPI backend that owns inventory, state, incidents, and notifications.
|
||||||
- `web/` - a read-only Next.js dashboard.
|
- `web/` - a Next.js dashboard with agent admission controls.
|
||||||
|
|
||||||
The first project phase is documentation and planning bootstrap only. Core business logic is intentionally not implemented yet.
|
All three applications are implemented and run together via Docker Compose. The current release covers agent ingestion with operator admission, server-owned incident lifecycle and notifier outbox, a Next.js dashboard, and partitioned event retention.
|
||||||
|
|
||||||
## MVP
|
## Scope
|
||||||
|
|
||||||
Monlet v1 focuses on local checks, central state, and simple read-only visibility:
|
Monlet v1 focuses on local checks, central state, and simple visibility:
|
||||||
|
|
||||||
- agents run local checks from TOML config;
|
- agents run local checks from TOML config;
|
||||||
- agents expose Prometheus metrics and/or push facts to the server;
|
- agents expose Prometheus metrics and/or push facts to the server;
|
||||||
@@ -39,9 +39,9 @@ Monlet v1 focuses on local checks, central state, and simple read-only visibilit
|
|||||||
- `docs/` - architecture, ADRs, development, and operations docs.
|
- `docs/` - architecture, ADRs, development, and operations docs.
|
||||||
- `plan/` - stage workflow and exit checkpoints.
|
- `plan/` - stage workflow and exit checkpoints.
|
||||||
- `api/` - versioned OpenAPI contract.
|
- `api/` - versioned OpenAPI contract.
|
||||||
- `agent/` - Go agent placeholder and deployment notes.
|
- `agent/` - Go agent: scheduler, runner, spool, metrics.
|
||||||
- `server/` - Python backend placeholder.
|
- `server/` - Python/FastAPI backend: ingestion, detector, notifier outbox.
|
||||||
- `web/` - Next.js frontend placeholder.
|
- `web/` - Next.js dashboard and agent admission UI.
|
||||||
- `deploy/` - local and observability deployment examples.
|
- `deploy/` - local and observability deployment examples.
|
||||||
- `examples/` - example checks and configs.
|
- `examples/` - example checks and configs.
|
||||||
|
|
||||||
@@ -59,4 +59,4 @@ Read in this order:
|
|||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
Stage 0 is the only active stage: documentation bootstrap. Do not start agent, server, or web core logic until Stage 0 exits and the API contract is reviewed.
|
Active development. Stages 0–6 are implemented. Run the stack via `docker compose up --build` from `deploy/`. See `docs/operations/deployment.md` and `docs/ops/` runbooks.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Monlet Agent
|
# Monlet Agent
|
||||||
|
|
||||||
Go agent (Stage 2 MVP). Reads `agent.toml`, runs configured commands on schedule, captures status/output, pushes contract-valid `heartbeat` and `events` batches to the Monlet server with Bearer auth, and survives outages via a local on-disk spool.
|
Go agent. Reads `agent.toml`, runs configured commands on schedule, captures status/output, pushes contract-valid `heartbeat` and `events` batches to the Monlet server with Bearer auth, and survives outages via a local on-disk spool.
|
||||||
|
|
||||||
## Build and run
|
## Build and run
|
||||||
|
|
||||||
@@ -14,22 +14,28 @@ Do not use `go install` or `go env -w`. Module cache, build cache, and tool bina
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
See `config.example.toml`. Required fields: top-level `state_dir`, at least one `[[checks]]` (`id`, `command`, `interval`, `timeout`), and at least one output enabled: `server.enabled = true` or `metrics.enabled = true`. `agent_id` is optional; if omitted, it defaults to `hostname`.
|
See `config.example.toml`. Required fields: top-level `state_dir`, at least one `[[checks]]` (`id`, `command`, `interval`, `timeout`), and at least one output enabled: `server.enabled = true` or `metrics.enabled = true`. `agent_id` is optional; if omitted, it defaults to the OS hostname. `hostname` is not a config key.
|
||||||
|
|
||||||
| Key | Default | Notes |
|
| Key | Default | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `server.enabled` | `false` | Enables heartbeat/event push. Requires `server.url` and `server.token` when true. |
|
| `server.enabled` | `false` | Enables heartbeat/event push. Requires `server.url` when true. The agent generates and stores its own Bearer key in `state_dir/agent.key`. |
|
||||||
| `metrics.enabled` | `false` | Enables `/metrics`. |
|
| `metrics.enabled` | `false` | Enables `/metrics`. |
|
||||||
| `labels` | `{}` | Optional heartbeat labels. Max 31 custom labels because `monlet_agent_version` is generated by the agent. Secret-like keys are rejected. |
|
| `labels` | `{}` | Optional heartbeat labels. Max 31 custom labels because `monlet_agent_version` is generated by the agent. Secret-like keys are rejected. |
|
||||||
| `server.heartbeat_interval` | `30s` | ADR-0005 |
|
| `server.heartbeat_interval` | `10s` | ADR-0005 |
|
||||||
| `server.batch_interval` | `10s` | events flush cadence |
|
| `server.batch_interval` | `10s` | events flush cadence |
|
||||||
| `metrics.listen` | `127.0.0.1:9465` | low-cardinality only |
|
| `metrics.listen` | `127.0.0.1:9465` | low-cardinality only |
|
||||||
| `checks[].command` | required | Shell string executed as `/bin/sh -c`; TOML multi-line strings are supported. |
|
| `checks[].command` | required | Shell string executed as `/bin/sh -c`. Both forms accepted: `command = "check_disk --warn 80 --crit 90"` and triple-quoted `command = '''...'''`. Argv arrays are rejected. See [Command security contract](#command-security-contract). |
|
||||||
| `checks[].notifications_enabled` | `true` | Set `false` for checks that must not create server notifications. |
|
| `checks[].notifications_enabled` | `true` | Set `false` for checks that must not create server notifications. |
|
||||||
|
|
||||||
Example:
|
Examples — one-line and multi-line forms:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
|
[[checks]]
|
||||||
|
id = "uptime"
|
||||||
|
command = "test $(cut -d. -f1 /proc/uptime) -gt 60"
|
||||||
|
interval = "60s"
|
||||||
|
timeout = "5s"
|
||||||
|
|
||||||
[[checks]]
|
[[checks]]
|
||||||
id = "disk_root"
|
id = "disk_root"
|
||||||
command = '''
|
command = '''
|
||||||
@@ -40,23 +46,47 @@ interval = "60s"
|
|||||||
timeout = "10s"
|
timeout = "10s"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Command security contract
|
||||||
|
|
||||||
|
`checks[].command` is the only public field for what a check runs. The agent
|
||||||
|
executes it as `/bin/sh -c <command>` on the host where the agent runs.
|
||||||
|
|
||||||
|
- Local-config only. Commands are loaded from the agent's TOML file on disk.
|
||||||
|
The server does not push config and does not execute remote commands; see
|
||||||
|
`docs/architecture/security.md#no-remote-execution`. Anyone able to edit the
|
||||||
|
agent config or replace the agent binary already has local root-equivalent
|
||||||
|
power, so shell execution adds no new attack surface beyond that file.
|
||||||
|
- Argv arrays (`command = ["..."]`) are intentionally rejected by config
|
||||||
|
validation. Use a shell string for both short and long commands.
|
||||||
|
- Quoting and escaping are the operator's responsibility. Triple-quoted
|
||||||
|
`'''...'''` is recommended for multi-line scripts and avoids most escaping.
|
||||||
|
- Do not put secrets in `command`. Pass them via the systemd unit
|
||||||
|
`Environment=` / `EnvironmentFile=` and reference them from the script.
|
||||||
|
Label keys containing `token`, `secret`, `password`, `credential`,
|
||||||
|
`authorization`, `cookie`, `api_key`/`apikey` are rejected by config
|
||||||
|
validation to keep secrets out of heartbeats.
|
||||||
|
- Each run uses `exec.CommandContext` with `Setpgid`. On timeout the whole
|
||||||
|
process group is killed and the check result becomes `critical`.
|
||||||
|
- Combined stdout+stderr is truncated by UTF-8 byte length to 8 KiB with marker
|
||||||
|
`...[truncated N bytes]` before being persisted, exposed, or notified.
|
||||||
|
|
||||||
## Behaviour
|
## Behaviour
|
||||||
|
|
||||||
- Per-check scheduler with no-overlap: a tick is skipped if the previous run is still in flight (`monlet_agent_check_skipped_total`).
|
- Per-check scheduler with no-overlap: a tick is skipped if the previous run is still in flight (`monlet_agent_check_skipped_total`).
|
||||||
- Check commands run through `/bin/sh -c`; the old argv-array form is intentionally unsupported.
|
- Check commands run through `/bin/sh -c`; the old argv-array form is intentionally unsupported.
|
||||||
- Timeout via `exec.CommandContext` with `Setpgid` so the process group is killed on expiry; timeout → `unknown`.
|
- Timeout via `exec.CommandContext` with `Setpgid` so the process group is killed on expiry; timeout → `critical`.
|
||||||
- Exit code mapping: 0=ok, 1=warning, 2=critical, 3+=unknown (ADR-0005).
|
- Exit code mapping: 0=ok, 1=warning, 2=critical, 3+=unknown (ADR-0005).
|
||||||
- `output` (stdout+stderr) is truncated by **UTF-8 byte length** to 8 KiB with marker `...[truncated N bytes]`.
|
- `output` (stdout+stderr) is truncated by **UTF-8 byte length** to 8 KiB with marker `...[truncated N bytes]`.
|
||||||
- `event_id` is UUIDv7 generated on check completion and preserved across spool replay (ADR-0006 idempotency).
|
- `event_id` is UUIDv7 generated on check completion and preserved across spool replay (ADR-0006 idempotency).
|
||||||
- Spool: per-event JSON files in `state_dir/spool/`, FIFO, limits 10 000 events / 50 MiB, drop oldest on overflow (ADR-0005).
|
- Spool: per-event JSON files in `state_dir/spool/`, FIFO, limits 10 000 events / 50 MiB, drop oldest on overflow (ADR-0005). Drops are counted in `monlet_agent_events_dropped_total{reason}` with bounded reasons; one warning is logged per ~30s drop burst, not per event.
|
||||||
- Retry backoff for heartbeat and events: 1s → 30s, exp ×2, jitter ±20%. 4xx are dropped (not retried) to avoid hot loop.
|
- Retry backoff for heartbeat and events: 1s → 30s, exp ×2, jitter ±20%. 4xx are dropped (not retried) to avoid hot loop; dropped batches are counted as `reason=send_non_retryable` and logged once per batch.
|
||||||
- Bearer auth header on both endpoints (ADR-0007).
|
- Bearer auth header on both endpoints uses the local `state_dir/agent.key` value (ADR-0007).
|
||||||
- Heartbeats include configured labels plus reserved `monlet_agent_version=<binary version>`.
|
- Heartbeats include configured labels plus reserved `monlet_agent_version=<binary version>`.
|
||||||
- Heartbeats include explicit feature flags: `push` from `server.enabled`, `metrics` from `metrics.enabled`.
|
- Heartbeats include explicit feature flags: `push` from `server.enabled`, `metrics` from `metrics.enabled`.
|
||||||
|
|
||||||
## Prometheus metrics
|
## Prometheus metrics
|
||||||
|
|
||||||
Exposed on `metrics.listen` at `/metrics`. Labels are static (`check_id`, `status`, `kind`):
|
Exposed on `metrics.listen` at `/metrics`. Labels are static (`check_id`, `status`, `kind`). The agent caps `checks` at 256 (PH-019) and each `id` must match `[A-Za-z0-9._:-]+`; do not embed dynamic values (timestamps, sequence numbers, hostnames) in `id` because metric cardinality scales with it on Prometheus.
|
||||||
|
|
||||||
- `monlet_agent_check_runs_total{check_id,status}`
|
- `monlet_agent_check_runs_total{check_id,status}`
|
||||||
- `monlet_agent_check_skipped_total{check_id}`
|
- `monlet_agent_check_skipped_total{check_id}`
|
||||||
@@ -70,6 +100,7 @@ Exposed on `metrics.listen` at `/metrics`. Labels are static (`check_id`, `statu
|
|||||||
- `monlet_agent_spool_events`, `monlet_agent_spool_bytes`
|
- `monlet_agent_spool_events`, `monlet_agent_spool_bytes`
|
||||||
- `monlet_agent_send_attempts_total{kind}`, `monlet_agent_send_failures_total{kind}` (`kind` ∈ `heartbeat`, `events`)
|
- `monlet_agent_send_attempts_total{kind}`, `monlet_agent_send_failures_total{kind}` (`kind` ∈ `heartbeat`, `events`)
|
||||||
- `monlet_agent_events_accepted_total`, `monlet_agent_events_deduplicated_total`
|
- `monlet_agent_events_accepted_total`, `monlet_agent_events_deduplicated_total`
|
||||||
|
- `monlet_agent_events_dropped_total{reason}` — `reason ∈ spool_overflow_events | spool_overflow_bytes | send_non_retryable`
|
||||||
- `monlet_agent_last_heartbeat_timestamp_seconds`
|
- `monlet_agent_last_heartbeat_timestamp_seconds`
|
||||||
- `monlet_agent_build_info{version,push,metrics}` (constant 1)
|
- `monlet_agent_build_info{version,push,metrics}` (constant 1)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/monlet/agent/internal/agentkey"
|
||||||
"github.com/monlet/agent/internal/app"
|
"github.com/monlet/agent/internal/app"
|
||||||
"github.com/monlet/agent/internal/client"
|
"github.com/monlet/agent/internal/client"
|
||||||
"github.com/monlet/agent/internal/config"
|
"github.com/monlet/agent/internal/config"
|
||||||
@@ -43,11 +44,20 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
key := ""
|
||||||
|
if cfg.PushesToServer() {
|
||||||
|
key, err = agentkey.LoadOrCreate(cfg.StateDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("agent key", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
a := &app.App{
|
a := &app.App{
|
||||||
Cfg: cfg,
|
Cfg: cfg,
|
||||||
AgentID: agentID,
|
AgentID: agentID,
|
||||||
Version: version,
|
Version: version,
|
||||||
Client: client.New(cfg.Server.URL, cfg.Server.Token, agentID),
|
Client: client.New(cfg.Server.URL, key, agentID),
|
||||||
Spool: sp,
|
Spool: sp,
|
||||||
Metrics: metrics.New(),
|
Metrics: metrics.New(),
|
||||||
Log: log,
|
Log: log,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
hostname = "example-host-01"
|
# Optional. If omitted, the OS hostname is used as agent_id.
|
||||||
|
# agent_id = "example-host-01"
|
||||||
state_dir = "/var/lib/monlet-agent"
|
state_dir = "/var/lib/monlet-agent"
|
||||||
|
|
||||||
[labels]
|
[labels]
|
||||||
@@ -8,8 +9,7 @@ role = "api"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://127.0.0.1:8000"
|
url = "http://127.0.0.1:8000"
|
||||||
token = "replace-me"
|
heartbeat_interval = "10s"
|
||||||
heartbeat_interval = "30s"
|
|
||||||
batch_interval = "10s"
|
batch_interval = "10s"
|
||||||
|
|
||||||
[metrics]
|
[metrics]
|
||||||
@@ -24,3 +24,10 @@ command = '''
|
|||||||
'''
|
'''
|
||||||
interval = "60s"
|
interval = "60s"
|
||||||
timeout = "10s"
|
timeout = "10s"
|
||||||
|
|
||||||
|
[[checks]]
|
||||||
|
id = "uptime"
|
||||||
|
name = "Uptime probe"
|
||||||
|
command = "test $(cut -d. -f1 /proc/uptime) -gt 60"
|
||||||
|
interval = "60s"
|
||||||
|
timeout = "5s"
|
||||||
|
|||||||
75
agent/internal/agentkey/agentkey.go
Normal file
75
agent/internal/agentkey/agentkey.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package agentkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fileName = "agent.key"
|
||||||
|
keyBytes = 32
|
||||||
|
keyLen = keyBytes * 2
|
||||||
|
)
|
||||||
|
|
||||||
|
func LoadOrCreate(stateDir string) (string, error) {
|
||||||
|
if err := os.MkdirAll(stateDir, 0o700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(stateDir, 0o700); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := filepath.Join(stateDir, fileName)
|
||||||
|
if info, err := os.Stat(path); err == nil {
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return "", fmt.Errorf("agent key is not a regular file: %s", path)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
if err := os.Chmod(path, 0o600); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return validate(strings.TrimSpace(string(b)), path)
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := generate()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmp := fmt.Sprintf("%s.%d.tmp", path, os.Getpid())
|
||||||
|
defer os.Remove(tmp)
|
||||||
|
if err := os.WriteFile(tmp, []byte(key+"\n"), 0o600); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, path); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validate(key string, path string) (string, error) {
|
||||||
|
if len(key) != keyLen {
|
||||||
|
return "", fmt.Errorf("invalid agent key in %s", path)
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(key); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid agent key in %s", path)
|
||||||
|
}
|
||||||
|
return strings.ToLower(key), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func generate() (string, error) {
|
||||||
|
var b [keyBytes]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:]), nil
|
||||||
|
}
|
||||||
75
agent/internal/agentkey/agentkey_test.go
Normal file
75
agent/internal/agentkey/agentkey_test.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package agentkey
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadOrCreatePersistsKey(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
k1, err := LoadOrCreate(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
k2, err := LoadOrCreate(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if k1 != k2 {
|
||||||
|
t.Fatal("key was not persisted")
|
||||||
|
}
|
||||||
|
if len(k1) != keyLen {
|
||||||
|
t.Fatalf("unexpected key length %d", len(k1))
|
||||||
|
}
|
||||||
|
info, err := os.Stat(filepath.Join(dir, fileName))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := info.Mode().Perm(); got != 0o600 {
|
||||||
|
t.Fatalf("unexpected key mode %o", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadOrCreateRejectsInvalidExistingKey(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, fileName), []byte("bad\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadOrCreate(dir); err == nil {
|
||||||
|
t.Fatal("expected invalid key error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadOrCreateRejectsInvalidHexExistingKey(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, fileName), []byte("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := LoadOrCreate(dir); err == nil {
|
||||||
|
t.Fatal("expected invalid key error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadOrCreateRepairsKeyPermissions(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
key := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
path := filepath.Join(dir, fileName)
|
||||||
|
if err := os.WriteFile(path, []byte(key+"\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := LoadOrCreate(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != key {
|
||||||
|
t.Fatalf("unexpected key %q", got)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := info.Mode().Perm(); got != 0o600 {
|
||||||
|
t.Fatalf("unexpected key mode %o", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,11 +22,23 @@ type App struct {
|
|||||||
Spool *spool.Spool
|
Spool *spool.Spool
|
||||||
Metrics *metrics.Metrics
|
Metrics *metrics.Metrics
|
||||||
Log *slog.Logger
|
Log *slog.Logger
|
||||||
|
|
||||||
|
lastDropLog time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
dropReasonOverflowEvents = "spool_overflow_events"
|
||||||
|
dropReasonOverflowBytes = "spool_overflow_bytes"
|
||||||
|
dropReasonSendRejected = "send_non_retryable"
|
||||||
|
|
||||||
|
dropLogThrottle = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
func (a *App) Run(ctx context.Context) error {
|
func (a *App) Run(ctx context.Context) error {
|
||||||
events := make(chan scheduler.Event, 256)
|
events := make(chan scheduler.Event, 256)
|
||||||
|
|
||||||
|
// Account drops accumulated during spool Open (e.g. shrunk limits on restart).
|
||||||
|
a.recordSpoolDrops(a.Spool.Drops())
|
||||||
a.Metrics.ChecksConfigured.Set(float64(len(a.Cfg.Checks)))
|
a.Metrics.ChecksConfigured.Set(float64(len(a.Cfg.Checks)))
|
||||||
a.Metrics.BuildInfo.WithLabelValues(a.Version, boolLabel(a.Cfg.PushesToServer()), boolLabel(a.Cfg.ExposesMetrics())).Set(1)
|
a.Metrics.BuildInfo.WithLabelValues(a.Version, boolLabel(a.Cfg.PushesToServer()), boolLabel(a.Cfg.ExposesMetrics())).Set(1)
|
||||||
for _, c := range a.Cfg.Checks {
|
for _, c := range a.Cfg.Checks {
|
||||||
@@ -108,6 +120,7 @@ func (a *App) spoolWriter(ctx context.Context, events <-chan scheduler.Event) {
|
|||||||
if err := a.Spool.Enqueue(ev); err != nil {
|
if err := a.Spool.Enqueue(ev); err != nil {
|
||||||
a.Log.Error("spool enqueue", "err", err)
|
a.Log.Error("spool enqueue", "err", err)
|
||||||
}
|
}
|
||||||
|
a.recordSpoolDrops(a.Spool.Drops())
|
||||||
a.Metrics.SpoolDepth.Set(float64(a.Spool.Len()))
|
a.Metrics.SpoolDepth.Set(float64(a.Spool.Len()))
|
||||||
a.Metrics.SpoolBytes.Set(float64(a.Spool.Bytes()))
|
a.Metrics.SpoolBytes.Set(float64(a.Spool.Bytes()))
|
||||||
}
|
}
|
||||||
@@ -188,7 +201,9 @@ func (a *App) sendLoop(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// non-retryable: drop to avoid hot loop
|
// non-retryable: drop to avoid hot loop.
|
||||||
|
a.Metrics.EventsDropped.WithLabelValues(dropReasonSendRejected).Add(float64(len(seqs)))
|
||||||
|
a.Log.Warn("events dropped: server rejected batch", "count", len(seqs), "reason", dropReasonSendRejected)
|
||||||
a.Spool.Commit(seqs)
|
a.Spool.Commit(seqs)
|
||||||
attempt = 0
|
attempt = 0
|
||||||
continue
|
continue
|
||||||
@@ -214,6 +229,26 @@ func sleep(ctx context.Context, d time.Duration) bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) recordSpoolDrops(d spool.DropStats) {
|
||||||
|
if d.OverflowEvents == 0 && d.OverflowBytes == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.OverflowEvents > 0 {
|
||||||
|
a.Metrics.EventsDropped.WithLabelValues(dropReasonOverflowEvents).Add(float64(d.OverflowEvents))
|
||||||
|
}
|
||||||
|
if d.OverflowBytes > 0 {
|
||||||
|
a.Metrics.EventsDropped.WithLabelValues(dropReasonOverflowBytes).Add(float64(d.OverflowBytes))
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(a.lastDropLog) >= dropLogThrottle {
|
||||||
|
a.Log.Warn("spool drops",
|
||||||
|
"events_overflow", d.OverflowEvents,
|
||||||
|
"bytes_overflow", d.OverflowBytes,
|
||||||
|
)
|
||||||
|
a.lastDropLog = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func boolLabel(v bool) string {
|
func boolLabel(v bool) string {
|
||||||
if v {
|
if v {
|
||||||
return "true"
|
return "true"
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ func TestPrometheusOnlyDoesNotPush(t *testing.T) {
|
|||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
AgentID: "a1",
|
AgentID: "a1",
|
||||||
StateDir: dir,
|
StateDir: dir,
|
||||||
Server: config.ServerConfig{Enabled: boolPtr(false), URL: srv.URL, Token: "t", HeartbeatInterval: config.Duration{Duration: 50 * time.Millisecond}, BatchInterval: config.Duration{Duration: 50 * time.Millisecond}},
|
Server: config.ServerConfig{Enabled: boolPtr(false), URL: srv.URL, HeartbeatInterval: config.Duration{Duration: 50 * time.Millisecond}, BatchInterval: config.Duration{Duration: 50 * time.Millisecond}},
|
||||||
Metrics: config.MetricsConfig{Enabled: true, Listen: "127.0.0.1:0"},
|
Metrics: config.MetricsConfig{Enabled: true, Listen: "127.0.0.1:0"},
|
||||||
Checks: []config.CheckConfig{{
|
Checks: []config.CheckConfig{{
|
||||||
ID: "c1",
|
ID: "c1",
|
||||||
@@ -106,7 +106,6 @@ func TestEndToEndPushAndReplay(t *testing.T) {
|
|||||||
Server: config.ServerConfig{
|
Server: config.ServerConfig{
|
||||||
Enabled: boolPtr(true),
|
Enabled: boolPtr(true),
|
||||||
URL: srv.URL,
|
URL: srv.URL,
|
||||||
Token: "t",
|
|
||||||
HeartbeatInterval: config.Duration{Duration: 100 * time.Millisecond},
|
HeartbeatInterval: config.Duration{Duration: 100 * time.Millisecond},
|
||||||
BatchInterval: config.Duration{Duration: 100 * time.Millisecond},
|
BatchInterval: config.Duration{Duration: 100 * time.Millisecond},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ func New(baseURL, token, agentID string) *Client {
|
|||||||
baseURL: strings.TrimRight(baseURL, "/"),
|
baseURL: strings.TrimRight(baseURL, "/"),
|
||||||
token: token,
|
token: token,
|
||||||
agentID: agentID,
|
agentID: agentID,
|
||||||
http: &http.Client{Timeout: 15 * time.Second},
|
http: &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
Transport: &http.Transport{DisableKeepAlives: true},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
@@ -18,15 +19,20 @@ const (
|
|||||||
maxLabelKeyLen = 64
|
maxLabelKeyLen = 64
|
||||||
maxLabelValueLen = 256
|
maxLabelValueLen = 256
|
||||||
maxLabels = 32
|
maxLabels = 32
|
||||||
|
// PH-019: bounded check inventory keeps {check_id} metric cardinality
|
||||||
|
// predictable. ValidateID already restricts each check_id to a stable
|
||||||
|
// identifier alphabet; this cap bounds the total. Override via
|
||||||
|
// MONLET_AGENT_MAX_CHECKS for fleets that genuinely need more.
|
||||||
|
defaultMaxChecks = 256
|
||||||
AgentVersionLabel = "monlet_agent_version"
|
AgentVersionLabel = "monlet_agent_version"
|
||||||
defaultHeartbeat = 30 * time.Second
|
defaultHeartbeat = 10 * time.Second
|
||||||
defaultBatch = 10 * time.Second
|
defaultBatch = 10 * time.Second
|
||||||
defaultMetricsAddr = "127.0.0.1:9465"
|
defaultMetricsAddr = "127.0.0.1:9465"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
AgentID string `toml:"agent_id"`
|
AgentID string `toml:"agent_id"`
|
||||||
Hostname string `toml:"hostname"`
|
Hostname string `toml:"-"`
|
||||||
StateDir string `toml:"state_dir"`
|
StateDir string `toml:"state_dir"`
|
||||||
Labels map[string]string `toml:"labels"`
|
Labels map[string]string `toml:"labels"`
|
||||||
Server ServerConfig `toml:"server"`
|
Server ServerConfig `toml:"server"`
|
||||||
@@ -37,7 +43,6 @@ type Config struct {
|
|||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Enabled *bool `toml:"enabled"`
|
Enabled *bool `toml:"enabled"`
|
||||||
URL string `toml:"url"`
|
URL string `toml:"url"`
|
||||||
Token string `toml:"token"`
|
|
||||||
HeartbeatInterval Duration `toml:"heartbeat_interval"`
|
HeartbeatInterval Duration `toml:"heartbeat_interval"`
|
||||||
BatchInterval Duration `toml:"batch_interval"`
|
BatchInterval Duration `toml:"batch_interval"`
|
||||||
}
|
}
|
||||||
@@ -124,11 +129,8 @@ func (c *Config) Validate() error {
|
|||||||
if c.Server.URL == "" {
|
if c.Server.URL == "" {
|
||||||
return fmt.Errorf("server.url is required when server.enabled = true")
|
return fmt.Errorf("server.url is required when server.enabled = true")
|
||||||
}
|
}
|
||||||
if c.Server.Token == "" {
|
} else if c.Server.URL != "" {
|
||||||
return fmt.Errorf("server.token is required when server.enabled = true")
|
return fmt.Errorf("server.url requires server.enabled = true")
|
||||||
}
|
|
||||||
} else if c.Server.URL != "" || c.Server.Token != "" {
|
|
||||||
return fmt.Errorf("server.url/token require server.enabled = true")
|
|
||||||
}
|
}
|
||||||
if !c.PushesToServer() && !c.ExposesMetrics() {
|
if !c.PushesToServer() && !c.ExposesMetrics() {
|
||||||
return fmt.Errorf("server.enabled or metrics.enabled must be true")
|
return fmt.Errorf("server.enabled or metrics.enabled must be true")
|
||||||
@@ -136,6 +138,17 @@ func (c *Config) Validate() error {
|
|||||||
if len(c.Checks) == 0 {
|
if len(c.Checks) == 0 {
|
||||||
return fmt.Errorf("at least one check is required")
|
return fmt.Errorf("at least one check is required")
|
||||||
}
|
}
|
||||||
|
maxChecks := defaultMaxChecks
|
||||||
|
if v, ok := os.LookupEnv("MONLET_AGENT_MAX_CHECKS"); ok {
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return fmt.Errorf("MONLET_AGENT_MAX_CHECKS must be a positive integer, got %q", v)
|
||||||
|
}
|
||||||
|
maxChecks = n
|
||||||
|
}
|
||||||
|
if len(c.Checks) > maxChecks {
|
||||||
|
return fmt.Errorf("too many checks: %d (max %d) — bounded for metric cardinality (PH-019)", len(c.Checks), maxChecks)
|
||||||
|
}
|
||||||
seen := make(map[string]struct{}, len(c.Checks))
|
seen := make(map[string]struct{}, len(c.Checks))
|
||||||
for i := range c.Checks {
|
for i := range c.Checks {
|
||||||
ch := &c.Checks[i]
|
ch := &c.Checks[i]
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ state_dir = "/tmp/x"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://localhost"
|
url = "http://localhost"
|
||||||
token = "t"
|
|
||||||
|
|
||||||
[[checks]]
|
[[checks]]
|
||||||
id = "c1"
|
id = "c1"
|
||||||
@@ -57,7 +56,6 @@ state_dir = "/tmp/x"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://localhost"
|
url = "http://localhost"
|
||||||
token = "t"
|
|
||||||
|
|
||||||
[[checks]]
|
[[checks]]
|
||||||
id = "c1"
|
id = "c1"
|
||||||
@@ -87,7 +85,6 @@ state_dir = "/tmp/x"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://localhost"
|
url = "http://localhost"
|
||||||
token = "t"
|
|
||||||
|
|
||||||
[[checks]]
|
[[checks]]
|
||||||
id = "c1"
|
id = "c1"
|
||||||
@@ -100,6 +97,36 @@ timeout = "5s"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEmptyCommandRejected(t *testing.T) {
|
||||||
|
for name, cmd := range map[string]string{
|
||||||
|
"empty": `""`,
|
||||||
|
"whitespace": `" \t "`,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
_, err := Load(writeTmp(t, `
|
||||||
|
agent_id = "host-1"
|
||||||
|
state_dir = "/tmp/x"
|
||||||
|
|
||||||
|
[server]
|
||||||
|
enabled = true
|
||||||
|
url = "http://localhost"
|
||||||
|
|
||||||
|
[[checks]]
|
||||||
|
id = "c1"
|
||||||
|
command = `+cmd+`
|
||||||
|
interval = "10s"
|
||||||
|
timeout = "5s"
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected empty command to be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "command is required") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadLabels(t *testing.T) {
|
func TestLoadLabels(t *testing.T) {
|
||||||
c, err := Load(writeTmp(t, `
|
c, err := Load(writeTmp(t, `
|
||||||
agent_id = "host-1"
|
agent_id = "host-1"
|
||||||
@@ -112,7 +139,6 @@ role = "api"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://localhost"
|
url = "http://localhost"
|
||||||
token = "t"
|
|
||||||
|
|
||||||
[[checks]]
|
[[checks]]
|
||||||
id = "c1"
|
id = "c1"
|
||||||
@@ -148,7 +174,6 @@ state_dir = "/tmp/x"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://localhost"
|
url = "http://localhost"
|
||||||
token = "t"
|
|
||||||
[[checks]]
|
[[checks]]
|
||||||
id = "c1"
|
id = "c1"
|
||||||
command = "true"
|
command = "true"
|
||||||
@@ -167,7 +192,6 @@ state_dir = "/tmp/x"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://localhost"
|
url = "http://localhost"
|
||||||
token = "t"
|
|
||||||
[[checks]]
|
[[checks]]
|
||||||
id = "c1"
|
id = "c1"
|
||||||
command = "true"
|
command = "true"
|
||||||
@@ -188,7 +212,6 @@ state_dir = "/tmp/x"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://localhost"
|
url = "http://localhost"
|
||||||
token = "t"
|
|
||||||
[[checks]]
|
[[checks]]
|
||||||
id = "c1"
|
id = "c1"
|
||||||
command = "true"
|
command = "true"
|
||||||
@@ -268,7 +291,28 @@ interval = "10s"
|
|||||||
timeout = "5s"
|
timeout = "5s"
|
||||||
`))
|
`))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("push_only must require server.url/token")
|
t.Fatal("push_only must require server.url")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerTokenRejected(t *testing.T) {
|
||||||
|
_, err := Load(writeTmp(t, `
|
||||||
|
state_dir = "/tmp/x"
|
||||||
|
[server]
|
||||||
|
enabled = true
|
||||||
|
url = "http://localhost"
|
||||||
|
token = "obsolete"
|
||||||
|
[[checks]]
|
||||||
|
id = "c1"
|
||||||
|
command = "true"
|
||||||
|
interval = "10s"
|
||||||
|
timeout = "5s"
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected obsolete server.token to be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "server.token") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,7 +323,6 @@ state_dir = "/tmp/x"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://localhost"
|
url = "http://localhost"
|
||||||
token = "t"
|
|
||||||
[[checks]]
|
[[checks]]
|
||||||
id = "c1"
|
id = "c1"
|
||||||
command = "true"
|
command = "true"
|
||||||
@@ -291,6 +334,27 @@ timeout = "5s"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHostnameConfigKeyRejected(t *testing.T) {
|
||||||
|
_, err := Load(writeTmp(t, `
|
||||||
|
hostname = "host-1"
|
||||||
|
state_dir = "/tmp/x"
|
||||||
|
[server]
|
||||||
|
enabled = true
|
||||||
|
url = "http://localhost"
|
||||||
|
[[checks]]
|
||||||
|
id = "c1"
|
||||||
|
command = "true"
|
||||||
|
interval = "10s"
|
||||||
|
timeout = "5s"
|
||||||
|
`))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected hostname key to be rejected")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "hostname") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNotificationsEnabledCanDisable(t *testing.T) {
|
func TestNotificationsEnabledCanDisable(t *testing.T) {
|
||||||
c, err := Load(writeTmp(t, minimal+`
|
c, err := Load(writeTmp(t, minimal+`
|
||||||
notifications_enabled = false
|
notifications_enabled = false
|
||||||
@@ -329,7 +393,6 @@ state_dir = "/tmp/x"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://localhost"
|
url = "http://localhost"
|
||||||
token = "t"
|
|
||||||
[[checks]]
|
[[checks]]
|
||||||
id = "c1"
|
id = "c1"
|
||||||
command = "true"
|
command = "true"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func ResolveAgentID(configured, hostname string) (string, error) {
|
|||||||
if id == "" {
|
if id == "" {
|
||||||
return "", fmt.Errorf("hostname is empty")
|
return "", fmt.Errorf("hostname is empty")
|
||||||
}
|
}
|
||||||
if err := config.ValidateID("hostname as agent_id", id); err != nil {
|
if err := config.ValidateID("OS hostname as agent_id", id); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return id, nil
|
return id, nil
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type Metrics struct {
|
|||||||
SendFailures *prometheus.CounterVec
|
SendFailures *prometheus.CounterVec
|
||||||
EventsAccepted prometheus.Counter
|
EventsAccepted prometheus.Counter
|
||||||
EventsDedup prometheus.Counter
|
EventsDedup prometheus.Counter
|
||||||
|
EventsDropped *prometheus.CounterVec
|
||||||
LastHeartbeat prometheus.Gauge
|
LastHeartbeat prometheus.Gauge
|
||||||
BuildInfo *prometheus.GaugeVec
|
BuildInfo *prometheus.GaugeVec
|
||||||
}
|
}
|
||||||
@@ -86,6 +87,10 @@ func New() *Metrics {
|
|||||||
Name: "monlet_agent_events_deduplicated_total",
|
Name: "monlet_agent_events_deduplicated_total",
|
||||||
Help: "Events the server reported as duplicates.",
|
Help: "Events the server reported as duplicates.",
|
||||||
})
|
})
|
||||||
|
m.EventsDropped = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "monlet_agent_events_dropped_total",
|
||||||
|
Help: "Events dropped by the agent before server acceptance, by reason (low-cardinality enum).",
|
||||||
|
}, []string{"reason"})
|
||||||
m.CheckStatus = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
m.CheckStatus = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||||
Name: "monlet_agent_check_status",
|
Name: "monlet_agent_check_status",
|
||||||
Help: "Last observed status per check: 0=ok,1=warning,2=critical,3=unknown.",
|
Help: "Last observed status per check: 0=ok,1=warning,2=critical,3=unknown.",
|
||||||
@@ -123,7 +128,7 @@ func New() *Metrics {
|
|||||||
m.CheckInterval, m.ChecksConfigured,
|
m.CheckInterval, m.ChecksConfigured,
|
||||||
m.SpoolDepth, m.SpoolBytes,
|
m.SpoolDepth, m.SpoolBytes,
|
||||||
m.SendAttempts, m.SendFailures,
|
m.SendAttempts, m.SendFailures,
|
||||||
m.EventsAccepted, m.EventsDedup,
|
m.EventsAccepted, m.EventsDedup, m.EventsDropped,
|
||||||
m.LastHeartbeat, m.BuildInfo)
|
m.LastHeartbeat, m.BuildInfo)
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ func Run(ctx context.Context, argv []string, timeout time.Duration) Result {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cctx.Err() == context.DeadlineExceeded {
|
if cctx.Err() == context.DeadlineExceeded {
|
||||||
r.Status = StatusUnknown
|
r.Status = StatusCritical
|
||||||
r.ExitCode = -1
|
r.ExitCode = 2
|
||||||
|
r.Output = fmt.Sprintf("critical: check timed out after %s", timeout)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -97,9 +97,12 @@ func TestRunUnknownExit(t *testing.T) {
|
|||||||
|
|
||||||
func TestRunTimeout(t *testing.T) {
|
func TestRunTimeout(t *testing.T) {
|
||||||
r := Run(context.Background(), []string{"sh", "-c", "sleep 5"}, 200*time.Millisecond)
|
r := Run(context.Background(), []string{"sh", "-c", "sleep 5"}, 200*time.Millisecond)
|
||||||
if r.Status != StatusUnknown {
|
if r.Status != StatusCritical || r.ExitCode != 2 {
|
||||||
t.Fatalf("got %+v", r)
|
t.Fatalf("got %+v", r)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(r.Output, "critical: check timed out after 200ms") {
|
||||||
|
t.Fatalf("output: %q", r.Output)
|
||||||
|
}
|
||||||
if r.DurationMs > 2000 {
|
if r.DurationMs > 2000 {
|
||||||
t.Fatalf("duration too long: %d", r.DurationMs)
|
t.Fatalf("duration too long: %d", r.DurationMs)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,15 @@ type Spool struct {
|
|||||||
nextSeq uint64
|
nextSeq uint64
|
||||||
files []entry // sorted ascending by seq
|
files []entry // sorted ascending by seq
|
||||||
bytes int64
|
bytes int64
|
||||||
|
dropEvents int
|
||||||
|
dropBytes int
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropStats reports events dropped due to bounded-spool overflow since the
|
||||||
|
// previous call to Drops. Counters are reset on read.
|
||||||
|
type DropStats struct {
|
||||||
|
OverflowEvents int
|
||||||
|
OverflowBytes int
|
||||||
}
|
}
|
||||||
|
|
||||||
type entry struct {
|
type entry struct {
|
||||||
@@ -135,12 +144,30 @@ func (s *Spool) dropOldestLocked() {
|
|||||||
if len(s.files) == 0 {
|
if len(s.files) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
byCount := len(s.files) > s.maxEvents
|
||||||
old := s.files[0]
|
old := s.files[0]
|
||||||
_ = os.Remove(filepath.Join(s.dir, old.name))
|
_ = os.Remove(filepath.Join(s.dir, old.name))
|
||||||
s.bytes -= old.size
|
s.bytes -= old.size
|
||||||
s.files = s.files[1:]
|
s.files = s.files[1:]
|
||||||
|
if byCount {
|
||||||
|
s.dropEvents++
|
||||||
|
} else {
|
||||||
|
s.dropBytes++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drops returns and resets the count of events dropped due to overflow since
|
||||||
|
// the previous call. Callers use this to feed bounded-cardinality metrics and
|
||||||
|
// throttled logs without spamming on every Enqueue.
|
||||||
|
func (s *Spool) Drops() DropStats {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
d := DropStats{OverflowEvents: s.dropEvents, OverflowBytes: s.dropBytes}
|
||||||
|
s.dropEvents = 0
|
||||||
|
s.dropBytes = 0
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
// Peek returns up to limit oldest items. Does not remove them.
|
// Peek returns up to limit oldest items. Does not remove them.
|
||||||
func (s *Spool) Peek(limit int) ([]Item, error) {
|
func (s *Spool) Peek(limit int) ([]Item, error) {
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ func TestDropOldestByCount(t *testing.T) {
|
|||||||
if items[0].Event.EventID != "e2" {
|
if items[0].Event.EventID != "e2" {
|
||||||
t.Fatalf("expected oldest dropped, got %q", items[0].Event.EventID)
|
t.Fatalf("expected oldest dropped, got %q", items[0].Event.EventID)
|
||||||
}
|
}
|
||||||
|
d := s.Drops()
|
||||||
|
if d.OverflowEvents != 2 || d.OverflowBytes != 0 {
|
||||||
|
t.Fatalf("drop stats: %+v", d)
|
||||||
|
}
|
||||||
|
if again := s.Drops(); again.OverflowEvents != 0 || again.OverflowBytes != 0 {
|
||||||
|
t.Fatalf("Drops must reset, got %+v", again)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDropOldestByBytes(t *testing.T) {
|
func TestDropOldestByBytes(t *testing.T) {
|
||||||
@@ -71,6 +78,10 @@ func TestDropOldestByBytes(t *testing.T) {
|
|||||||
if s.Bytes() > 3000 {
|
if s.Bytes() > 3000 {
|
||||||
t.Fatalf("bytes=%d > 3000", s.Bytes())
|
t.Fatalf("bytes=%d > 3000", s.Bytes())
|
||||||
}
|
}
|
||||||
|
d := s.Drops()
|
||||||
|
if d.OverflowBytes == 0 {
|
||||||
|
t.Fatalf("expected bytes-overflow drops to be counted, got %+v", d)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReopenAppliesLimits(t *testing.T) {
|
func TestReopenAppliesLimits(t *testing.T) {
|
||||||
|
|||||||
384
api/openapi.yaml
384
api/openapi.yaml
@@ -5,11 +5,16 @@ info:
|
|||||||
description: |
|
description: |
|
||||||
Monlet v1 API contract. Stage 1 frozen.
|
Monlet v1 API contract. Stage 1 frozen.
|
||||||
|
|
||||||
All endpoints under `/api/v1`. Authentication uses a single shared Bearer token (see ADR-0007). Agent-facing ingestion endpoints (`/heartbeat`, `/events`) and read-only UI-facing endpoints share the same security scheme in v1.
|
All endpoints under `/api/v1`. Authentication is split (ADR-0007):
|
||||||
|
|
||||||
|
- `agentToken` — self-generated agent Bearer key stored locally by the agent. Used by ingestion paths `POST /api/v1/heartbeat` and `POST /api/v1/events`.
|
||||||
|
- `uiToken` — operator/UI shared Bearer token from `MONLET_AUTH_TOKEN`. Used by every other authenticated endpoint. Supports overlap rotation via space/comma-separated list (PH-008).
|
||||||
|
|
||||||
|
Both are presented as `Authorization: Bearer <token>`. UI token comparison is constant-time. Agent keys are stored server-side only as hashes/fingerprints and never appear in logs, metrics, or notifications.
|
||||||
servers:
|
servers:
|
||||||
- url: http://127.0.0.1:8000
|
- url: http://127.0.0.1:8000
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- uiToken: []
|
||||||
paths:
|
paths:
|
||||||
/api/v1/health:
|
/api/v1/health:
|
||||||
get:
|
get:
|
||||||
@@ -45,6 +50,8 @@ paths:
|
|||||||
post:
|
post:
|
||||||
summary: Ingest agent heartbeat
|
summary: Ingest agent heartbeat
|
||||||
operationId: ingestHeartbeat
|
operationId: ingestHeartbeat
|
||||||
|
security:
|
||||||
|
- agentToken: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -62,14 +69,26 @@ paths:
|
|||||||
$ref: "#/components/responses/ValidationError"
|
$ref: "#/components/responses/ValidationError"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
description: Agent key is blocked.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"409":
|
||||||
|
$ref: "#/components/responses/Conflict"
|
||||||
"413":
|
"413":
|
||||||
$ref: "#/components/responses/PayloadTooLarge"
|
$ref: "#/components/responses/PayloadTooLarge"
|
||||||
|
"429":
|
||||||
|
$ref: "#/components/responses/RateLimited"
|
||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$ref: "#/components/responses/InternalError"
|
||||||
/api/v1/events:
|
/api/v1/events:
|
||||||
post:
|
post:
|
||||||
summary: Ingest agent check result events
|
summary: Ingest agent check result events
|
||||||
operationId: ingestEvents
|
operationId: ingestEvents
|
||||||
|
security:
|
||||||
|
- agentToken: []
|
||||||
requestBody:
|
requestBody:
|
||||||
required: true
|
required: true
|
||||||
content:
|
content:
|
||||||
@@ -87,6 +106,12 @@ paths:
|
|||||||
$ref: "#/components/responses/ValidationError"
|
$ref: "#/components/responses/ValidationError"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"403":
|
||||||
|
description: Agent key is blocked.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
"413":
|
"413":
|
||||||
$ref: "#/components/responses/PayloadTooLarge"
|
$ref: "#/components/responses/PayloadTooLarge"
|
||||||
"500":
|
"500":
|
||||||
@@ -97,6 +122,7 @@ paths:
|
|||||||
operationId: listAgents
|
operationId: listAgents
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: "#/components/parameters/Cursor"
|
- $ref: "#/components/parameters/Cursor"
|
||||||
|
- $ref: "#/components/parameters/Back"
|
||||||
- $ref: "#/components/parameters/Limit"
|
- $ref: "#/components/parameters/Limit"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -115,6 +141,109 @@ paths:
|
|||||||
$ref: "#/components/schemas/Agent"
|
$ref: "#/components/schemas/Agent"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
/api/v1/agents/admission/blacklist:
|
||||||
|
get:
|
||||||
|
summary: List blocked agent keys
|
||||||
|
operationId: listAgentBlacklist
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/Cursor"
|
||||||
|
- $ref: "#/components/parameters/Limit"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Agent blacklist.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: "#/components/schemas/PageEnvelope"
|
||||||
|
- type: object
|
||||||
|
required: [items]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AgentBlacklistItem"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
delete:
|
||||||
|
summary: Clear blocked agent keys
|
||||||
|
operationId: clearAgentBlacklist
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Number of removed blacklist rows.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ActionCountResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
/api/v1/agents/admission/blacklist/{key_id}:
|
||||||
|
delete:
|
||||||
|
summary: Remove one blocked agent key
|
||||||
|
operationId: deleteAgentBlacklistItem
|
||||||
|
parameters:
|
||||||
|
- name: key_id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
minLength: 64
|
||||||
|
maxLength: 64
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Number of removed blacklist rows.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ActionCountResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
/api/v1/agents/admission/accept-all:
|
||||||
|
post:
|
||||||
|
summary: Accept all pending agents
|
||||||
|
operationId: acceptAllPendingAgents
|
||||||
|
parameters:
|
||||||
|
- name: include_rotation
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: When false, accepted agents with pending replacement keys are skipped.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Number of accepted agents.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ActionCountResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
/api/v1/agents/admission/block-all:
|
||||||
|
post:
|
||||||
|
summary: Block all pending agents
|
||||||
|
operationId: blockAllPendingAgents
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Number of blocked agent keys.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ActionCountResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
/api/v1/agents/admission/remove-all:
|
||||||
|
post:
|
||||||
|
summary: Remove all pending agents
|
||||||
|
operationId: removeAllPendingAgents
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Number of removed agents.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ActionCountResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
/api/v1/agents/{agent_id}:
|
/api/v1/agents/{agent_id}:
|
||||||
get:
|
get:
|
||||||
summary: Get agent detail
|
summary: Get agent detail
|
||||||
@@ -132,6 +261,56 @@ paths:
|
|||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
"404":
|
"404":
|
||||||
$ref: "#/components/responses/NotFound"
|
$ref: "#/components/responses/NotFound"
|
||||||
|
delete:
|
||||||
|
summary: Remove agent and all owned state
|
||||||
|
operationId: deleteAgent
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/AgentId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Number of removed agents.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ActionCountResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
/api/v1/agents/{agent_id}/accept:
|
||||||
|
post:
|
||||||
|
summary: Accept pending agent key
|
||||||
|
operationId: acceptAgent
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/AgentId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Number of accepted agents.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ActionCountResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
|
/api/v1/agents/{agent_id}/block:
|
||||||
|
post:
|
||||||
|
summary: Block pending agent key
|
||||||
|
operationId: blockAgent
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/AgentId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Number of blocked agent keys.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ActionCountResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"404":
|
||||||
|
$ref: "#/components/responses/NotFound"
|
||||||
/api/v1/checks:
|
/api/v1/checks:
|
||||||
get:
|
get:
|
||||||
summary: List current check states
|
summary: List current check states
|
||||||
@@ -145,6 +324,7 @@ paths:
|
|||||||
maxLength: 128
|
maxLength: 128
|
||||||
pattern: "^[A-Za-z0-9._:-]+$"
|
pattern: "^[A-Za-z0-9._:-]+$"
|
||||||
- $ref: "#/components/parameters/Cursor"
|
- $ref: "#/components/parameters/Cursor"
|
||||||
|
- $ref: "#/components/parameters/Back"
|
||||||
- $ref: "#/components/parameters/Limit"
|
- $ref: "#/components/parameters/Limit"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -174,7 +354,37 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
enum: [open, resolved]
|
enum: [open, resolved]
|
||||||
|
- name: severity
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [warning, critical, unknown]
|
||||||
|
- name: agent_id
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
maxLength: 128
|
||||||
|
pattern: "^[A-Za-z0-9._:-]+$"
|
||||||
|
- name: check_id
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
maxLength: 128
|
||||||
|
pattern: "^[A-Za-z0-9._:-]+$"
|
||||||
|
- name: sort
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [status, opened_at, resolved_at]
|
||||||
|
default: status
|
||||||
|
- name: direction
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [asc, desc]
|
||||||
|
default: desc
|
||||||
- $ref: "#/components/parameters/Cursor"
|
- $ref: "#/components/parameters/Cursor"
|
||||||
|
- $ref: "#/components/parameters/Back"
|
||||||
- $ref: "#/components/parameters/Limit"
|
- $ref: "#/components/parameters/Limit"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -213,6 +423,7 @@ paths:
|
|||||||
maxLength: 128
|
maxLength: 128
|
||||||
pattern: "^[A-Za-z0-9._:-]+$"
|
pattern: "^[A-Za-z0-9._:-]+$"
|
||||||
- $ref: "#/components/parameters/Cursor"
|
- $ref: "#/components/parameters/Cursor"
|
||||||
|
- $ref: "#/components/parameters/Back"
|
||||||
- $ref: "#/components/parameters/Limit"
|
- $ref: "#/components/parameters/Limit"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -231,12 +442,56 @@ paths:
|
|||||||
$ref: "#/components/schemas/StoredCheckResultEvent"
|
$ref: "#/components/schemas/StoredCheckResultEvent"
|
||||||
"401":
|
"401":
|
||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
/api/v1/system-summary:
|
||||||
|
get:
|
||||||
|
summary: System navigation summary
|
||||||
|
operationId: getSystemSummary
|
||||||
|
description: |
|
||||||
|
Bounded aggregate counts for the top navigation. Safe to poll from many
|
||||||
|
open dashboards because response size is independent of row counts.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: System summary snapshot.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SystemSummaryResponse"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
/api/v1/notifiers/outbox:
|
/api/v1/notifiers/outbox:
|
||||||
get:
|
get:
|
||||||
summary: List notification outbox items
|
summary: List notification outbox items
|
||||||
operationId: listOutbox
|
operationId: listOutbox
|
||||||
parameters:
|
parameters:
|
||||||
|
- name: state
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [pending, sending, sent, retry, failed, discarded]
|
||||||
|
- name: notifier
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [debug, telegram, webhook, alertmanager]
|
||||||
|
- name: event_type
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [firing, resolved]
|
||||||
|
- name: sort
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [created_at, updated_at]
|
||||||
|
default: updated_at
|
||||||
|
- name: direction
|
||||||
|
in: query
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [asc, desc]
|
||||||
|
default: desc
|
||||||
- $ref: "#/components/parameters/Cursor"
|
- $ref: "#/components/parameters/Cursor"
|
||||||
|
- $ref: "#/components/parameters/Back"
|
||||||
- $ref: "#/components/parameters/Limit"
|
- $ref: "#/components/parameters/Limit"
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
@@ -257,10 +512,21 @@ paths:
|
|||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
components:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
agentToken:
|
||||||
type: http
|
type: http
|
||||||
scheme: bearer
|
scheme: bearer
|
||||||
description: Shared static Bearer token in v1. See ADR-0007.
|
description: |
|
||||||
|
Self-generated agent Bearer key. The agent creates and persists it in
|
||||||
|
local state; the server stores only hash/fingerprint and requires UI
|
||||||
|
acceptance before events affect state. Used only by `/heartbeat` and
|
||||||
|
`/events`. See ADR-0007.
|
||||||
|
uiToken:
|
||||||
|
type: http
|
||||||
|
scheme: bearer
|
||||||
|
description: |
|
||||||
|
Operator/UI Bearer token from `MONLET_AUTH_TOKEN`. Multiple tokens may
|
||||||
|
be active simultaneously (space/comma separated) for zero-loss rotation.
|
||||||
|
See PH-008 and ADR-0007.
|
||||||
parameters:
|
parameters:
|
||||||
AgentId:
|
AgentId:
|
||||||
name: agent_id
|
name: agent_id
|
||||||
@@ -274,10 +540,18 @@ components:
|
|||||||
name: cursor
|
name: cursor
|
||||||
in: query
|
in: query
|
||||||
required: false
|
required: false
|
||||||
description: Opaque pagination cursor returned in `next_cursor`.
|
description: Opaque pagination cursor returned in `next_cursor` (or `prev_cursor` when `back=true`).
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 512
|
maxLength: 512
|
||||||
|
Back:
|
||||||
|
name: back
|
||||||
|
in: query
|
||||||
|
required: false
|
||||||
|
description: When true, treat the `cursor` as a `prev_cursor` and return the previous page.
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
Limit:
|
Limit:
|
||||||
name: limit
|
name: limit
|
||||||
in: query
|
in: query
|
||||||
@@ -300,12 +574,24 @@ components:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
Conflict:
|
||||||
|
description: Request conflicts with current server state.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
PayloadTooLarge:
|
PayloadTooLarge:
|
||||||
description: Payload exceeds configured limit (1 MiB).
|
description: Payload exceeds configured limit (1 MiB).
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
RateLimited:
|
||||||
|
description: Request rejected by configured rate/volume limits.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
ValidationError:
|
ValidationError:
|
||||||
description: Request body or parameters failed validation.
|
description: Request body or parameters failed validation.
|
||||||
content:
|
content:
|
||||||
@@ -338,6 +624,9 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum:
|
enum:
|
||||||
- unauthorized
|
- unauthorized
|
||||||
|
- forbidden
|
||||||
|
- conflict
|
||||||
|
- agent_blocked
|
||||||
- not_found
|
- not_found
|
||||||
- validation
|
- validation
|
||||||
- payload_too_large
|
- payload_too_large
|
||||||
@@ -355,6 +644,9 @@ components:
|
|||||||
next_cursor:
|
next_cursor:
|
||||||
type: [string, "null"]
|
type: [string, "null"]
|
||||||
description: Pass back as `cursor` to fetch the next page. Null/absent if no more results.
|
description: Pass back as `cursor` to fetch the next page. Null/absent if no more results.
|
||||||
|
prev_cursor:
|
||||||
|
type: [string, "null"]
|
||||||
|
description: Pass back as `cursor` with `back=true` to fetch the previous page. Null on the first page.
|
||||||
AcceptedResponse:
|
AcceptedResponse:
|
||||||
type: object
|
type: object
|
||||||
required: [accepted]
|
required: [accepted]
|
||||||
@@ -438,7 +730,7 @@ components:
|
|||||||
description: |
|
description: |
|
||||||
Event body used inside `EventBatchRequest.events`. The owning `agent_id`
|
Event body used inside `EventBatchRequest.events`. The owning `agent_id`
|
||||||
is supplied once at the batch envelope; per-event `agent_id` is intentionally
|
is supplied once at the batch envelope; per-event `agent_id` is intentionally
|
||||||
absent so requests cannot mix or spoof other agents under a shared token.
|
absent so one HTTP request cannot mix multiple agents.
|
||||||
Server response endpoints (`GET /events/query`) include `agent_id` separately
|
Server response endpoints (`GET /events/query`) include `agent_id` separately
|
||||||
in their item schema.
|
in their item schema.
|
||||||
required:
|
required:
|
||||||
@@ -510,7 +802,7 @@ components:
|
|||||||
format: date-time
|
format: date-time
|
||||||
Agent:
|
Agent:
|
||||||
type: object
|
type: object
|
||||||
required: [agent_id, hostname, status, last_seen_at, features]
|
required: [agent_id, hostname, status, admission_state, last_seen_at, features]
|
||||||
properties:
|
properties:
|
||||||
agent_id:
|
agent_id:
|
||||||
type: string
|
type: string
|
||||||
@@ -519,6 +811,17 @@ components:
|
|||||||
status:
|
status:
|
||||||
type: string
|
type: string
|
||||||
enum: [alive, stale, dead]
|
enum: [alive, stale, dead]
|
||||||
|
admission_state:
|
||||||
|
type: string
|
||||||
|
enum: [pending, accepted]
|
||||||
|
rotation_pending:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
description: True when a pending key would replace an already accepted key for this agent.
|
||||||
|
key_fingerprint:
|
||||||
|
type: [string, "null"]
|
||||||
|
minLength: 16
|
||||||
|
maxLength: 16
|
||||||
last_seen_at:
|
last_seen_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -528,6 +831,36 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
type: string
|
type: string
|
||||||
|
AgentBlacklistItem:
|
||||||
|
type: object
|
||||||
|
required: [id, key_fingerprint, agent_id, hostname, created_at, last_seen_at]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
minLength: 64
|
||||||
|
maxLength: 64
|
||||||
|
description: Opaque blacklist row id. Use for delete operations; display `key_fingerprint` to humans.
|
||||||
|
key_fingerprint:
|
||||||
|
type: string
|
||||||
|
minLength: 16
|
||||||
|
maxLength: 16
|
||||||
|
agent_id:
|
||||||
|
type: string
|
||||||
|
hostname:
|
||||||
|
type: string
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
last_seen_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
ActionCountResponse:
|
||||||
|
type: object
|
||||||
|
required: [count]
|
||||||
|
properties:
|
||||||
|
count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
CheckState:
|
CheckState:
|
||||||
type: object
|
type: object
|
||||||
required: [agent_id, check_id, status, last_observed_at]
|
required: [agent_id, check_id, status, last_observed_at]
|
||||||
@@ -602,3 +935,40 @@ components:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
SystemSummaryResponse:
|
||||||
|
type: object
|
||||||
|
description: Bounded aggregate counts for the web navigation.
|
||||||
|
required:
|
||||||
|
- agents_online
|
||||||
|
- agents_offline
|
||||||
|
- checks_ok
|
||||||
|
- checks_warning
|
||||||
|
- checks_critical
|
||||||
|
- checks_unknown
|
||||||
|
- open_incidents
|
||||||
|
- generated_at
|
||||||
|
properties:
|
||||||
|
agents_online:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
agents_offline:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
checks_ok:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
checks_warning:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
checks_critical:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
checks_unknown:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
open_incidents:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
generated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ Boots the stack, hits `/health`, `/ready`, `/metrics`, ingests example payloads
|
|||||||
|
|
||||||
Full-fat demo stack with several dockerized agents driving the system through
|
Full-fat demo stack with several dockerized agents driving the system through
|
||||||
every witness state: agent alive/stale/dead, check ok/warning/critical/unknown,
|
every witness state: agent alive/stale/dead, check ok/warning/critical/unknown,
|
||||||
incident open/resolved, outbox sent/retry/failed/discarded,
|
incident open/resolved, outbox sent/retry/failed,
|
||||||
anti-double-alerting with `notifications_enabled=false`, and agent spool
|
anti-double-alerting with `notifications_enabled=false`, and agent spool
|
||||||
replay against a server that becomes available after the agent starts.
|
replay after a controlled server outage.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
bash deploy/e2e-showcase.sh
|
bash deploy/e2e-showcase.sh
|
||||||
@@ -52,7 +52,7 @@ bash deploy/e2e-showcase.sh
|
|||||||
Internally:
|
Internally:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
MONLET_AUTH_TOKEN=monlet-dev-token docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.showcase.yml up -d --build
|
MONLET_AUTH_TOKEN=monlet-ui-dev-token docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.showcase.yml up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
`SHOWCASE_KEEP=1 bash deploy/e2e-showcase.sh` leaves the stack up so you can
|
`SHOWCASE_KEEP=1 bash deploy/e2e-showcase.sh` leaves the stack up so you can
|
||||||
@@ -71,7 +71,7 @@ for demo/e2e only — production deploys the Go binary under systemd.
|
|||||||
- `smoke.sh` — local stack smoke entry point.
|
- `smoke.sh` — local stack smoke entry point.
|
||||||
- `docker-compose.showcase.yml` — override that adds demo agents + mock webhook.
|
- `docker-compose.showcase.yml` — override that adds demo agents + mock webhook.
|
||||||
- `docker/agent.Dockerfile` — demo/e2e agent image (not for production).
|
- `docker/agent.Dockerfile` — demo/e2e agent image (not for production).
|
||||||
- `showcase/` — demo agent configs, check scripts, mock webhook, seed SQL.
|
- `showcase/` — demo agent configs, check scripts, and mock webhook.
|
||||||
- `e2e-showcase.sh` — full showcase runner (Stage 6.1).
|
- `e2e-showcase.sh` — full showcase runner (Stage 6.1).
|
||||||
|
|
||||||
Docker images install Python/Node project dependencies inside the image; host venvs are never mounted (see `AGENTS.md`).
|
Docker images install Python/Node project dependencies inside the image; host venvs are never mounted (see `AGENTS.md`).
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ services:
|
|||||||
server:
|
server:
|
||||||
environment:
|
environment:
|
||||||
MONLET_STALE_AFTER_SEC: "20"
|
MONLET_STALE_AFTER_SEC: "20"
|
||||||
MONLET_DEAD_AFTER_SEC: "40"
|
MONLET_DEAD_AFTER_SEC: "30"
|
||||||
MONLET_DETECTOR_TICK_SEC: "5"
|
MONLET_DETECTOR_TICK_SEC: "5"
|
||||||
MONLET_EVENTS_RETENTION_MONTHS: "1"
|
MONLET_EVENTS_RETENTION_MONTHS: "1"
|
||||||
MONLET_OUTBOX_RETENTION_MAX_ROWS: "1000"
|
MONLET_OUTBOX_RETENTION_MAX_ROWS: "1000"
|
||||||
@@ -28,6 +28,7 @@ services:
|
|||||||
|
|
||||||
agent-mixed:
|
agent-mixed:
|
||||||
image: monlet-showcase-agent
|
image: monlet-showcase-agent
|
||||||
|
hostname: agent-01
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/docker/agent.Dockerfile
|
dockerfile: deploy/docker/agent.Dockerfile
|
||||||
@@ -36,6 +37,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./showcase/agents/mixed.toml:/etc/monlet/agent.toml:ro
|
- ./showcase/agents/mixed.toml:/etc/monlet/agent.toml:ro
|
||||||
- ./showcase/checks:/opt/monlet/checks:ro
|
- ./showcase/checks:/opt/monlet/checks:ro
|
||||||
|
- mixed_agent_state:/var/lib/monlet-agent
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -44,11 +46,13 @@ services:
|
|||||||
|
|
||||||
agent-resolve:
|
agent-resolve:
|
||||||
image: monlet-showcase-agent
|
image: monlet-showcase-agent
|
||||||
|
hostname: agent-02
|
||||||
environment:
|
environment:
|
||||||
MONLET_CONFIG: /etc/monlet/agent.toml
|
MONLET_CONFIG: /etc/monlet/agent.toml
|
||||||
volumes:
|
volumes:
|
||||||
- ./showcase/agents/resolve.toml:/etc/monlet/agent.toml:ro
|
- ./showcase/agents/resolve.toml:/etc/monlet/agent.toml:ro
|
||||||
- ./showcase/checks:/opt/monlet/checks:ro
|
- ./showcase/checks:/opt/monlet/checks:ro
|
||||||
|
- resolve_agent_state:/var/lib/monlet-agent
|
||||||
- resolve_state:/state
|
- resolve_state:/state
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
@@ -58,11 +62,13 @@ services:
|
|||||||
|
|
||||||
agent-flap:
|
agent-flap:
|
||||||
image: monlet-showcase-agent
|
image: monlet-showcase-agent
|
||||||
|
hostname: agent-03
|
||||||
environment:
|
environment:
|
||||||
MONLET_CONFIG: /etc/monlet/agent.toml
|
MONLET_CONFIG: /etc/monlet/agent.toml
|
||||||
volumes:
|
volumes:
|
||||||
- ./showcase/agents/flap.toml:/etc/monlet/agent.toml:ro
|
- ./showcase/agents/flap.toml:/etc/monlet/agent.toml:ro
|
||||||
- ./showcase/checks:/opt/monlet/checks:ro
|
- ./showcase/checks:/opt/monlet/checks:ro
|
||||||
|
- flap_agent_state:/var/lib/monlet-agent
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -71,11 +77,13 @@ services:
|
|||||||
|
|
||||||
agent-timeout:
|
agent-timeout:
|
||||||
image: monlet-showcase-agent
|
image: monlet-showcase-agent
|
||||||
|
hostname: agent-04
|
||||||
environment:
|
environment:
|
||||||
MONLET_CONFIG: /etc/monlet/agent.toml
|
MONLET_CONFIG: /etc/monlet/agent.toml
|
||||||
volumes:
|
volumes:
|
||||||
- ./showcase/agents/timeout.toml:/etc/monlet/agent.toml:ro
|
- ./showcase/agents/timeout.toml:/etc/monlet/agent.toml:ro
|
||||||
- ./showcase/checks:/opt/monlet/checks:ro
|
- ./showcase/checks:/opt/monlet/checks:ro
|
||||||
|
- timeout_agent_state:/var/lib/monlet-agent
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -84,11 +92,13 @@ services:
|
|||||||
|
|
||||||
agent-prometheus-owned:
|
agent-prometheus-owned:
|
||||||
image: monlet-showcase-agent
|
image: monlet-showcase-agent
|
||||||
|
hostname: agent-05
|
||||||
environment:
|
environment:
|
||||||
MONLET_CONFIG: /etc/monlet/agent.toml
|
MONLET_CONFIG: /etc/monlet/agent.toml
|
||||||
volumes:
|
volumes:
|
||||||
- ./showcase/agents/prometheus_owned.toml:/etc/monlet/agent.toml:ro
|
- ./showcase/agents/prometheus_owned.toml:/etc/monlet/agent.toml:ro
|
||||||
- ./showcase/checks:/opt/monlet/checks:ro
|
- ./showcase/checks:/opt/monlet/checks:ro
|
||||||
|
- prometheus_owned_agent_state:/var/lib/monlet-agent
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -97,11 +107,13 @@ services:
|
|||||||
|
|
||||||
agent-stale:
|
agent-stale:
|
||||||
image: monlet-showcase-agent
|
image: monlet-showcase-agent
|
||||||
|
hostname: agent-07
|
||||||
environment:
|
environment:
|
||||||
MONLET_CONFIG: /etc/monlet/agent.toml
|
MONLET_CONFIG: /etc/monlet/agent.toml
|
||||||
volumes:
|
volumes:
|
||||||
- ./showcase/agents/stale.toml:/etc/monlet/agent.toml:ro
|
- ./showcase/agents/stale.toml:/etc/monlet/agent.toml:ro
|
||||||
- ./showcase/checks:/opt/monlet/checks:ro
|
- ./showcase/checks:/opt/monlet/checks:ro
|
||||||
|
- stale_agent_state:/var/lib/monlet-agent
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -110,11 +122,13 @@ services:
|
|||||||
|
|
||||||
agent-dead:
|
agent-dead:
|
||||||
image: monlet-showcase-agent
|
image: monlet-showcase-agent
|
||||||
|
hostname: agent-08
|
||||||
environment:
|
environment:
|
||||||
MONLET_CONFIG: /etc/monlet/agent.toml
|
MONLET_CONFIG: /etc/monlet/agent.toml
|
||||||
volumes:
|
volumes:
|
||||||
- ./showcase/agents/dead.toml:/etc/monlet/agent.toml:ro
|
- ./showcase/agents/dead.toml:/etc/monlet/agent.toml:ro
|
||||||
- ./showcase/checks:/opt/monlet/checks:ro
|
- ./showcase/checks:/opt/monlet/checks:ro
|
||||||
|
- dead_agent_state:/var/lib/monlet-agent
|
||||||
depends_on:
|
depends_on:
|
||||||
server:
|
server:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -124,6 +138,7 @@ services:
|
|||||||
# No depends_on:server — starts in parallel so it must spool events until server is ready.
|
# No depends_on:server — starts in parallel so it must spool events until server is ready.
|
||||||
agent-spool-replay:
|
agent-spool-replay:
|
||||||
image: monlet-showcase-agent
|
image: monlet-showcase-agent
|
||||||
|
hostname: agent-06
|
||||||
environment:
|
environment:
|
||||||
MONLET_CONFIG: /etc/monlet/agent.toml
|
MONLET_CONFIG: /etc/monlet/agent.toml
|
||||||
volumes:
|
volumes:
|
||||||
@@ -135,5 +150,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
mixed_agent_state:
|
||||||
|
resolve_agent_state:
|
||||||
resolve_state:
|
resolve_state:
|
||||||
|
flap_agent_state:
|
||||||
|
timeout_agent_state:
|
||||||
|
prometheus_owned_agent_state:
|
||||||
|
stale_agent_state:
|
||||||
|
dead_agent_state:
|
||||||
spool_replay_state:
|
spool_replay_state:
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: monlet
|
POSTGRES_USER: monlet
|
||||||
POSTGRES_PASSWORD: monlet
|
POSTGRES_PASSWORD: monlet
|
||||||
POSTGRES_DB: monlet
|
POSTGRES_DB: monlet
|
||||||
|
POSTGRES_INITDB_ARGS: "--auth-local=scram-sha-256 --auth-host=scram-sha-256"
|
||||||
TZ: UTC
|
TZ: UTC
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U monlet -d monlet"]
|
test: ["CMD-SHELL", "pg_isready -U monlet -d monlet"]
|
||||||
@@ -14,7 +15,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- monlet_pg:/var/lib/postgresql/data
|
- monlet_pg:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "5432:5432"
|
- "${MONLET_POSTGRES_HOST_PORT:-5432}:5432"
|
||||||
|
|
||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
@@ -36,12 +37,21 @@ services:
|
|||||||
- -c
|
- -c
|
||||||
- "alembic upgrade head && uvicorn monlet_server.main:app --host 0.0.0.0 --port 8000"
|
- "alembic upgrade head && uvicorn monlet_server.main:app --host 0.0.0.0 --port 8000"
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "${MONLET_SERVER_HOST_PORT:-8000}:8000"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "python -c 'import urllib.request,sys;sys.exit(0) if urllib.request.urlopen(\"http://localhost:8000/api/v1/ready\").status==200 else sys.exit(1)'"]
|
test: ["CMD-SHELL", "python -c 'import urllib.request,sys;sys.exit(0) if urllib.request.urlopen(\"http://localhost:8000/api/v1/ready\").status==200 else sys.exit(1)'"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 30
|
retries: 30
|
||||||
|
# PH-015: production hardening for runtime containers.
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=64M
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
stop_grace_period: 15s
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@@ -53,9 +63,29 @@ services:
|
|||||||
MONLET_API_BASE_URL: http://server:8000
|
MONLET_API_BASE_URL: http://server:8000
|
||||||
MONLET_API_TOKEN: ${MONLET_AUTH_TOKEN:?MONLET_AUTH_TOKEN is required}
|
MONLET_API_TOKEN: ${MONLET_AUTH_TOKEN:?MONLET_AUTH_TOKEN is required}
|
||||||
MONLET_WEB_TIME_ZONE: ${MONLET_WEB_TIME_ZONE:-UTC}
|
MONLET_WEB_TIME_ZONE: ${MONLET_WEB_TIME_ZONE:-UTC}
|
||||||
|
MONLET_WEB_AUTH_USERNAME: ${MONLET_WEB_AUTH_USERNAME:-}
|
||||||
|
MONLET_WEB_AUTH_PASSWORD: ${MONLET_WEB_AUTH_PASSWORD:-}
|
||||||
|
MONLET_WEB_TRUST_PROXY_AUTH: ${MONLET_WEB_TRUST_PROXY_AUTH:-false}
|
||||||
TZ: ${MONLET_WEB_TIME_ZONE:-UTC}
|
TZ: ${MONLET_WEB_TIME_ZONE:-UTC}
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "${MONLET_WEB_HOST_PORT:-3000}:3000"
|
||||||
|
# PH-015: production hardening for the Next.js runtime. Next writes a small
|
||||||
|
# cache under .next/cache so we keep that path writable via tmpfs.
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=64M
|
||||||
|
- /app/.next/cache:size=64M
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -q -O- http://localhost:3000/api/health >/dev/null 2>&1 || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
stop_grace_period: 15s
|
||||||
|
|
||||||
prometheus:
|
prometheus:
|
||||||
image: prom/prometheus:v2.55.0
|
image: prom/prometheus:v2.55.0
|
||||||
|
|||||||
@@ -2,18 +2,25 @@
|
|||||||
# Stage 6.1 — Docker showcase / e2e stand for Monlet.
|
# Stage 6.1 — Docker showcase / e2e stand for Monlet.
|
||||||
# Brings up the full stack with demo agents, drives scenarios, asserts all
|
# Brings up the full stack with demo agents, drives scenarios, asserts all
|
||||||
# witness states (agent alive/stale/dead, check ok/warning/critical/unknown,
|
# witness states (agent alive/stale/dead, check ok/warning/critical/unknown,
|
||||||
# incident open/resolved, outbox sent/retry/failed/discarded, disabled server notifications,
|
# incident open/resolved, outbox sent/retry/failed, disabled server notifications,
|
||||||
# agent spool replay), and verifies metrics + web pages.
|
# agent spool replay), and verifies metrics + web pages.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
ROOT="$(cd "$HERE/.." && pwd)"
|
ROOT="$(cd "$HERE/.." && pwd)"
|
||||||
TOKEN="${MONLET_AUTH_TOKEN:-monlet-dev-token}"
|
TOKEN="${MONLET_AUTH_TOKEN:-monlet-ui-dev-token}"
|
||||||
|
# Export so every `docker compose` call (up/exec/down) sees the UI token env,
|
||||||
|
# including the cleanup trap.
|
||||||
|
export MONLET_AUTH_TOKEN="$TOKEN"
|
||||||
BASE="${MONLET_API_BASE_URL:-http://127.0.0.1:8000}"
|
BASE="${MONLET_API_BASE_URL:-http://127.0.0.1:8000}"
|
||||||
WEB_BASE="${MONLET_WEB_BASE_URL:-http://127.0.0.1:3000}"
|
WEB_BASE="${MONLET_WEB_BASE_URL:-http://127.0.0.1:3000}"
|
||||||
AGENT_METRICS="${MONLET_AGENT_METRICS_URL:-http://127.0.0.1:9465/metrics}"
|
AGENT_METRICS="${MONLET_AGENT_METRICS_URL:-http://127.0.0.1:9465/metrics}"
|
||||||
|
ADMISSION_MODE="${SHOWCASE_ADMISSION_MODE:-pending}"
|
||||||
|
|
||||||
|
# PH-review: dedicated project name so showcase does not collide with the
|
||||||
|
# smoke/dev stack (orphans, shared postgres volume, stale baseline schema).
|
||||||
COMPOSE=(docker compose
|
COMPOSE=(docker compose
|
||||||
|
-p monlet-showcase
|
||||||
-f "$HERE/docker-compose.yml"
|
-f "$HERE/docker-compose.yml"
|
||||||
-f "$HERE/docker-compose.showcase.yml")
|
-f "$HERE/docker-compose.showcase.yml")
|
||||||
|
|
||||||
@@ -21,6 +28,7 @@ log() { printf "\033[1;36m[showcase]\033[0m %s\n" "$*"; }
|
|||||||
fail() { printf "\033[1;31m[showcase]\033[0m %s\n" "$*" >&2; exit 1; }
|
fail() { printf "\033[1;31m[showcase]\033[0m %s\n" "$*" >&2; exit 1; }
|
||||||
|
|
||||||
AUTH=(-H "Authorization: Bearer $TOKEN")
|
AUTH=(-H "Authorization: Bearer $TOKEN")
|
||||||
|
EXPECTED_AGENTS=8
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
if [[ "${SHOWCASE_KEEP:-0}" != "1" ]]; then
|
if [[ "${SHOWCASE_KEEP:-0}" != "1" ]]; then
|
||||||
@@ -42,9 +50,55 @@ wait_url() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
api() { curl -sf "${AUTH[@]}" "$BASE$1"; }
|
api() { curl -sf "${AUTH[@]}" "$BASE$1"; }
|
||||||
|
wait_agents_visible() {
|
||||||
|
local expected="$1"
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
local count
|
||||||
|
count="$(api "/api/v1/agents?limit=500" | python3 -c "import json,sys; print(len(json.load(sys.stdin)['items']))" 2>/dev/null || echo 0)"
|
||||||
|
[[ "${count:-0}" -ge "$expected" ]] && return 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
fail "agents did not register"
|
||||||
|
}
|
||||||
|
|
||||||
log "starting full showcase stack"
|
wait_agents_accepted() {
|
||||||
MONLET_AUTH_TOKEN="$TOKEN" "${COMPOSE[@]}" up -d --build
|
local expected="$1"
|
||||||
|
for i in $(seq 1 300); do
|
||||||
|
local counts accepted pending
|
||||||
|
counts="$(api "/api/v1/agents?limit=500" | python3 -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
items=d['items']
|
||||||
|
accepted=sum(1 for a in items if a['admission_state']=='accepted')
|
||||||
|
pending=sum(1 for a in items if a['admission_state']=='pending')
|
||||||
|
print(accepted, pending)
|
||||||
|
" 2>/dev/null || echo "0 0")"
|
||||||
|
read -r accepted pending <<<"$counts"
|
||||||
|
[[ "${accepted:-0}" -ge "$expected" && "${pending:-0}" -eq 0 ]] && return 0
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
fail "agents were not accepted in time"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Multi-phase startup keeps DB migration explicit, then agents register as
|
||||||
|
# pending. Full assertions wait read-only until an operator accepts them.
|
||||||
|
log "pre-clean previous showcase stack (if any)"
|
||||||
|
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
log "phase 1: postgres"
|
||||||
|
# --wait blocks until the postgres healthcheck (pg_isready) reports healthy,
|
||||||
|
# so the alembic step below does not race a still-booting DB.
|
||||||
|
"${COMPOSE[@]}" up -d --wait --build postgres
|
||||||
|
|
||||||
|
log "building current server image for migrations"
|
||||||
|
"${COMPOSE[@]}" build server >/dev/null || fail "server image build failed"
|
||||||
|
|
||||||
|
log "applying alembic migrations (one-off, server not yet running)"
|
||||||
|
"${COMPOSE[@]}" run --rm --no-deps server sh -c "alembic upgrade head" \
|
||||||
|
>/dev/null || fail "alembic upgrade failed"
|
||||||
|
|
||||||
|
log "phase 2: server + agents + web"
|
||||||
|
"${COMPOSE[@]}" up -d --build
|
||||||
|
|
||||||
log "waiting for server /api/v1/ready"
|
log "waiting for server /api/v1/ready"
|
||||||
wait_url "$BASE/api/v1/ready" "server" 90
|
wait_url "$BASE/api/v1/ready" "server" 90
|
||||||
@@ -52,31 +106,59 @@ wait_url "$BASE/api/v1/ready" "server" 90
|
|||||||
log "waiting for web /"
|
log "waiting for web /"
|
||||||
wait_url "$WEB_BASE/" "web" 90
|
wait_url "$WEB_BASE/" "web" 90
|
||||||
|
|
||||||
|
log "waiting for agents to register as pending"
|
||||||
|
wait_agents_visible "$EXPECTED_AGENTS"
|
||||||
|
|
||||||
|
case "$ADMISSION_MODE" in
|
||||||
|
pending)
|
||||||
|
log "agents are pending; automatic accept is disabled"
|
||||||
|
log "accept them in the UI/API, or rerun with SHOWCASE_ADMISSION_MODE=wait to continue assertions after manual admission"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
wait)
|
||||||
|
log "waiting for operator to accept agents (read-only polling)"
|
||||||
|
wait_agents_accepted "$EXPECTED_AGENTS"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "unknown SHOWCASE_ADMISSION_MODE=$ADMISSION_MODE (expected pending or wait)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
# --- Phase A: let agents run a few cycles ---
|
# --- Phase A: let agents run a few cycles ---
|
||||||
log "letting demo agents push events for ~25s"
|
log "letting demo agents push events for ~25s"
|
||||||
sleep 25
|
sleep 25
|
||||||
|
|
||||||
log "asserting agent labels include scenario and monlet_agent_version"
|
log "asserting agent-01 labels include scenario and monlet_agent_version"
|
||||||
api "/api/v1/agents/agent-mixed" | python3 -c "
|
api "/api/v1/agents/agent-01" | python3 -c "
|
||||||
import json,sys
|
import json,sys
|
||||||
d=json.load(sys.stdin)
|
d=json.load(sys.stdin)
|
||||||
labels=d.get('labels') or {}
|
labels=d.get('labels') or {}
|
||||||
ver=labels.get('monlet_agent_version')
|
ver=labels.get('monlet_agent_version')
|
||||||
ok=labels.get('scenario')=='mixed-status' and ver and ver!='dev'
|
ok=labels.get('scenario')=='mixed-status' and ver and ver!='dev'
|
||||||
print('agent-mixed labels:', labels)
|
print('agent-01 labels:', labels)
|
||||||
sys.exit(0 if ok else 1)" \
|
sys.exit(0 if ok else 1)" \
|
||||||
|| fail "agent-mixed labels missing scenario or numeric monlet_agent_version"
|
|| fail "agent-01 labels missing scenario or numeric monlet_agent_version"
|
||||||
|
|
||||||
log "asserting agent-mixed has 4 distinct check statuses (ok/warning/critical/unknown)"
|
log "asserting agent-01 has 4 distinct check statuses (ok/warning/critical/unknown)"
|
||||||
api "/api/v1/checks?limit=500" | python3 -c "
|
api "/api/v1/checks?limit=500" | python3 -c "
|
||||||
import json,sys
|
import json,sys
|
||||||
d=json.load(sys.stdin)
|
d=json.load(sys.stdin)
|
||||||
want={'ok','warning','critical','unknown'}
|
want={'ok','warning','critical','unknown'}
|
||||||
have={c['status'] for c in d['items'] if c['agent_id']=='agent-mixed'}
|
have={c['status'] for c in d['items'] if c['agent_id']=='agent-01'}
|
||||||
missing=want-have
|
missing=want-have
|
||||||
print('agent-mixed statuses:', sorted(have))
|
print('agent-01 statuses:', sorted(have))
|
||||||
sys.exit(0 if not missing else 1)" \
|
sys.exit(0 if not missing else 1)" \
|
||||||
|| fail "agent-mixed missing one of ok/warning/critical/unknown"
|
|| fail "agent-01 missing one of ok/warning/critical/unknown"
|
||||||
|
|
||||||
|
log "asserting timeout check is explicit critical"
|
||||||
|
api "/api/v1/checks?agent_id=agent-04&limit=50" | python3 -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
slow=next((c for c in d['items'] if c['check_id']=='slow_probe'), None)
|
||||||
|
print('slow_probe:', slow)
|
||||||
|
ok=slow and slow['status']=='critical' and slow['exit_code']==2 and 'timed out' in (slow.get('summary') or '')
|
||||||
|
sys.exit(0 if ok else 1)" \
|
||||||
|
|| fail "agent-04/slow_probe is not explicit critical timeout"
|
||||||
|
|
||||||
# --- Phase B: trigger resolve scenario ---
|
# --- Phase B: trigger resolve scenario ---
|
||||||
log "triggering resolve: touch /state/resolve.flag in agent-resolve"
|
log "triggering resolve: touch /state/resolve.flag in agent-resolve"
|
||||||
@@ -86,14 +168,14 @@ log "triggering resolve: touch /state/resolve.flag in agent-resolve"
|
|||||||
log "waiting for resolve event to be processed (~15s)"
|
log "waiting for resolve event to be processed (~15s)"
|
||||||
sleep 15
|
sleep 15
|
||||||
|
|
||||||
log "asserting resolved incident exists for agent-resolve/flapping"
|
log "asserting resolved incident exists for agent-02/flapping"
|
||||||
api "/api/v1/incidents?state=resolved&limit=500" \
|
api "/api/v1/incidents?state=resolved&limit=500" \
|
||||||
| python3 -c "
|
| python3 -c "
|
||||||
import json,sys
|
import json,sys
|
||||||
d=json.load(sys.stdin)
|
d=json.load(sys.stdin)
|
||||||
ok=any(i['agent_id']=='agent-resolve' and i['check_id']=='flapping' for i in d['items'])
|
ok=any(i['agent_id']=='agent-02' and i['check_id']=='flapping' for i in d['items'])
|
||||||
sys.exit(0 if ok else 1)" \
|
sys.exit(0 if ok else 1)" \
|
||||||
|| fail "no resolved incident for agent-resolve/flapping"
|
|| fail "no resolved incident for agent-02/flapping"
|
||||||
|
|
||||||
# --- Phase C: disabled server notifications ---
|
# --- Phase C: disabled server notifications ---
|
||||||
log "asserting notifications-disabled incident exists but no outbox row for it"
|
log "asserting notifications-disabled incident exists but no outbox row for it"
|
||||||
@@ -101,7 +183,7 @@ PROM_INC="$(api "/api/v1/incidents?limit=500" | python3 -c "
|
|||||||
import json,sys
|
import json,sys
|
||||||
d=json.load(sys.stdin)
|
d=json.load(sys.stdin)
|
||||||
for i in d['items']:
|
for i in d['items']:
|
||||||
if i['agent_id']=='agent-prometheus-owned' and i['check_id']=='promcheck':
|
if i['agent_id']=='agent-05' and i['check_id']=='promcheck':
|
||||||
print(i['id']); break")"
|
print(i['id']); break")"
|
||||||
[[ -n "$PROM_INC" ]] || fail "no incident for prometheus-owned agent"
|
[[ -n "$PROM_INC" ]] || fail "no incident for prometheus-owned agent"
|
||||||
api "/api/v1/notifiers/outbox?limit=500" | grep -q "\"incident_id\":\"$PROM_INC\"" \
|
api "/api/v1/notifiers/outbox?limit=500" | grep -q "\"incident_id\":\"$PROM_INC\"" \
|
||||||
@@ -114,22 +196,22 @@ log "stopping agent-stale and agent-dead to age their heartbeats"
|
|||||||
|
|
||||||
log "waiting ~25s for stale transition"
|
log "waiting ~25s for stale transition"
|
||||||
sleep 25
|
sleep 25
|
||||||
api "/api/v1/agents/agent-stale" | grep -q '"status":"stale"' \
|
api "/api/v1/agents/agent-07" | grep -q '"status":"stale"' \
|
||||||
|| api "/api/v1/agents/agent-stale" | grep -q '"status":"dead"' \
|
|| api "/api/v1/agents/agent-07" | grep -q '"status":"dead"' \
|
||||||
|| fail "agent-stale did not transition to stale/dead"
|
|| fail "agent-07 did not transition to stale/dead"
|
||||||
|
|
||||||
log "waiting another ~25s for dead transition"
|
log "waiting another ~25s for dead transition"
|
||||||
sleep 25
|
sleep 25
|
||||||
api "/api/v1/agents/agent-stale" | grep -q '"status":"dead"' \
|
api "/api/v1/agents/agent-07" | grep -q '"status":"dead"' \
|
||||||
|| fail "agent-stale did not transition to dead"
|
|| fail "agent-07 did not transition to dead"
|
||||||
api "/api/v1/agents/agent-dead" | grep -q '"status":"dead"' \
|
api "/api/v1/agents/agent-08" | grep -q '"status":"dead"' \
|
||||||
|| fail "agent-dead did not transition to dead"
|
|| fail "agent-08 did not transition to dead"
|
||||||
|
|
||||||
log "asserting dead agents have open liveness incidents"
|
log "asserting dead agents have open liveness incidents"
|
||||||
api "/api/v1/incidents?state=open&limit=500" | python3 -c "
|
api "/api/v1/incidents?state=open&limit=500" | python3 -c "
|
||||||
import json,sys
|
import json,sys
|
||||||
d=json.load(sys.stdin)
|
d=json.load(sys.stdin)
|
||||||
want={'agent-stale','agent-dead'}
|
want={'agent-07','agent-08'}
|
||||||
have={
|
have={
|
||||||
i['agent_id']
|
i['agent_id']
|
||||||
for i in d['items']
|
for i in d['items']
|
||||||
@@ -140,9 +222,9 @@ print('liveness incidents:', sorted(have))
|
|||||||
sys.exit(0 if not missing else 1)" \
|
sys.exit(0 if not missing else 1)" \
|
||||||
|| fail "missing critical agent_liveness incident for dead agents"
|
|| fail "missing critical agent_liveness incident for dead agents"
|
||||||
|
|
||||||
# At least one agent must still be alive (agent-mixed keeps pushing).
|
# At least one agent must still be alive (agent-01 keeps pushing).
|
||||||
api "/api/v1/agents/agent-mixed" | grep -q '"status":"alive"' \
|
api "/api/v1/agents/agent-01" | grep -q '"status":"alive"' \
|
||||||
|| fail "agent-mixed not alive"
|
|| fail "agent-01 not alive"
|
||||||
|
|
||||||
# --- Phase E: outbox state coverage (live via webhook + debug) ---
|
# --- Phase E: outbox state coverage (live via webhook + debug) ---
|
||||||
log "waiting for notifier worker cycles (~10s)"
|
log "waiting for notifier worker cycles (~10s)"
|
||||||
@@ -159,22 +241,27 @@ print('outbox states:', sorted(states))
|
|||||||
sys.exit(0 if not missing else 2)" \
|
sys.exit(0 if not missing else 2)" \
|
||||||
|| fail "outbox missing one of sent/retry/failed"
|
|| fail "outbox missing one of sent/retry/failed"
|
||||||
|
|
||||||
# --- Phase F: seed discarded row, wait for worker ---
|
|
||||||
log "seeding 'discarded' outbox row via SQL"
|
|
||||||
"${COMPOSE[@]}" exec -T postgres \
|
|
||||||
psql -U monlet -d monlet -v ON_ERROR_STOP=1 -q \
|
|
||||||
< "$HERE/showcase/seed_discarded.sql" >/dev/null \
|
|
||||||
|| fail "seed_discarded.sql failed"
|
|
||||||
|
|
||||||
log "waiting for worker to mark unknown-notifier row as discarded (~10s)"
|
|
||||||
sleep 10
|
|
||||||
api "/api/v1/notifiers/outbox?state=discarded&limit=10" \
|
|
||||||
| grep -q '"state":"discarded"' \
|
|
||||||
|| fail "no outbox row in state=discarded"
|
|
||||||
|
|
||||||
# --- Phase G: spool replay assertion ---
|
# --- Phase G: spool replay assertion ---
|
||||||
log "asserting agent-spool-replay events arrived without duplicates"
|
log "forcing server outage so agent-spool-replay writes to local spool"
|
||||||
api "/api/v1/events/query?agent_id=agent-spool-replay&check_id=replay_check&limit=500" \
|
"${COMPOSE[@]}" stop server >/dev/null
|
||||||
|
spool_count=0
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
spool_count=$("${COMPOSE[@]}" exec -T agent-spool-replay \
|
||||||
|
sh -c 'ls /var/lib/monlet-agent/spool 2>/dev/null | wc -l' 2>/dev/null \
|
||||||
|
| tr -d '[:space:]' || echo 0)
|
||||||
|
[[ "${spool_count:-0}" -gt 0 ]] && break
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
[[ "${spool_count:-0}" -gt 0 ]] || fail "agent-spool-replay did not spool any events"
|
||||||
|
log "spool populated with $spool_count file(s)"
|
||||||
|
|
||||||
|
log "restarting server and waiting for replay"
|
||||||
|
"${COMPOSE[@]}" up -d --no-deps server >/dev/null
|
||||||
|
wait_url "$BASE/api/v1/ready" "server after replay outage" 90
|
||||||
|
sleep 15
|
||||||
|
|
||||||
|
log "asserting agent-06 spool replay events arrived without duplicates"
|
||||||
|
api "/api/v1/events/query?agent_id=agent-06&check_id=replay_check&limit=500" \
|
||||||
| python3 -c "
|
| python3 -c "
|
||||||
import json,sys
|
import json,sys
|
||||||
d=json.load(sys.stdin)
|
d=json.load(sys.stdin)
|
||||||
@@ -192,17 +279,40 @@ log "server /metrics contains monlet_server_*"
|
|||||||
curl -sf "$BASE/metrics" | grep -q '^monlet_server_' \
|
curl -sf "$BASE/metrics" | grep -q '^monlet_server_' \
|
||||||
|| fail "server metrics missing monlet_server_*"
|
|| fail "server metrics missing monlet_server_*"
|
||||||
|
|
||||||
log "agent-mixed /metrics contains monlet_agent_check_status"
|
log "agent-01 /metrics contains monlet_agent_check_status"
|
||||||
curl -sf "$AGENT_METRICS" | grep -q 'monlet_agent_check_status' \
|
curl -sf "$AGENT_METRICS" | grep -q 'monlet_agent_check_status' \
|
||||||
|| fail "agent metrics missing monlet_agent_check_status"
|
|| fail "agent metrics missing monlet_agent_check_status"
|
||||||
|
|
||||||
# --- Phase I: web UI ---
|
# --- Phase I: web UI ---
|
||||||
log "web pages render with expected content"
|
log "web pages render with expected content"
|
||||||
curl -sf "$WEB_BASE/" | grep -q "Overview" || fail "web / missing Overview"
|
curl -sfL "$WEB_BASE/" | grep -q "Agents" || fail "web / did not redirect to Agents"
|
||||||
curl -sf "$WEB_BASE/agents" | grep -q "agent-mixed" || fail "web /agents missing agent-mixed"
|
curl -sf "$WEB_BASE/agents" | grep -q "mixed-status" || fail "web /agents missing mixed-status label"
|
||||||
curl -sf "$WEB_BASE/checks" | grep -q "critical_check" || fail "web /checks missing critical_check"
|
curl -sf "$WEB_BASE/checks" | grep -q "critical_check" || fail "web /checks missing critical_check"
|
||||||
curl -sf "$WEB_BASE/incidents" | grep -q "agent-resolve" || fail "web /incidents missing agent-resolve"
|
curl -sf "$WEB_BASE/incidents?state=open&limit=100&sort=status&direction=desc" \
|
||||||
|
| grep -q "agent-08" || fail "web /incidents missing agent-08"
|
||||||
curl -sf "$WEB_BASE/outbox" | grep -q -E 'webhook|debug' || fail "web /outbox missing notifier names"
|
curl -sf "$WEB_BASE/outbox" | grep -q -E 'webhook|debug' || fail "web /outbox missing notifier names"
|
||||||
curl -sf -o /dev/null "$WEB_BASE/events" || fail "web /events returned non-200"
|
curl -sf -o /dev/null "$WEB_BASE/events" || fail "web /events returned non-200"
|
||||||
|
|
||||||
|
if [[ "${SHOWCASE_KEEP:-0}" == "1" ]]; then
|
||||||
|
log "refreshing transient containers before leaving stack"
|
||||||
|
"${COMPOSE[@]}" stop \
|
||||||
|
agent-mixed agent-resolve agent-flap agent-timeout agent-prometheus-owned \
|
||||||
|
agent-spool-replay >/dev/null || true
|
||||||
|
"${COMPOSE[@]}" stop mock-webhook server >/dev/null || true
|
||||||
|
"${COMPOSE[@]}" rm -f \
|
||||||
|
agent-mixed agent-resolve agent-flap agent-timeout agent-prometheus-owned \
|
||||||
|
agent-spool-replay agent-stale agent-dead server mock-webhook >/dev/null || true
|
||||||
|
"${COMPOSE[@]}" up -d --no-deps mock-webhook server >/dev/null \
|
||||||
|
|| fail "could not restart runtime services"
|
||||||
|
wait_url "$BASE/api/v1/ready" "server after refresh" 90
|
||||||
|
"${COMPOSE[@]}" up -d --no-deps \
|
||||||
|
agent-mixed agent-resolve agent-flap agent-timeout agent-prometheus-owned \
|
||||||
|
agent-spool-replay >/dev/null \
|
||||||
|
|| fail "could not restart live showcase agents"
|
||||||
|
wait_agents_visible "$EXPECTED_AGENTS"
|
||||||
|
wait_agents_accepted "$EXPECTED_AGENTS"
|
||||||
|
"${COMPOSE[@]}" create agent-stale agent-dead >/dev/null \
|
||||||
|
|| fail "could not recreate transient containers"
|
||||||
|
fi
|
||||||
|
|
||||||
log "ALL SHOWCASE ASSERTIONS PASSED"
|
log "ALL SHOWCASE ASSERTIONS PASSED"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
hostname = "agent-dead"
|
agent_id = "agent-08"
|
||||||
state_dir = "/var/lib/monlet-agent"
|
state_dir = "/var/lib/monlet-agent"
|
||||||
|
|
||||||
[labels]
|
[labels]
|
||||||
@@ -8,7 +8,6 @@ role = "showcase"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://server:8000"
|
url = "http://server:8000"
|
||||||
token = "monlet-dev-token"
|
|
||||||
heartbeat_interval = "5s"
|
heartbeat_interval = "5s"
|
||||||
batch_interval = "3s"
|
batch_interval = "3s"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
hostname = "agent-flap"
|
agent_id = "agent-03"
|
||||||
state_dir = "/var/lib/monlet-agent"
|
state_dir = "/var/lib/monlet-agent"
|
||||||
|
|
||||||
[labels]
|
[labels]
|
||||||
@@ -8,7 +8,6 @@ role = "showcase"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://server:8000"
|
url = "http://server:8000"
|
||||||
token = "monlet-dev-token"
|
|
||||||
heartbeat_interval = "5s"
|
heartbeat_interval = "5s"
|
||||||
batch_interval = "3s"
|
batch_interval = "3s"
|
||||||
|
|
||||||
@@ -22,4 +21,4 @@ name = "Flapping check (oscillates ok/critical)"
|
|||||||
command = "/opt/monlet/checks/flap.sh"
|
command = "/opt/monlet/checks/flap.sh"
|
||||||
interval = "10s"
|
interval = "10s"
|
||||||
timeout = "3s"
|
timeout = "3s"
|
||||||
dedupe_key = "agent-flap:flap"
|
dedupe_key = "agent-03:flap"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
hostname = "agent-mixed"
|
agent_id = "agent-01"
|
||||||
state_dir = "/var/lib/monlet-agent"
|
state_dir = "/var/lib/monlet-agent"
|
||||||
|
|
||||||
[labels]
|
[labels]
|
||||||
@@ -8,7 +8,6 @@ role = "showcase"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://server:8000"
|
url = "http://server:8000"
|
||||||
token = "monlet-dev-token"
|
|
||||||
heartbeat_interval = "5s"
|
heartbeat_interval = "5s"
|
||||||
batch_interval = "3s"
|
batch_interval = "3s"
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ name = "Showcase critical (permanent webhook fail)"
|
|||||||
command = "/opt/monlet/checks/exit_2.sh"
|
command = "/opt/monlet/checks/exit_2.sh"
|
||||||
interval = "5s"
|
interval = "5s"
|
||||||
timeout = "3s"
|
timeout = "3s"
|
||||||
dedupe_key = "agent-mixed:critical:fail"
|
dedupe_key = "agent-01:critical:fail"
|
||||||
|
|
||||||
[[checks]]
|
[[checks]]
|
||||||
id = "unknown_check"
|
id = "unknown_check"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
hostname = "agent-prometheus-owned"
|
agent_id = "agent-05"
|
||||||
state_dir = "/var/lib/monlet-agent"
|
state_dir = "/var/lib/monlet-agent"
|
||||||
|
|
||||||
[labels]
|
[labels]
|
||||||
@@ -8,7 +8,6 @@ role = "showcase"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://server:8000"
|
url = "http://server:8000"
|
||||||
token = "monlet-dev-token"
|
|
||||||
heartbeat_interval = "5s"
|
heartbeat_interval = "5s"
|
||||||
batch_interval = "3s"
|
batch_interval = "3s"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
hostname = "agent-resolve"
|
agent_id = "agent-02"
|
||||||
state_dir = "/var/lib/monlet-agent"
|
state_dir = "/var/lib/monlet-agent"
|
||||||
|
|
||||||
[labels]
|
[labels]
|
||||||
@@ -8,7 +8,6 @@ role = "showcase"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://server:8000"
|
url = "http://server:8000"
|
||||||
token = "monlet-dev-token"
|
|
||||||
heartbeat_interval = "5s"
|
heartbeat_interval = "5s"
|
||||||
batch_interval = "3s"
|
batch_interval = "3s"
|
||||||
|
|
||||||
@@ -22,4 +21,4 @@ name = "Flapping check (critical then ok)"
|
|||||||
command = "/opt/monlet/checks/flapping.sh"
|
command = "/opt/monlet/checks/flapping.sh"
|
||||||
interval = "5s"
|
interval = "5s"
|
||||||
timeout = "3s"
|
timeout = "3s"
|
||||||
dedupe_key = "agent-resolve:flapping:retry"
|
dedupe_key = "agent-02:flapping:retry"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
hostname = "agent-spool-replay"
|
agent_id = "agent-06"
|
||||||
state_dir = "/var/lib/monlet-agent"
|
state_dir = "/var/lib/monlet-agent"
|
||||||
|
|
||||||
[labels]
|
[labels]
|
||||||
@@ -8,7 +8,6 @@ role = "showcase"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://server:8000"
|
url = "http://server:8000"
|
||||||
token = "monlet-dev-token"
|
|
||||||
heartbeat_interval = "5s"
|
heartbeat_interval = "5s"
|
||||||
batch_interval = "3s"
|
batch_interval = "3s"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
hostname = "agent-stale"
|
agent_id = "agent-07"
|
||||||
state_dir = "/var/lib/monlet-agent"
|
state_dir = "/var/lib/monlet-agent"
|
||||||
|
|
||||||
[labels]
|
[labels]
|
||||||
@@ -8,7 +8,6 @@ role = "showcase"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://server:8000"
|
url = "http://server:8000"
|
||||||
token = "monlet-dev-token"
|
|
||||||
heartbeat_interval = "5s"
|
heartbeat_interval = "5s"
|
||||||
batch_interval = "3s"
|
batch_interval = "3s"
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
hostname = "agent-timeout"
|
agent_id = "agent-04"
|
||||||
state_dir = "/var/lib/monlet-agent"
|
state_dir = "/var/lib/monlet-agent"
|
||||||
|
|
||||||
[labels]
|
[labels]
|
||||||
@@ -8,7 +8,6 @@ role = "showcase"
|
|||||||
[server]
|
[server]
|
||||||
enabled = true
|
enabled = true
|
||||||
url = "http://server:8000"
|
url = "http://server:8000"
|
||||||
token = "monlet-dev-token"
|
|
||||||
heartbeat_interval = "5s"
|
heartbeat_interval = "5s"
|
||||||
batch_interval = "3s"
|
batch_interval = "3s"
|
||||||
|
|
||||||
@@ -22,7 +21,7 @@ name = "Healthy probe"
|
|||||||
command = "/opt/monlet/checks/exit_0.sh"
|
command = "/opt/monlet/checks/exit_0.sh"
|
||||||
interval = "10s"
|
interval = "10s"
|
||||||
timeout = "3s"
|
timeout = "3s"
|
||||||
dedupe_key = "agent-timeout:ok_probe"
|
dedupe_key = "agent-04:ok_probe"
|
||||||
|
|
||||||
[[checks]]
|
[[checks]]
|
||||||
id = "slow_probe"
|
id = "slow_probe"
|
||||||
@@ -30,4 +29,4 @@ name = "Probe that exceeds the timeout"
|
|||||||
command = "/opt/monlet/checks/sleep_long.sh"
|
command = "/opt/monlet/checks/sleep_long.sh"
|
||||||
interval = "10s"
|
interval = "10s"
|
||||||
timeout = "2s"
|
timeout = "2s"
|
||||||
dedupe_key = "agent-timeout:slow_probe"
|
dedupe_key = "agent-04:slow_probe"
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
echo "ok: replay tick"
|
# PH-review: spool-replay scenario needs many DISTINCT events because the
|
||||||
|
# server only persists an event row when the check status TRANSITIONS
|
||||||
|
# (server/monlet_server/services/ingestion.py). Alternate ok/warning on each
|
||||||
|
# run so every tick produces a fresh event for the spool to replay.
|
||||||
|
STATE_FILE=/var/lib/monlet-agent/replay_tick
|
||||||
|
COUNT=$(cat "$STATE_FILE" 2>/dev/null || echo 0)
|
||||||
|
COUNT=$((COUNT + 1))
|
||||||
|
echo "$COUNT" > "$STATE_FILE"
|
||||||
|
if [ $((COUNT % 2)) -eq 0 ]; then
|
||||||
|
echo "warning: replay tick $COUNT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "ok: replay tick $COUNT"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
-- Seed one outbox row with an unknown notifier name so the worker marks it 'discarded'.
|
|
||||||
-- Attached to any existing incident; if none exists yet the script is a no-op.
|
|
||||||
INSERT INTO notification_outbox
|
|
||||||
(id, incident_id, notifier, event_type, state, attempts, next_attempt_at, payload)
|
|
||||||
SELECT
|
|
||||||
gen_random_uuid(),
|
|
||||||
i.id,
|
|
||||||
'showcase-unknown',
|
|
||||||
'firing',
|
|
||||||
'pending',
|
|
||||||
0,
|
|
||||||
now(),
|
|
||||||
'{"showcase":"discarded"}'::jsonb
|
|
||||||
FROM incidents i
|
|
||||||
ORDER BY i.opened_at DESC
|
|
||||||
LIMIT 1;
|
|
||||||
@@ -6,9 +6,22 @@ set -euo pipefail
|
|||||||
|
|
||||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
ROOT="$(cd "$HERE/.." && pwd)"
|
ROOT="$(cd "$HERE/.." && pwd)"
|
||||||
TOKEN="${MONLET_AUTH_TOKEN:-monlet-dev-token}"
|
TOKEN="${MONLET_AUTH_TOKEN:-monlet-ui-dev-token}"
|
||||||
BASE="${MONLET_API_BASE_URL:-http://127.0.0.1:8000}"
|
AGENT_KEY="${MONLET_AGENT_KEY:-monlet-agent-dev-key-0000000000000000}"
|
||||||
COMPOSE=(docker compose -f "$HERE/docker-compose.yml")
|
POSTGRES_HOST_PORT="${MONLET_POSTGRES_HOST_PORT:-15432}"
|
||||||
|
SERVER_HOST_PORT="${MONLET_SERVER_HOST_PORT:-18000}"
|
||||||
|
WEB_HOST_PORT="${MONLET_WEB_HOST_PORT:-13000}"
|
||||||
|
# Export so every `docker compose` call (up/exec/down) sees the UI token env,
|
||||||
|
# including the cleanup trap.
|
||||||
|
export MONLET_AUTH_TOKEN="$TOKEN"
|
||||||
|
export MONLET_POSTGRES_HOST_PORT="$POSTGRES_HOST_PORT"
|
||||||
|
export MONLET_SERVER_HOST_PORT="$SERVER_HOST_PORT"
|
||||||
|
export MONLET_WEB_HOST_PORT="$WEB_HOST_PORT"
|
||||||
|
BASE="${MONLET_API_BASE_URL:-http://127.0.0.1:$SERVER_HOST_PORT}"
|
||||||
|
# PH-review: dedicated project name so smoke does not collide with the
|
||||||
|
# showcase/dev stack (orphan containers, shared postgres volume with a stale
|
||||||
|
# baseline schema, etc.).
|
||||||
|
COMPOSE=(docker compose -p monlet-smoke -f "$HERE/docker-compose.yml")
|
||||||
|
|
||||||
log() { printf "\033[1;36m[smoke]\033[0m %s\n" "$*"; }
|
log() { printf "\033[1;36m[smoke]\033[0m %s\n" "$*"; }
|
||||||
fail() { printf "\033[1;31m[smoke]\033[0m %s\n" "$*" >&2; exit 1; }
|
fail() { printf "\033[1;31m[smoke]\033[0m %s\n" "$*" >&2; exit 1; }
|
||||||
@@ -21,8 +34,11 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
log "starting stack"
|
log "pre-clean previous smoke stack (if any)"
|
||||||
MONLET_AUTH_TOKEN="$TOKEN" "${COMPOSE[@]}" up -d --build postgres server web
|
"${COMPOSE[@]}" down -v --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
log "starting stack (phase 1: postgres+server)"
|
||||||
|
"${COMPOSE[@]}" up -d --build postgres server
|
||||||
|
|
||||||
log "waiting for /api/v1/ready"
|
log "waiting for /api/v1/ready"
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
@@ -37,33 +53,41 @@ curl -sf "$BASE/api/v1/health" | grep -q '"status":"ok"' || fail "health wrong b
|
|||||||
log "GET /metrics"
|
log "GET /metrics"
|
||||||
curl -sf "$BASE/metrics" | grep -q '^monlet_server_' || fail "metrics missing"
|
curl -sf "$BASE/metrics" | grep -q '^monlet_server_' || fail "metrics missing"
|
||||||
|
|
||||||
AUTH=(-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json")
|
log "starting web (phase 2)"
|
||||||
|
"${COMPOSE[@]}" up -d --build web
|
||||||
|
|
||||||
|
AGENT_AUTH=(-H "Authorization: Bearer $AGENT_KEY" -H "Content-Type: application/json")
|
||||||
|
UI_AUTH=(-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json")
|
||||||
|
|
||||||
log "POST heartbeat (examples/heartbeat.json)"
|
log "POST heartbeat (examples/heartbeat.json)"
|
||||||
curl -sf "${AUTH[@]}" -X POST "$BASE/api/v1/heartbeat" --data @"$ROOT/examples/heartbeat.json" \
|
curl -sf "${AGENT_AUTH[@]}" -X POST "$BASE/api/v1/heartbeat" --data @"$ROOT/examples/heartbeat.json" \
|
||||||
| grep -q '"accepted":true' || fail "heartbeat not accepted"
|
| grep -q '"accepted":true' || fail "heartbeat not accepted"
|
||||||
|
|
||||||
|
log "accept pending agent"
|
||||||
|
curl -sf "${UI_AUTH[@]}" -X POST "$BASE/api/v1/agents/host-01.prod/accept" \
|
||||||
|
| grep -q '"count":1' || fail "agent not accepted"
|
||||||
|
|
||||||
log "POST events (examples/event-batch.json)"
|
log "POST events (examples/event-batch.json)"
|
||||||
curl -sf "${AUTH[@]}" -X POST "$BASE/api/v1/events" --data @"$ROOT/examples/event-batch.json" \
|
curl -sf "${AGENT_AUTH[@]}" -X POST "$BASE/api/v1/events" --data @"$ROOT/examples/event-batch.json" \
|
||||||
| grep -q '"accepted":' || fail "event batch not accepted"
|
| grep -q '"accepted":' || fail "event batch not accepted"
|
||||||
|
|
||||||
log "GET /agents has host-01.prod"
|
log "GET /agents has host-01.prod"
|
||||||
curl -sf "${AUTH[@]}" "$BASE/api/v1/agents" | grep -q '"agent_id":"host-01.prod"' \
|
curl -sf "${UI_AUTH[@]}" "$BASE/api/v1/agents" | grep -q '"agent_id":"host-01.prod"' \
|
||||||
|| fail "host-01.prod missing"
|
|| fail "host-01.prod missing"
|
||||||
|
|
||||||
log "GET /checks not empty"
|
log "GET /checks not empty"
|
||||||
curl -sf "${AUTH[@]}" "$BASE/api/v1/checks" | grep -q '"items":\[{' \
|
curl -sf "${UI_AUTH[@]}" "$BASE/api/v1/checks" | grep -q '"items":\[{' \
|
||||||
|| fail "checks empty"
|
|| fail "checks empty"
|
||||||
|
|
||||||
log "GET /incidents not empty (event-batch.json contains a critical event)"
|
log "GET /incidents not empty (event-batch.json contains a critical event)"
|
||||||
curl -sf "${AUTH[@]}" "$BASE/api/v1/incidents?state=open" | grep -q '"items":\[{' \
|
curl -sf "${UI_AUTH[@]}" "$BASE/api/v1/incidents?state=open" | grep -q '"items":\[{' \
|
||||||
|| fail "no open incident"
|
|| fail "no open incident"
|
||||||
|
|
||||||
log "GET /notifiers/outbox not empty"
|
log "GET /notifiers/outbox not empty"
|
||||||
curl -sf "${AUTH[@]}" "$BASE/api/v1/notifiers/outbox" | grep -q '"items":\[{' \
|
curl -sf "${UI_AUTH[@]}" "$BASE/api/v1/notifiers/outbox" | grep -q '"items":\[{' \
|
||||||
|| fail "outbox empty"
|
|| fail "outbox empty"
|
||||||
|
|
||||||
WEB_BASE="${MONLET_WEB_BASE_URL:-http://127.0.0.1:3000}"
|
WEB_BASE="${MONLET_WEB_BASE_URL:-http://127.0.0.1:$WEB_HOST_PORT}"
|
||||||
log "waiting for web $WEB_BASE"
|
log "waiting for web $WEB_BASE"
|
||||||
for i in $(seq 1 60); do
|
for i in $(seq 1 60); do
|
||||||
if curl -sf -o /dev/null "$WEB_BASE/"; then break; fi
|
if curl -sf -o /dev/null "$WEB_BASE/"; then break; fi
|
||||||
@@ -71,8 +95,8 @@ for i in $(seq 1 60); do
|
|||||||
[[ "$i" == "60" ]] && fail "web did not become ready"
|
[[ "$i" == "60" ]] && fail "web did not become ready"
|
||||||
done
|
done
|
||||||
|
|
||||||
log "GET / (web overview)"
|
log "GET / (web root follows redirect to /agents)"
|
||||||
curl -sf "$WEB_BASE/" | grep -q "Overview" || fail "web overview missing 'Overview'"
|
curl -sfL "$WEB_BASE/" | grep -q "host-01.prod" || fail "web root did not render agents list"
|
||||||
|
|
||||||
log "GET /agents (web list)"
|
log "GET /agents (web list)"
|
||||||
curl -sf "$WEB_BASE/agents" | grep -q "host-01.prod" || fail "web /agents missing host-01.prod"
|
curl -sf "$WEB_BASE/agents" | grep -q "host-01.prod" || fail "web /agents missing host-01.prod"
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ Docker image rule:
|
|||||||
|
|
||||||
## Web
|
## Web
|
||||||
|
|
||||||
Web is read-only in v1.
|
Web is read-only for monitoring state in v1. Agent admission and blacklist controls are the explicit write exception.
|
||||||
|
|
||||||
Auth plan:
|
Auth plan:
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ All persistent state lives in PostgreSQL (`agents`, `checks`, `events`, `inciden
|
|||||||
|
|
||||||
- The Monlet PostgreSQL database (logical or physical).
|
- The Monlet PostgreSQL database (logical or physical).
|
||||||
- The server `MONLET_AUTH_TOKEN` and `.env` (kept in your secret store, not in backups of the DB).
|
- The server `MONLET_AUTH_TOKEN` and `.env` (kept in your secret store, not in backups of the DB).
|
||||||
- The agent `config.toml` per host (kept in your config management).
|
- The agent `config.toml` per host and local `state_dir/agent.key` if you need to preserve accepted identity without re-acceptance.
|
||||||
|
|
||||||
## Logical backup (recommended for small deployments)
|
## Logical backup (recommended for small deployments)
|
||||||
|
|
||||||
@@ -31,19 +31,15 @@ The schema is owned by Alembic. Restoring a logical dump from the same Monlet ve
|
|||||||
|
|
||||||
## Retention
|
## Retention
|
||||||
|
|
||||||
- `events` grows unbounded in v1. Decide a retention policy that matches your storage budget (typical: 7–30 days). Stage 6+ may add server-side trimming; until then, run a manual cron:
|
- `events` is partitioned by `received_at` (one monthly partition per range). Retention is controlled by `MONLET_EVENTS_RETENTION_MONTHS` (default 36). The partition maintenance loop drops old partitions on `MONLET_PARTITION_MAINTENANCE_INTERVAL_SEC` — disk is reclaimed by `DROP PARTITION`, not by row-level `DELETE`, so reclaim is bulk-fast and lock-light. Do NOT run row-level `DELETE FROM events` — it bloats the table and conflicts with partition rotation.
|
||||||
|
- `event_ingest_dedup` is the idempotency table; TTL `MONLET_EVENT_DEDUP_RETENTION_DAYS` (default 30) prunes rows in bounded batches each detector tick. After dedup TTL elapses an agent replaying a very old spooled event will be accepted again (no double-side effects because the corresponding `events` partition is already dropped on the same horizon).
|
||||||
```sql
|
- `notification_outbox` retention is bounded by `MONLET_OUTBOX_RETENTION_MAX_ROWS` (default 50000) and pruned in batches of `MONLET_OUTBOX_PRUNE_BATCH_SIZE` per tick (PH-012). Only terminal states (`sent`, `failed`, `discarded`) are eligible — active rows (`pending`, `sending`, `retry`) are never deleted.
|
||||||
DELETE FROM events WHERE observed_at < now() - interval '14 days';
|
- `incidents` is small and not pruned automatically; size scales with operator activity rather than ingestion rate.
|
||||||
```
|
|
||||||
|
|
||||||
`checks.last_event_id` is not a FK, so trimming `events` does not break current state.
|
|
||||||
|
|
||||||
- `incidents` and `notification_outbox` are small. Trim outbox `state IN ('sent','failed','discarded')` rows older than 7 days if storage is tight.
|
|
||||||
|
|
||||||
## Disaster recovery
|
## Disaster recovery
|
||||||
|
|
||||||
- Restore the database.
|
- Restore the database.
|
||||||
- Restart the server. Agents will resume sending; spooled events on each agent are replayed and de-duplicated by `event_id` (ADR-0006).
|
- Restart the server. Agents will resume sending; spooled events on each agent are replayed and de-duplicated by `event_id` (ADR-0006).
|
||||||
- The agent spool is bounded (10 000 events / 50 MiB FIFO, ADR-0005). During a long outage the oldest events get dropped to make room for new ones, so observations from the start of the outage are lost first. Plan recovery time accordingly — for an agent producing ~1 event/s per check, ~10 000 events is roughly 2–3 hours of buffering for a handful of checks.
|
- The agent spool is bounded (10 000 events / 50 MiB FIFO, ADR-0005). During a long outage the oldest events get dropped to make room for new ones, so observations from the start of the outage are lost first. Plan recovery time accordingly — for an agent producing ~1 event/s per check, ~10 000 events is roughly 2–3 hours of buffering for a handful of checks.
|
||||||
|
- Spool overflow and non-retryable (4xx) server rejections are observable via `monlet_agent_events_dropped_total{reason}` with `reason ∈ spool_overflow_events | spool_overflow_bytes | send_non_retryable`. Alert on any non-zero rate. Operator action: investigate the agent log (one warning per ~30s drop burst), the server (for 4xx), and whether spool limits need raising in `state_dir`.
|
||||||
- If agents were also lost, only events that were not yet persisted to the spool file are lost in addition.
|
- If agents were also lost, only events that were not yet persisted to the spool file are lost in addition.
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ A pragmatic list for the first production-ish Monlet stand-up. Adjust per enviro
|
|||||||
## 4. Agent (per host)
|
## 4. Agent (per host)
|
||||||
|
|
||||||
- [ ] Install binary (see `agent/systemd/`).
|
- [ ] Install binary (see `agent/systemd/`).
|
||||||
- [ ] Render `config.toml` from `agent/config.example.toml` with hostname or explicit `agent_id`, and `[server].token`.
|
- [ ] Render `config.toml` from `agent/config.example.toml`; omit `agent_id` to use the OS hostname, or set explicit `agent_id` when the host name is not a stable Monlet identifier.
|
||||||
- [ ] Start under systemd; verify a heartbeat reaches the server (`GET /api/v1/agents`).
|
- [ ] Start under systemd; verify the host appears as `pending` in `GET /api/v1/agents`, then accept it in the UI/API.
|
||||||
- [ ] Run one configured check and verify a row appears in `/api/v1/checks` and an event in `/api/v1/events/query`.
|
- [ ] Run one configured check and verify a row appears in `/api/v1/checks` and an event in `/api/v1/events/query`.
|
||||||
|
|
||||||
## 5. Notifications
|
## 5. Notifications
|
||||||
@@ -41,6 +41,7 @@ A pragmatic list for the first production-ish Monlet stand-up. Adjust per enviro
|
|||||||
## 6. Web UI
|
## 6. Web UI
|
||||||
|
|
||||||
- [ ] Set `MONLET_API_BASE_URL`, `MONLET_API_TOKEN`, and optional `MONLET_WEB_TIME_ZONE` for the web container.
|
- [ ] Set `MONLET_API_BASE_URL`, `MONLET_API_TOKEN`, and optional `MONLET_WEB_TIME_ZONE` for the web container.
|
||||||
|
- [ ] Protect the web UI with either `MONLET_WEB_AUTH_USERNAME`/`MONLET_WEB_AUTH_PASSWORD` or a trusted reverse proxy that sets `X-Forwarded-User` plus `MONLET_WEB_TRUST_PROXY_AUTH=true`.
|
||||||
- [ ] Set optional `MONLET_NOTIFICATION_TIME_ZONE` for human-readable notifier timestamps.
|
- [ ] Set optional `MONLET_NOTIFICATION_TIME_ZONE` for human-readable notifier timestamps.
|
||||||
- [ ] Browse `/`, `/agents`, `/checks`, `/incidents`, `/events`, `/outbox`.
|
- [ ] Browse `/`, `/agents`, `/checks`, `/incidents`, `/events`, `/outbox`.
|
||||||
- [ ] Verify the web image does not log the token (`docker logs` greps clean).
|
- [ ] Verify the web image does not log the token (`docker logs` greps clean).
|
||||||
|
|||||||
@@ -1,20 +1,33 @@
|
|||||||
# Token rotation
|
# Token rotation
|
||||||
|
|
||||||
Monlet v1 uses a single shared Bearer token (`MONLET_AUTH_TOKEN`) for all agent and UI endpoints (ADR-0007). Per-agent tokens and dual-token acceptance are out of scope for v1.
|
Monlet uses split Bearer-token auth (ADR-0007):
|
||||||
|
|
||||||
## Why rotation is disruptive in v1
|
- **UI token** — `MONLET_AUTH_TOKEN` env. Accepted by every UI/operator endpoint. The variable holds one or more space/comma-separated tokens; the server accepts any of them, so rotation is zero-loss.
|
||||||
|
- **Agent key** — generated locally by each agent and stored in `state_dir/agent.key`. It is not configured on the server; operators accept, block, remove, or blacklist keys through the Agents UI/API.
|
||||||
|
|
||||||
The server validates exactly one token at a time. Agents that present the old token after the server has switched receive `401`, which the agent treats as a **non-retryable** failure and drops the affected batch from its local spool (`agent/internal/client/client.go`, `agent/internal/app/app.go`). Events flushed during the mismatch window are lost — they are not re-spooled and not re-sent.
|
Rotate UI tokens independently from agent admission. Changing `MONLET_AUTH_TOKEN` does not affect accepted agents.
|
||||||
|
|
||||||
Plan rotations during a low-traffic window and either stop the agents first (so spooled events persist on disk until they restart with the new token) or accept the event-loss window.
|
## How multi-token overlap works
|
||||||
|
|
||||||
|
`MONLET_AUTH_TOKEN` is a list: tokens separated by spaces and/or commas. Examples:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
MONLET_AUTH_TOKEN="single-token"
|
||||||
|
MONLET_AUTH_TOKEN="old-token new-token"
|
||||||
|
MONLET_AUTH_TOKEN="old-token,new-token"
|
||||||
|
```
|
||||||
|
|
||||||
|
All listed tokens are accepted. The middleware compares in constant time across the full list, so timing-side-channels do not leak which token matched.
|
||||||
|
|
||||||
|
Logs and metrics never include token values.
|
||||||
|
|
||||||
## When to rotate
|
## When to rotate
|
||||||
|
|
||||||
- A suspected leak (token committed, log exposure, lost laptop with `config.toml`).
|
- Suspected UI token leak.
|
||||||
- Operator turnover.
|
- Operator turnover.
|
||||||
- Routine schedule (e.g., quarterly).
|
- Routine schedule (e.g., quarterly).
|
||||||
|
|
||||||
## Procedure
|
## Zero-loss procedure
|
||||||
|
|
||||||
1. **Generate a new token**:
|
1. **Generate a new token**:
|
||||||
|
|
||||||
@@ -22,24 +35,39 @@ Plan rotations during a low-traffic window and either stop the agents first (so
|
|||||||
openssl rand -hex 32
|
openssl rand -hex 32
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Stop or pause agents** (recommended). Either:
|
2. **Add the new token alongside the old one** in the server environment:
|
||||||
- `systemctl stop monlet-agent` on each host; spooled events remain on disk and replay after restart, or
|
|
||||||
- skip this step and accept that any events sent during steps 3–4 are lost on the `401`.
|
|
||||||
|
|
||||||
3. **Restart server** with the new `MONLET_AUTH_TOKEN`. Verify:
|
```sh
|
||||||
|
MONLET_AUTH_TOKEN="$OLD $NEW"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart (or reload) the server. The old token still works, the new one is now accepted as well.
|
||||||
|
|
||||||
|
3. **Move clients to the new token**:
|
||||||
|
- Update Web UI `MONLET_API_TOKEN` and restart.
|
||||||
|
- Update any operator scripts or curl examples to use the new value.
|
||||||
|
|
||||||
|
4. **Verify everything uses the new token**:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -sf -H "Authorization: Bearer $NEW" http://server:8000/api/v1/agents | jq .
|
curl -sf -H "Authorization: Bearer $NEW" http://server:8000/api/v1/agents | jq .
|
||||||
|
# Optional: tail server logs for unauthorized attempts during the window.
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Update agent `config.toml`** (`[server].token`) on each host and start agents.
|
5. **Remove the old token** from the server environment:
|
||||||
|
|
||||||
5. **Update Web UI** (`MONLET_API_TOKEN`) and restart.
|
```sh
|
||||||
|
MONLET_AUTH_TOKEN="$NEW"
|
||||||
|
```
|
||||||
|
|
||||||
6. **Revoke the old token**: remove the old value from secret stores, CI variables, and operator notes.
|
Restart the server. From this point the old token is revoked.
|
||||||
|
|
||||||
|
6. **Scrub the old value** from secret stores, CI variables, and operator notes.
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- The token is the only auth in v1; protect it like a database password.
|
- Tokens are the only auth in v1; protect them like a database password.
|
||||||
- The token never appears in metrics labels, incident keys, or notification labels (ADR-0007, redaction policy).
|
- Tokens never appear in metrics labels, incident keys, or notification labels (ADR-0007, redaction policy).
|
||||||
- The server redacts `Authorization`/`Cookie`-style headers before logging (`monlet_server/redaction.py`).
|
- The server redacts `Authorization`/`Cookie`-style headers before logging (`monlet_server/redaction.py`).
|
||||||
|
- The middleware uses `hmac.compare_digest` against every accepted token; rotation does not introduce a timing oracle.
|
||||||
|
- To rotate an individual agent key, delete `state_dir/agent.key` on that host and restart the agent. The server will show a pending replacement for the same hostname; accept it after verifying the host.
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ MONLET_DATABASE_URL=postgresql+asyncpg://monlet:monlet@localhost:5432/monlet
|
|||||||
MONLET_LOG_LEVEL=INFO
|
MONLET_LOG_LEVEL=INFO
|
||||||
MONLET_BODY_LIMIT_BYTES=1048576
|
MONLET_BODY_LIMIT_BYTES=1048576
|
||||||
MONLET_OUTPUT_MAX_BYTES=8192
|
MONLET_OUTPUT_MAX_BYTES=8192
|
||||||
MONLET_STALE_AFTER_SEC=90
|
MONLET_STALE_AFTER_SEC=20
|
||||||
MONLET_DEAD_AFTER_SEC=300
|
MONLET_DEAD_AFTER_SEC=30
|
||||||
MONLET_DETECTOR_TICK_SEC=15
|
MONLET_DETECTOR_TICK_SEC=5
|
||||||
MONLET_ENABLE_DETECTOR=true
|
MONLET_ENABLE_DETECTOR=true
|
||||||
MONLET_EVENTS_RETENTION_MONTHS=36
|
MONLET_EVENTS_RETENTION_MONTHS=36
|
||||||
|
MONLET_MAX_PENDING_AGENTS=10000
|
||||||
|
MONLET_PENDING_AGENT_TTL_DAYS=7
|
||||||
|
MONLET_PENDING_AGENT_PRUNE_BATCH_SIZE=1000
|
||||||
MONLET_OUTBOX_RETENTION_MAX_ROWS=50000
|
MONLET_OUTBOX_RETENTION_MAX_ROWS=50000
|
||||||
|
|
||||||
MONLET_ENABLE_NOTIFIER_WORKER=true
|
MONLET_ENABLE_NOTIFIER_WORKER=true
|
||||||
|
|||||||
@@ -20,8 +20,17 @@ RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
|
|||||||
FROM python:3.14-slim
|
FROM python:3.14-slim
|
||||||
ENV PATH="/app/.venv/bin:$PATH" \
|
ENV PATH="/app/.venv/bin:$PATH" \
|
||||||
PYTHONUNBUFFERED=1
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# PH-015: non-root runtime user. UID/GID are stable so bind-mounts can be
|
||||||
|
# pre-chowned by operators if they need writable host paths.
|
||||||
|
RUN groupadd --system --gid 1000 monlet \
|
||||||
|
&& useradd --system --uid 1000 --gid monlet --home-dir /app --shell /usr/sbin/nologin monlet
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app /app
|
COPY --from=builder /app /app
|
||||||
|
RUN chown -R monlet:monlet /app
|
||||||
|
|
||||||
|
USER monlet:monlet
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
STOPSIGNAL SIGTERM
|
||||||
CMD ["uvicorn", "monlet_server.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "monlet_server.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -30,18 +30,21 @@ UV_PROJECT_ENVIRONMENT=.venv UV_CACHE_DIR=../.cache/uv uv run fastapi dev monlet
|
|||||||
|
|
||||||
| Var | Default | Purpose |
|
| Var | Default | Purpose |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `MONLET_AUTH_TOKEN` | required | Shared Bearer token (ADR-0007); `changeme` is rejected |
|
| `MONLET_AUTH_TOKEN` | required | UI/operator Bearer token. Space/comma-separated list; any accepted. `changeme` is rejected. |
|
||||||
| `MONLET_DATABASE_URL` | `postgresql+asyncpg://monlet:monlet@127.0.0.1:5432/monlet` | Async DSN |
|
| `MONLET_DATABASE_URL` | `postgresql+asyncpg://monlet:monlet@127.0.0.1:5432/monlet` | Async DSN |
|
||||||
| `MONLET_LOG_LEVEL` | `INFO` | Log level |
|
| `MONLET_LOG_LEVEL` | `INFO` | Log level |
|
||||||
| `MONLET_BODY_LIMIT_BYTES` | `1048576` | Request body limit (ADR-0005) |
|
| `MONLET_BODY_LIMIT_BYTES` | `1048576` | Request body limit (ADR-0005) |
|
||||||
| `MONLET_OUTPUT_MAX_BYTES` | `8192` | Per-event output limit |
|
| `MONLET_OUTPUT_MAX_BYTES` | `8192` | Per-event output limit |
|
||||||
| `MONLET_STALE_AFTER_SEC` | `90` | Stale threshold |
|
| `MONLET_STALE_AFTER_SEC` | `20` | Stale threshold |
|
||||||
| `MONLET_DEAD_AFTER_SEC` | `300` | Dead threshold |
|
| `MONLET_DEAD_AFTER_SEC` | `30` | Dead threshold |
|
||||||
| `MONLET_DETECTOR_TICK_SEC` | `15` | Detector tick |
|
| `MONLET_DETECTOR_TICK_SEC` | `5` | Detector tick |
|
||||||
| `MONLET_ENABLE_DETECTOR` | `true` | Toggle detector task |
|
| `MONLET_ENABLE_DETECTOR` | `true` | Toggle detector task |
|
||||||
| `MONLET_EVENTS_RETENTION_MONTHS` | `36` | Months of `events` partitions to keep; `0` disables drop |
|
| `MONLET_EVENTS_RETENTION_MONTHS` | `36` | Months of `events` partitions to keep; `0` disables drop |
|
||||||
| `MONLET_EVENTS_FUTURE_PARTITIONS` | `3` | Future month partitions to pre-create |
|
| `MONLET_EVENTS_FUTURE_PARTITIONS` | `3` | Future month partitions to pre-create |
|
||||||
| `MONLET_EVENT_DEDUP_RETENTION_DAYS` | `30` | Idempotency window for `event_id` |
|
| `MONLET_EVENT_DEDUP_RETENTION_DAYS` | `30` | Idempotency window for `event_id` |
|
||||||
|
| `MONLET_MAX_PENDING_AGENTS` | `10000` | Maximum pending agent keys before heartbeat rejects with 429 |
|
||||||
|
| `MONLET_PENDING_AGENT_TTL_DAYS` | `7` | Pending key cleanup TTL; `0` disables pruning |
|
||||||
|
| `MONLET_PENDING_AGENT_PRUNE_BATCH_SIZE` | `1000` | Pending cleanup rows per detector tick |
|
||||||
| `MONLET_PARTITION_MAINTENANCE_INTERVAL_SEC` | `3600` | Partition create/drop worker interval |
|
| `MONLET_PARTITION_MAINTENANCE_INTERVAL_SEC` | `3600` | Partition create/drop worker interval |
|
||||||
| `MONLET_OUTBOX_RETENTION_MAX_ROWS` | `50000` | Maximum retained terminal outbox rows; `0` disables pruning |
|
| `MONLET_OUTBOX_RETENTION_MAX_ROWS` | `50000` | Maximum retained terminal outbox rows; `0` disables pruning |
|
||||||
| `MONLET_ENABLE_NOTIFIER_WORKER` | `true` | Toggle outbox worker |
|
| `MONLET_ENABLE_NOTIFIER_WORKER` | `true` | Toggle outbox worker |
|
||||||
@@ -79,4 +82,7 @@ docker build -t monlet-server .
|
|||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
All routes under `/api/v1` (per `api/openapi.yaml`). `/metrics` and `/api/v1/health`, `/api/v1/ready` are open; everything else requires `Authorization: Bearer <token>`.
|
All routes under `/api/v1` (per `api/openapi.yaml`). `/metrics` and `/api/v1/health`, `/api/v1/ready` are open.
|
||||||
|
|
||||||
|
- UI/operator endpoints require `Authorization: Bearer <MONLET_AUTH_TOKEN>`.
|
||||||
|
- Agent ingestion endpoints accept the per-agent key generated and stored by the agent. A new key creates a pending agent; events are ignored until an operator accepts it.
|
||||||
|
|||||||
@@ -26,12 +26,47 @@ def upgrade() -> None:
|
|||||||
last_seen_at TIMESTAMPTZ NOT NULL,
|
last_seen_at TIMESTAMPTZ NOT NULL,
|
||||||
features JSONB NOT NULL DEFAULT jsonb_build_object('push', false, 'metrics', false),
|
features JSONB NOT NULL DEFAULT jsonb_build_object('push', false, 'metrics', false),
|
||||||
labels JSONB NOT NULL DEFAULT '{}'::jsonb,
|
labels JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
accepted_key_hash TEXT,
|
||||||
|
accepted_key_fingerprint TEXT,
|
||||||
|
accepted_at TIMESTAMPTZ,
|
||||||
|
pending_key_hash TEXT,
|
||||||
|
pending_key_fingerprint TEXT,
|
||||||
|
pending_hostname TEXT,
|
||||||
|
pending_seen_at TIMESTAMPTZ,
|
||||||
|
pending_features JSONB,
|
||||||
|
pending_labels JSONB,
|
||||||
|
admission_rank INTEGER NOT NULL GENERATED ALWAYS AS (
|
||||||
|
CASE WHEN pending_key_hash IS NOT NULL THEN 0 ELSE 1 END
|
||||||
|
) STORED
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
op.execute("CREATE INDEX agents_status_idx ON agents (status);")
|
op.execute("CREATE INDEX agents_status_idx ON agents (status);")
|
||||||
op.execute("CREATE INDEX agents_last_seen_idx ON agents (last_seen_at);")
|
op.execute("CREATE INDEX agents_last_seen_idx ON agents (last_seen_at);")
|
||||||
|
op.execute("CREATE INDEX agents_admission_rank_idx ON agents (admission_rank, agent_id);")
|
||||||
|
op.execute("CREATE UNIQUE INDEX agents_hostname_idx ON agents (hostname);")
|
||||||
|
op.execute(
|
||||||
|
"CREATE UNIQUE INDEX agents_accepted_key_hash_idx ON agents (accepted_key_hash) WHERE accepted_key_hash IS NOT NULL;"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE UNIQUE INDEX agents_pending_key_hash_idx ON agents (pending_key_hash) WHERE pending_key_hash IS NOT NULL;"
|
||||||
|
)
|
||||||
|
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE agent_blacklist (
|
||||||
|
key_hash TEXT PRIMARY KEY,
|
||||||
|
key_fingerprint TEXT NOT NULL,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
hostname TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
last_seen_at TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute("CREATE INDEX agent_blacklist_fingerprint_idx ON agent_blacklist (key_fingerprint);")
|
||||||
|
op.execute("CREATE INDEX agent_blacklist_last_seen_idx ON agent_blacklist (last_seen_at);")
|
||||||
|
|
||||||
op.execute(
|
op.execute(
|
||||||
"""
|
"""
|
||||||
@@ -76,6 +111,18 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
op.execute("CREATE INDEX events_observed_at_idx ON events (observed_at DESC);")
|
op.execute("CREATE INDEX events_observed_at_idx ON events (observed_at DESC);")
|
||||||
op.execute("CREATE INDEX events_received_at_idx ON events (received_at DESC);")
|
op.execute("CREATE INDEX events_received_at_idx ON events (received_at DESC);")
|
||||||
|
# PH-010: pagination uses ORDER BY (received_at DESC, event_id DESC). Compound
|
||||||
|
# indexes match the unfiltered and per-agent/per-check filtered queries so the
|
||||||
|
# planner does not fall back to seq-scan + sort on hot partitions.
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX events_received_event_idx ON events (received_at DESC, event_id DESC);"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX events_agent_received_event_idx ON events (agent_id, received_at DESC, event_id DESC);"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX events_check_received_event_idx ON events (check_id, received_at DESC, event_id DESC);"
|
||||||
|
)
|
||||||
|
|
||||||
# Global idempotency table: server enforces event_id uniqueness here, not on the
|
# Global idempotency table: server enforces event_id uniqueness here, not on the
|
||||||
# partitioned events table. Pruned by retention TTL (event_dedup_retention_days).
|
# partitioned events table. Pruned by retention TTL (event_dedup_retention_days).
|
||||||
@@ -91,6 +138,10 @@ def upgrade() -> None:
|
|||||||
"CREATE INDEX event_ingest_dedup_received_at_idx ON event_ingest_dedup (received_at);"
|
"CREATE INDEX event_ingest_dedup_received_at_idx ON event_ingest_dedup (received_at);"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# PH-review: status_rank is a STORED generated column so the default UI sort
|
||||||
|
# (status_rank, opened_at, id) maps to a trivial B-tree index. Keep the
|
||||||
|
# CASE expression in sync with `_STATUS_RANK` in
|
||||||
|
# server/monlet_server/api/incidents.py.
|
||||||
op.execute(
|
op.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE incidents (
|
CREATE TABLE incidents (
|
||||||
@@ -103,14 +154,26 @@ def upgrade() -> None:
|
|||||||
opened_at TIMESTAMPTZ NOT NULL,
|
opened_at TIMESTAMPTZ NOT NULL,
|
||||||
resolved_at TIMESTAMPTZ,
|
resolved_at TIMESTAMPTZ,
|
||||||
summary TEXT,
|
summary TEXT,
|
||||||
last_event_id UUID NOT NULL
|
last_event_id UUID NOT NULL,
|
||||||
|
status_rank INTEGER NOT NULL GENERATED ALWAYS AS (
|
||||||
|
CASE
|
||||||
|
WHEN state = 'open' AND severity = 'critical' THEN 5
|
||||||
|
WHEN state = 'open' AND severity = 'warning' THEN 4
|
||||||
|
WHEN state = 'open' AND severity = 'unknown' THEN 3
|
||||||
|
WHEN state = 'resolved' AND severity = 'critical' THEN 2
|
||||||
|
WHEN state = 'resolved' AND severity = 'warning' THEN 1
|
||||||
|
WHEN state = 'resolved' AND severity = 'unknown' THEN 0
|
||||||
|
END
|
||||||
|
) STORED
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
op.execute(
|
op.execute(
|
||||||
"CREATE UNIQUE INDEX incidents_open_key_idx ON incidents (incident_key) WHERE state = 'open';"
|
"CREATE UNIQUE INDEX incidents_open_key_idx ON incidents (incident_key) WHERE state = 'open';"
|
||||||
)
|
)
|
||||||
op.execute("CREATE INDEX incidents_state_opened_idx ON incidents (state, opened_at DESC);")
|
op.execute(
|
||||||
|
"CREATE INDEX incidents_status_rank_idx ON incidents (status_rank DESC, opened_at DESC, id DESC);"
|
||||||
|
)
|
||||||
|
|
||||||
op.execute(
|
op.execute(
|
||||||
"""
|
"""
|
||||||
@@ -132,6 +195,13 @@ def upgrade() -> None:
|
|||||||
op.execute(
|
op.execute(
|
||||||
"CREATE INDEX outbox_pending_idx ON notification_outbox (state, next_attempt_at) WHERE state IN ('pending','retry');"
|
"CREATE INDEX outbox_pending_idx ON notification_outbox (state, next_attempt_at) WHERE state IN ('pending','retry');"
|
||||||
)
|
)
|
||||||
|
# PH-review: UI lists outbox with cursor pagination ordered by created_at/updated_at.
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX outbox_created_at_idx ON notification_outbox (created_at DESC, id DESC);"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX outbox_updated_at_idx ON notification_outbox (updated_at DESC, id DESC);"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
@@ -140,4 +210,5 @@ def downgrade() -> None:
|
|||||||
op.execute("DROP TABLE IF EXISTS event_ingest_dedup CASCADE;")
|
op.execute("DROP TABLE IF EXISTS event_ingest_dedup CASCADE;")
|
||||||
op.execute("DROP TABLE IF EXISTS events CASCADE;")
|
op.execute("DROP TABLE IF EXISTS events CASCADE;")
|
||||||
op.execute("DROP TABLE IF EXISTS checks CASCADE;")
|
op.execute("DROP TABLE IF EXISTS checks CASCADE;")
|
||||||
|
op.execute("DROP TABLE IF EXISTS agent_blacklist CASCADE;")
|
||||||
op.execute("DROP TABLE IF EXISTS agents CASCADE;")
|
op.execute("DROP TABLE IF EXISTS agents CASCADE;")
|
||||||
|
|||||||
30
server/monlet_server/agent_auth.py
Normal file
30
server/monlet_server/agent_auth.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AgentCredential:
|
||||||
|
key_hash: str
|
||||||
|
key_fingerprint: str
|
||||||
|
|
||||||
|
|
||||||
|
def hash_agent_key(token: str) -> str:
|
||||||
|
return sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def fingerprint_from_hash(key_hash: str) -> str:
|
||||||
|
return key_hash[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def credential_from_token(token: str) -> AgentCredential:
|
||||||
|
key_hash = hash_agent_key(token)
|
||||||
|
return AgentCredential(key_hash=key_hash, key_fingerprint=fingerprint_from_hash(key_hash))
|
||||||
|
|
||||||
|
|
||||||
|
def get_agent_credential(request: Request) -> AgentCredential:
|
||||||
|
cred = getattr(request.state, "agent_credential", None)
|
||||||
|
if not isinstance(cred, AgentCredential):
|
||||||
|
raise RuntimeError("agent credential missing")
|
||||||
|
return cred
|
||||||
@@ -1,49 +1,341 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import select
|
from sqlalchemy import and_, delete, or_, select
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from ..cursors import decode, encode
|
from ..cursors import decode, encode
|
||||||
from ..db import get_session
|
from ..db import get_session
|
||||||
from ..models import Agent as AgentModel
|
from ..models import Agent as AgentModel
|
||||||
from ..schemas import Agent, AgentsPage
|
from ..models import AgentBlacklist, Event, Incident, NotificationOutbox
|
||||||
|
from ..schemas import (
|
||||||
|
ActionCountResponse,
|
||||||
|
Agent,
|
||||||
|
AgentBlacklistItem,
|
||||||
|
AgentBlacklistPage,
|
||||||
|
AgentsPage,
|
||||||
|
)
|
||||||
from ..timeutil import to_utc
|
from ..timeutil import to_utc
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pending(a: AgentModel) -> bool:
|
||||||
|
return a.pending_key_hash is not None
|
||||||
|
|
||||||
|
|
||||||
def _to_schema(a: AgentModel) -> Agent:
|
def _to_schema(a: AgentModel) -> Agent:
|
||||||
|
pending = _is_pending(a)
|
||||||
|
hostname = a.pending_hostname if pending and a.pending_hostname is not None else a.hostname
|
||||||
|
last_seen_at = (
|
||||||
|
a.pending_seen_at if pending and a.pending_seen_at is not None else a.last_seen_at
|
||||||
|
)
|
||||||
|
features = a.pending_features if pending and a.pending_features is not None else a.features
|
||||||
|
labels = a.pending_labels if pending and a.pending_labels is not None else a.labels
|
||||||
return Agent(
|
return Agent(
|
||||||
agent_id=a.agent_id,
|
agent_id=a.agent_id,
|
||||||
hostname=a.hostname,
|
hostname=hostname,
|
||||||
status=a.status,
|
status=a.status,
|
||||||
last_seen_at=to_utc(a.last_seen_at),
|
admission_state="pending" if pending or a.accepted_key_hash is None else "accepted",
|
||||||
features=a.features or {"push": False, "metrics": False},
|
rotation_pending=pending and a.accepted_key_hash is not None,
|
||||||
labels=a.labels or {},
|
key_fingerprint=(a.pending_key_fingerprint if pending else a.accepted_key_fingerprint),
|
||||||
|
last_seen_at=to_utc(last_seen_at),
|
||||||
|
features=features or {"push": False, "metrics": False},
|
||||||
|
labels=labels or {},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _blacklist_schema(row: AgentBlacklist) -> AgentBlacklistItem:
|
||||||
|
return AgentBlacklistItem(
|
||||||
|
id=row.key_hash,
|
||||||
|
key_fingerprint=row.key_fingerprint,
|
||||||
|
agent_id=row.agent_id,
|
||||||
|
hostname=row.hostname,
|
||||||
|
created_at=to_utc(row.created_at),
|
||||||
|
last_seen_at=to_utc(row.last_seen_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_agent(session: AsyncSession, agent_id: str) -> AgentModel:
|
||||||
|
res = await session.execute(select(AgentModel).where(AgentModel.agent_id == agent_id))
|
||||||
|
a = res.scalar_one_or_none()
|
||||||
|
if a is None:
|
||||||
|
raise HTTPException(status_code=404, detail="agent not found")
|
||||||
|
return a
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_pending(a: AgentModel) -> None:
|
||||||
|
a.pending_key_hash = None
|
||||||
|
a.pending_key_fingerprint = None
|
||||||
|
a.pending_hostname = None
|
||||||
|
a.pending_seen_at = None
|
||||||
|
a.pending_features = None
|
||||||
|
a.pending_labels = None
|
||||||
|
|
||||||
|
|
||||||
|
def _accept_pending(a: AgentModel) -> bool:
|
||||||
|
if not _is_pending(a):
|
||||||
|
return False
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
a.accepted_key_hash = a.pending_key_hash
|
||||||
|
a.accepted_key_fingerprint = a.pending_key_fingerprint
|
||||||
|
a.accepted_at = now
|
||||||
|
a.hostname = a.pending_hostname or a.hostname
|
||||||
|
a.last_seen_at = a.pending_seen_at or a.last_seen_at
|
||||||
|
if a.pending_features is not None:
|
||||||
|
a.features = a.pending_features
|
||||||
|
if a.pending_labels is not None:
|
||||||
|
a.labels = a.pending_labels
|
||||||
|
a.status = "alive"
|
||||||
|
_clear_pending(a)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _block_pending(session: AsyncSession, a: AgentModel) -> bool:
|
||||||
|
if not _is_pending(a):
|
||||||
|
return False
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
stmt = pg_insert(AgentBlacklist).values(
|
||||||
|
key_hash=a.pending_key_hash,
|
||||||
|
key_fingerprint=a.pending_key_fingerprint,
|
||||||
|
agent_id=a.agent_id,
|
||||||
|
hostname=a.pending_hostname or a.hostname,
|
||||||
|
last_seen_at=a.pending_seen_at or now,
|
||||||
|
)
|
||||||
|
stmt = stmt.on_conflict_do_update(
|
||||||
|
index_elements=[AgentBlacklist.key_hash],
|
||||||
|
set_={
|
||||||
|
"agent_id": stmt.excluded.agent_id,
|
||||||
|
"hostname": stmt.excluded.hostname,
|
||||||
|
"last_seen_at": stmt.excluded.last_seen_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await session.execute(stmt)
|
||||||
|
_clear_pending(a)
|
||||||
|
if a.accepted_key_hash is None:
|
||||||
|
await _delete_agent_data(session, a.agent_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete_agent_data(session: AsyncSession, agent_id: str) -> None:
|
||||||
|
incident_ids = select(Incident.id).where(Incident.agent_id == agent_id)
|
||||||
|
await session.execute(
|
||||||
|
delete(NotificationOutbox).where(NotificationOutbox.incident_id.in_(incident_ids))
|
||||||
|
)
|
||||||
|
await session.execute(delete(Incident).where(Incident.agent_id == agent_id))
|
||||||
|
await session.execute(delete(Event).where(Event.agent_id == agent_id))
|
||||||
|
await session.execute(delete(AgentModel).where(AgentModel.agent_id == agent_id))
|
||||||
|
|
||||||
|
|
||||||
|
async def _remove_pending_or_agent(session: AsyncSession, a: AgentModel) -> None:
|
||||||
|
if _is_pending(a) and a.accepted_key_hash is not None:
|
||||||
|
_clear_pending(a)
|
||||||
|
return
|
||||||
|
await _delete_agent_data(session, a.agent_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_rank(a: AgentModel) -> int:
|
||||||
|
return a.admission_rank
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_agent_cursor(cursor: str) -> tuple[int, str]:
|
||||||
|
rank_raw, agent_id = decode(cursor, 2)
|
||||||
|
try:
|
||||||
|
rank = int(rank_raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid cursor") from exc
|
||||||
|
if rank not in (0, 1):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid cursor")
|
||||||
|
return rank, agent_id
|
||||||
|
|
||||||
|
|
||||||
@router.get("/agents", response_model=AgentsPage)
|
@router.get("/agents", response_model=AgentsPage)
|
||||||
async def list_agents(
|
async def list_agents(
|
||||||
cursor: str | None = Query(default=None, max_length=512),
|
cursor: str | None = Query(default=None, max_length=512),
|
||||||
|
back: bool = Query(default=False),
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> AgentsPage:
|
) -> AgentsPage:
|
||||||
stmt = select(AgentModel).order_by(AgentModel.agent_id)
|
if back:
|
||||||
|
stmt = select(AgentModel).order_by(
|
||||||
|
AgentModel.admission_rank.desc(), AgentModel.agent_id.desc()
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = select(AgentModel).order_by(
|
||||||
|
AgentModel.admission_rank.asc(), AgentModel.agent_id.asc()
|
||||||
|
)
|
||||||
if cursor is not None:
|
if cursor is not None:
|
||||||
(last_id,) = decode(cursor, 1)
|
last_rank, last_id = _decode_agent_cursor(cursor)
|
||||||
stmt = stmt.where(AgentModel.agent_id > last_id)
|
if back:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
AgentModel.admission_rank < last_rank,
|
||||||
|
and_(
|
||||||
|
AgentModel.admission_rank == last_rank,
|
||||||
|
AgentModel.agent_id < last_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
AgentModel.admission_rank > last_rank,
|
||||||
|
and_(
|
||||||
|
AgentModel.admission_rank == last_rank,
|
||||||
|
AgentModel.agent_id > last_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
||||||
next_cursor = None
|
has_more = len(rows) > limit
|
||||||
if len(rows) > limit:
|
|
||||||
rows = rows[:limit]
|
rows = rows[:limit]
|
||||||
next_cursor = encode(rows[-1].agent_id)
|
if back:
|
||||||
return AgentsPage(items=[_to_schema(a) for a in rows], next_cursor=next_cursor)
|
rows = list(reversed(rows))
|
||||||
|
next_cursor: str | None = None
|
||||||
|
prev_cursor: str | None = None
|
||||||
|
if rows:
|
||||||
|
if back:
|
||||||
|
next_cursor = encode(_agent_rank(rows[-1]), rows[-1].agent_id)
|
||||||
|
if has_more:
|
||||||
|
prev_cursor = encode(_agent_rank(rows[0]), rows[0].agent_id)
|
||||||
|
else:
|
||||||
|
if has_more:
|
||||||
|
next_cursor = encode(_agent_rank(rows[-1]), rows[-1].agent_id)
|
||||||
|
if cursor is not None:
|
||||||
|
prev_cursor = encode(_agent_rank(rows[0]), rows[0].agent_id)
|
||||||
|
return AgentsPage(
|
||||||
|
items=[_to_schema(a) for a in rows],
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
prev_cursor=prev_cursor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/agents/admission/blacklist", response_model=AgentBlacklistPage)
|
||||||
|
async def list_agent_blacklist(
|
||||||
|
cursor: str | None = Query(default=None, max_length=512),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> AgentBlacklistPage:
|
||||||
|
stmt = select(AgentBlacklist).order_by(AgentBlacklist.key_fingerprint, AgentBlacklist.key_hash)
|
||||||
|
if cursor is not None:
|
||||||
|
last_fingerprint, last_hash = decode(cursor, 2)
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
AgentBlacklist.key_fingerprint > last_fingerprint,
|
||||||
|
and_(
|
||||||
|
AgentBlacklist.key_fingerprint == last_fingerprint,
|
||||||
|
AgentBlacklist.key_hash > last_hash,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
rows = rows[:limit]
|
||||||
|
return AgentBlacklistPage(
|
||||||
|
items=[_blacklist_schema(r) for r in rows],
|
||||||
|
next_cursor=encode(rows[-1].key_fingerprint, rows[-1].key_hash)
|
||||||
|
if rows and has_more
|
||||||
|
else None,
|
||||||
|
prev_cursor=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/agents/admission/blacklist", response_model=ActionCountResponse)
|
||||||
|
async def clear_agent_blacklist(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ActionCountResponse:
|
||||||
|
res = await session.execute(delete(AgentBlacklist))
|
||||||
|
await session.commit()
|
||||||
|
return ActionCountResponse(count=res.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/agents/admission/blacklist/{key_id}", response_model=ActionCountResponse)
|
||||||
|
async def delete_agent_blacklist_item(
|
||||||
|
key_id: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ActionCountResponse:
|
||||||
|
res = await session.execute(delete(AgentBlacklist).where(AgentBlacklist.key_hash == key_id))
|
||||||
|
await session.commit()
|
||||||
|
return ActionCountResponse(count=res.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agents/admission/accept-all", response_model=ActionCountResponse)
|
||||||
|
async def accept_all_pending_agents(
|
||||||
|
include_rotation: bool = Query(default=False),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ActionCountResponse:
|
||||||
|
stmt = select(AgentModel).where(AgentModel.pending_key_hash.is_not(None))
|
||||||
|
if not include_rotation:
|
||||||
|
stmt = stmt.where(AgentModel.accepted_key_hash.is_(None))
|
||||||
|
rows = (await session.execute(stmt)).scalars().all()
|
||||||
|
count = sum(1 for a in rows if _accept_pending(a))
|
||||||
|
await session.commit()
|
||||||
|
return ActionCountResponse(count=count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agents/admission/block-all", response_model=ActionCountResponse)
|
||||||
|
async def block_all_pending_agents(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ActionCountResponse:
|
||||||
|
rows = (
|
||||||
|
(await session.execute(select(AgentModel).where(AgentModel.pending_key_hash.is_not(None))))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
count = 0
|
||||||
|
for a in rows:
|
||||||
|
if await _block_pending(session, a):
|
||||||
|
count += 1
|
||||||
|
await session.commit()
|
||||||
|
return ActionCountResponse(count=count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agents/admission/remove-all", response_model=ActionCountResponse)
|
||||||
|
async def remove_all_pending_agents(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> ActionCountResponse:
|
||||||
|
rows = (
|
||||||
|
(await session.execute(select(AgentModel).where(AgentModel.pending_key_hash.is_not(None))))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for agent in rows:
|
||||||
|
await _remove_pending_or_agent(session, agent)
|
||||||
|
await session.commit()
|
||||||
|
return ActionCountResponse(count=len(rows))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agents/{agent_id}/accept", response_model=ActionCountResponse)
|
||||||
|
async def accept_agent(
|
||||||
|
agent_id: str, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> ActionCountResponse:
|
||||||
|
agent = await _get_agent(session, agent_id)
|
||||||
|
count = 1 if _accept_pending(agent) else 0
|
||||||
|
await session.commit()
|
||||||
|
return ActionCountResponse(count=count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agents/{agent_id}/block", response_model=ActionCountResponse)
|
||||||
|
async def block_agent(
|
||||||
|
agent_id: str, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> ActionCountResponse:
|
||||||
|
agent = await _get_agent(session, agent_id)
|
||||||
|
count = 1 if await _block_pending(session, agent) else 0
|
||||||
|
await session.commit()
|
||||||
|
return ActionCountResponse(count=count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/agents/{agent_id}", response_model=ActionCountResponse)
|
||||||
|
async def delete_agent(
|
||||||
|
agent_id: str, session: AsyncSession = Depends(get_session)
|
||||||
|
) -> ActionCountResponse:
|
||||||
|
agent = await _get_agent(session, agent_id)
|
||||||
|
await _remove_pending_or_agent(session, agent)
|
||||||
|
await session.commit()
|
||||||
|
return ActionCountResponse(count=1)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/agents/{agent_id}", response_model=Agent)
|
@router.get("/agents/{agent_id}", response_model=Agent)
|
||||||
async def get_agent(agent_id: str, session: AsyncSession = Depends(get_session)) -> Agent:
|
async def get_agent(agent_id: str, session: AsyncSession = Depends(get_session)) -> Agent:
|
||||||
res = await session.execute(select(AgentModel).where(AgentModel.agent_id == agent_id))
|
return _to_schema(await _get_agent(session, agent_id))
|
||||||
a = res.scalar_one_or_none()
|
|
||||||
if a is None:
|
|
||||||
raise HTTPException(status_code=404, detail="agent not found")
|
|
||||||
return _to_schema(a)
|
|
||||||
|
|||||||
@@ -15,14 +15,27 @@ router = APIRouter()
|
|||||||
async def list_checks(
|
async def list_checks(
|
||||||
agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
|
agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
|
||||||
cursor: str | None = Query(default=None, max_length=512),
|
cursor: str | None = Query(default=None, max_length=512),
|
||||||
|
back: bool = Query(default=False),
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> ChecksPage:
|
) -> ChecksPage:
|
||||||
|
# PH-review: bidirectional cursor. Natural sort is ASC (agent_id, check_id).
|
||||||
|
if back:
|
||||||
|
stmt = select(Check).order_by(Check.agent_id.desc(), Check.check_id.desc())
|
||||||
|
else:
|
||||||
stmt = select(Check).order_by(Check.agent_id, Check.check_id)
|
stmt = select(Check).order_by(Check.agent_id, Check.check_id)
|
||||||
if agent_id is not None:
|
if agent_id is not None:
|
||||||
stmt = stmt.where(Check.agent_id == agent_id)
|
stmt = stmt.where(Check.agent_id == agent_id)
|
||||||
if cursor is not None:
|
if cursor is not None:
|
||||||
last_agent, last_check = decode(cursor, 2)
|
last_agent, last_check = decode(cursor, 2)
|
||||||
|
if back:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Check.agent_id < last_agent,
|
||||||
|
and_(Check.agent_id == last_agent, Check.check_id < last_check),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
Check.agent_id > last_agent,
|
Check.agent_id > last_agent,
|
||||||
@@ -30,10 +43,22 @@ async def list_checks(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
||||||
next_cursor = None
|
has_more = len(rows) > limit
|
||||||
if len(rows) > limit:
|
|
||||||
rows = rows[:limit]
|
rows = rows[:limit]
|
||||||
|
if back:
|
||||||
|
rows = list(reversed(rows))
|
||||||
|
next_cursor: str | None = None
|
||||||
|
prev_cursor: str | None = None
|
||||||
|
if rows:
|
||||||
|
if back:
|
||||||
next_cursor = encode(rows[-1].agent_id, rows[-1].check_id)
|
next_cursor = encode(rows[-1].agent_id, rows[-1].check_id)
|
||||||
|
if has_more:
|
||||||
|
prev_cursor = encode(rows[0].agent_id, rows[0].check_id)
|
||||||
|
else:
|
||||||
|
if has_more:
|
||||||
|
next_cursor = encode(rows[-1].agent_id, rows[-1].check_id)
|
||||||
|
if cursor is not None:
|
||||||
|
prev_cursor = encode(rows[0].agent_id, rows[0].check_id)
|
||||||
items = [
|
items = [
|
||||||
CheckState(
|
CheckState(
|
||||||
agent_id=r.agent_id,
|
agent_id=r.agent_id,
|
||||||
@@ -46,4 +71,4 @@ async def list_checks(
|
|||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
return ChecksPage(items=items, next_cursor=next_cursor)
|
return ChecksPage(items=items, next_cursor=next_cursor, prev_cursor=prev_cursor)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import and_, or_, select
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..agent_auth import AgentCredential, get_agent_credential
|
||||||
from ..db import get_session
|
from ..db import get_session
|
||||||
from ..models import Event
|
from ..models import Event
|
||||||
from ..schemas import (
|
from ..schemas import (
|
||||||
@@ -15,7 +17,7 @@ from ..schemas import (
|
|||||||
EventsPage,
|
EventsPage,
|
||||||
StoredCheckResultEvent,
|
StoredCheckResultEvent,
|
||||||
)
|
)
|
||||||
from ..services.ingestion import ingest_event
|
from ..services.ingestion import accepted_agent_for_key, ingest_event
|
||||||
from ..timeutil import to_utc
|
from ..timeutil import to_utc
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -26,12 +28,14 @@ def _encode_cursor(received_at: datetime, event_id) -> str:
|
|||||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
def _decode_cursor(cursor: str) -> tuple[datetime, str]:
|
def _decode_cursor(cursor: str) -> tuple[datetime, UUID]:
|
||||||
pad = "=" * (-len(cursor) % 4)
|
pad = "=" * (-len(cursor) % 4)
|
||||||
try:
|
try:
|
||||||
raw = base64.urlsafe_b64decode(cursor + pad).decode()
|
raw = base64.urlsafe_b64decode(cursor + pad).decode()
|
||||||
ts_str, eid = raw.split("|", 1)
|
ts_str, eid = raw.split("|", 1)
|
||||||
return to_utc(datetime.fromisoformat(ts_str)), eid
|
# PH-010: decode event_id to UUID before comparing to the UUID column
|
||||||
|
# to avoid silent type-coercion behavior across DB drivers.
|
||||||
|
return to_utc(datetime.fromisoformat(ts_str)), UUID(eid)
|
||||||
except (binascii.Error, ValueError) as exc:
|
except (binascii.Error, ValueError) as exc:
|
||||||
raise HTTPException(status_code=400, detail="invalid cursor") from exc
|
raise HTTPException(status_code=400, detail="invalid cursor") from exc
|
||||||
|
|
||||||
@@ -42,12 +46,18 @@ def _decode_cursor(cursor: str) -> tuple[datetime, str]:
|
|||||||
status_code=status.HTTP_202_ACCEPTED,
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
)
|
)
|
||||||
async def events(
|
async def events(
|
||||||
body: EventBatchRequest, session: AsyncSession = Depends(get_session)
|
body: EventBatchRequest,
|
||||||
|
credential: AgentCredential = Depends(get_agent_credential),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> EventBatchAcceptedResponse:
|
) -> EventBatchAcceptedResponse:
|
||||||
|
agent = await accepted_agent_for_key(session, credential)
|
||||||
|
if agent is None:
|
||||||
|
await session.commit()
|
||||||
|
return EventBatchAcceptedResponse(accepted=0, deduplicated=0)
|
||||||
accepted = 0
|
accepted = 0
|
||||||
deduplicated = 0
|
deduplicated = 0
|
||||||
for ev in body.events:
|
for ev in body.events:
|
||||||
ok = await ingest_event(session, body.agent_id, ev)
|
ok = await ingest_event(session, agent.agent_id, ev)
|
||||||
if ok:
|
if ok:
|
||||||
accepted += 1
|
accepted += 1
|
||||||
else:
|
else:
|
||||||
@@ -61,9 +71,16 @@ async def query_events(
|
|||||||
agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
|
agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
|
||||||
check_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
|
check_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
|
||||||
cursor: str | None = Query(default=None, max_length=512),
|
cursor: str | None = Query(default=None, max_length=512),
|
||||||
|
back: bool = Query(default=False),
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> EventsPage:
|
) -> EventsPage:
|
||||||
|
# PH-review: bidirectional cursor. The natural sort is DESC on
|
||||||
|
# (received_at, event_id); `back=True` reverses the query so we can return
|
||||||
|
# the previous page without an unbounded cursor stack in the URL.
|
||||||
|
if back:
|
||||||
|
stmt = select(Event).order_by(Event.received_at.asc(), Event.event_id.asc())
|
||||||
|
else:
|
||||||
stmt = select(Event).order_by(Event.received_at.desc(), Event.event_id.desc())
|
stmt = select(Event).order_by(Event.received_at.desc(), Event.event_id.desc())
|
||||||
if agent_id is not None:
|
if agent_id is not None:
|
||||||
stmt = stmt.where(Event.agent_id == agent_id)
|
stmt = stmt.where(Event.agent_id == agent_id)
|
||||||
@@ -71,6 +88,14 @@ async def query_events(
|
|||||||
stmt = stmt.where(Event.check_id == check_id)
|
stmt = stmt.where(Event.check_id == check_id)
|
||||||
if cursor is not None:
|
if cursor is not None:
|
||||||
ts, eid = _decode_cursor(cursor)
|
ts, eid = _decode_cursor(cursor)
|
||||||
|
if back:
|
||||||
|
stmt = stmt.where(
|
||||||
|
or_(
|
||||||
|
Event.received_at > ts,
|
||||||
|
and_(Event.received_at == ts, Event.event_id > eid),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
Event.received_at < ts,
|
Event.received_at < ts,
|
||||||
@@ -79,10 +104,22 @@ async def query_events(
|
|||||||
)
|
)
|
||||||
stmt = stmt.limit(limit + 1)
|
stmt = stmt.limit(limit + 1)
|
||||||
rows = (await session.execute(stmt)).scalars().all()
|
rows = (await session.execute(stmt)).scalars().all()
|
||||||
next_cursor = None
|
has_more = len(rows) > limit
|
||||||
if len(rows) > limit:
|
|
||||||
rows = rows[:limit]
|
rows = rows[:limit]
|
||||||
|
if back:
|
||||||
|
rows = list(reversed(rows))
|
||||||
|
next_cursor: str | None = None
|
||||||
|
prev_cursor: str | None = None
|
||||||
|
if rows:
|
||||||
|
if back:
|
||||||
next_cursor = _encode_cursor(rows[-1].received_at, rows[-1].event_id)
|
next_cursor = _encode_cursor(rows[-1].received_at, rows[-1].event_id)
|
||||||
|
if has_more:
|
||||||
|
prev_cursor = _encode_cursor(rows[0].received_at, rows[0].event_id)
|
||||||
|
else:
|
||||||
|
if has_more:
|
||||||
|
next_cursor = _encode_cursor(rows[-1].received_at, rows[-1].event_id)
|
||||||
|
if cursor is not None:
|
||||||
|
prev_cursor = _encode_cursor(rows[0].received_at, rows[0].event_id)
|
||||||
items = [
|
items = [
|
||||||
StoredCheckResultEvent(
|
StoredCheckResultEvent(
|
||||||
event_id=str(r.event_id),
|
event_id=str(r.event_id),
|
||||||
@@ -100,4 +137,4 @@ async def query_events(
|
|||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
return EventsPage(items=items, next_cursor=next_cursor)
|
return EventsPage(items=items, next_cursor=next_cursor, prev_cursor=prev_cursor)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from fastapi import APIRouter, Depends, status
|
from fastapi import APIRouter, Depends, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..agent_auth import AgentCredential, get_agent_credential
|
||||||
from ..db import get_session
|
from ..db import get_session
|
||||||
from ..schemas import AcceptedResponse, HeartbeatRequest
|
from ..schemas import AcceptedResponse, HeartbeatRequest
|
||||||
from ..services.ingestion import upsert_agent_heartbeat
|
from ..services.ingestion import upsert_agent_heartbeat
|
||||||
@@ -14,8 +15,10 @@ router = APIRouter()
|
|||||||
status_code=status.HTTP_202_ACCEPTED,
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
)
|
)
|
||||||
async def heartbeat(
|
async def heartbeat(
|
||||||
body: HeartbeatRequest, session: AsyncSession = Depends(get_session)
|
body: HeartbeatRequest,
|
||||||
|
credential: AgentCredential = Depends(get_agent_credential),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> AcceptedResponse:
|
) -> AcceptedResponse:
|
||||||
await upsert_agent_heartbeat(session, body)
|
await upsert_agent_heartbeat(session, body, credential)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return AcceptedResponse(accepted=True)
|
return AcceptedResponse(accepted=True)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import and_, or_, select
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -5,11 +7,29 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from ..cursors import decode, decode_datetime, encode
|
from ..cursors import decode, decode_datetime, encode
|
||||||
from ..db import get_session
|
from ..db import get_session
|
||||||
from ..models import Incident as IncidentModel
|
from ..models import Incident as IncidentModel
|
||||||
from ..schemas import Incident, IncidentsPage
|
from ..schemas import ID_PATTERN, Incident, IncidentsPage
|
||||||
from ..timeutil import to_utc
|
from ..timeutil import to_utc
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
_SORT_COLUMNS = {
|
||||||
|
"opened_at": IncidentModel.opened_at,
|
||||||
|
"resolved_at": IncidentModel.resolved_at,
|
||||||
|
}
|
||||||
|
SortField = Literal["status", "opened_at", "resolved_at"]
|
||||||
|
|
||||||
|
# PH-review: the rank is materialized as a STORED generated column
|
||||||
|
# `incidents.status_rank` (see models.py / 0001_baseline.py). This map mirrors
|
||||||
|
# the SQL CASE for Python-side cursor encoding only.
|
||||||
|
_STATUS_RANK: dict[tuple[str, str], int] = {
|
||||||
|
("open", "critical"): 5,
|
||||||
|
("open", "warning"): 4,
|
||||||
|
("open", "unknown"): 3,
|
||||||
|
("resolved", "critical"): 2,
|
||||||
|
("resolved", "warning"): 1,
|
||||||
|
("resolved", "unknown"): 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _to_schema(i: IncidentModel) -> Incident:
|
def _to_schema(i: IncidentModel) -> Incident:
|
||||||
return Incident(
|
return Incident(
|
||||||
@@ -25,30 +45,105 @@ def _to_schema(i: IncidentModel) -> Incident:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_incident_cursor(i: IncidentModel, sort: SortField) -> str:
|
||||||
|
if sort == "status":
|
||||||
|
return encode(i.status_rank, to_utc(i.opened_at).isoformat(), i.id)
|
||||||
|
return encode(to_utc(getattr(i, sort)).isoformat(), i.id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/incidents", response_model=IncidentsPage)
|
@router.get("/incidents", response_model=IncidentsPage)
|
||||||
async def list_incidents(
|
async def list_incidents(
|
||||||
state: str | None = Query(default=None),
|
state: Literal["open", "resolved"] | None = Query(default=None),
|
||||||
|
severity: Literal["warning", "critical", "unknown"] | None = Query(default=None),
|
||||||
|
agent_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
|
||||||
|
check_id: str | None = Query(default=None, max_length=128, pattern=ID_PATTERN),
|
||||||
|
sort: SortField = Query(default="status"),
|
||||||
|
direction: Literal["asc", "desc"] = Query(default="desc"),
|
||||||
cursor: str | None = Query(default=None, max_length=512),
|
cursor: str | None = Query(default=None, max_length=512),
|
||||||
|
back: bool = Query(default=False),
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> IncidentsPage:
|
) -> IncidentsPage:
|
||||||
if state is not None and state not in ("open", "resolved"):
|
# PH-001 finding-4: sort=resolved_at must skip open incidents — their
|
||||||
raise HTTPException(status_code=400, detail="invalid state")
|
# resolved_at is NULL and would otherwise break to_utc() on the cursor.
|
||||||
stmt = select(IncidentModel).order_by(IncidentModel.opened_at.desc(), IncidentModel.id.desc())
|
if sort == "resolved_at" and state is None:
|
||||||
|
state = "resolved"
|
||||||
|
if sort == "resolved_at" and state == "open":
|
||||||
|
raise HTTPException(status_code=400, detail="sort=resolved_at requires state=resolved")
|
||||||
|
# PH-review: `back=True` flips the effective sort to walk to the previous
|
||||||
|
# page. We reverse the rows before returning so the response order matches
|
||||||
|
# the canonical (user-requested) direction.
|
||||||
|
effective_desc = (direction == "desc") ^ back
|
||||||
|
if sort == "status":
|
||||||
|
rank_col = IncidentModel.status_rank
|
||||||
|
rank_order = rank_col.desc() if effective_desc else rank_col.asc()
|
||||||
|
opened_order = (
|
||||||
|
IncidentModel.opened_at.desc() if effective_desc else IncidentModel.opened_at.asc()
|
||||||
|
)
|
||||||
|
id_order = IncidentModel.id.desc() if effective_desc else IncidentModel.id.asc()
|
||||||
|
stmt = select(IncidentModel).order_by(rank_order, opened_order, id_order)
|
||||||
|
else:
|
||||||
|
col = _SORT_COLUMNS[sort]
|
||||||
|
order_col = col.desc() if effective_desc else col.asc()
|
||||||
|
tiebreak = IncidentModel.id.desc() if effective_desc else IncidentModel.id.asc()
|
||||||
|
stmt = select(IncidentModel).order_by(order_col, tiebreak)
|
||||||
|
if sort == "resolved_at":
|
||||||
|
stmt = stmt.where(IncidentModel.resolved_at.is_not(None))
|
||||||
if state is not None:
|
if state is not None:
|
||||||
stmt = stmt.where(IncidentModel.state == state)
|
stmt = stmt.where(IncidentModel.state == state)
|
||||||
|
if severity is not None:
|
||||||
|
stmt = stmt.where(IncidentModel.severity == severity)
|
||||||
|
if agent_id is not None:
|
||||||
|
stmt = stmt.where(IncidentModel.agent_id == agent_id)
|
||||||
|
if check_id is not None:
|
||||||
|
stmt = stmt.where(IncidentModel.check_id == check_id)
|
||||||
if cursor is not None:
|
if cursor is not None:
|
||||||
ts_str, last_id = decode(cursor, 2)
|
if sort == "status":
|
||||||
|
rank_str, ts_str, last_id = decode(cursor, 3)
|
||||||
|
try:
|
||||||
|
rank = int(rank_str)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="invalid cursor") from exc
|
||||||
ts = decode_datetime(ts_str)
|
ts = decode_datetime(ts_str)
|
||||||
|
rank_col = IncidentModel.status_rank
|
||||||
|
rank_cmp = rank_col < rank if effective_desc else rank_col > rank
|
||||||
|
opened_cmp = (
|
||||||
|
IncidentModel.opened_at < ts if effective_desc else IncidentModel.opened_at > ts
|
||||||
|
)
|
||||||
|
id_cmp = IncidentModel.id < last_id if effective_desc else IncidentModel.id > last_id
|
||||||
stmt = stmt.where(
|
stmt = stmt.where(
|
||||||
or_(
|
or_(
|
||||||
IncidentModel.opened_at < ts,
|
rank_cmp,
|
||||||
and_(IncidentModel.opened_at == ts, IncidentModel.id < last_id),
|
and_(rank_col == rank, opened_cmp),
|
||||||
|
and_(rank_col == rank, IncidentModel.opened_at == ts, id_cmp),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
else:
|
||||||
|
col = _SORT_COLUMNS[sort]
|
||||||
|
ts_str, last_id = decode(cursor, 2)
|
||||||
|
ts = decode_datetime(ts_str)
|
||||||
|
cmp = col < ts if effective_desc else col > ts
|
||||||
|
tie = IncidentModel.id < last_id if effective_desc else IncidentModel.id > last_id
|
||||||
|
stmt = stmt.where(or_(cmp, and_(col == ts, tie)))
|
||||||
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
||||||
next_cursor = None
|
has_more = len(rows) > limit
|
||||||
if len(rows) > limit:
|
|
||||||
rows = rows[:limit]
|
rows = rows[:limit]
|
||||||
next_cursor = encode(to_utc(rows[-1].opened_at).isoformat(), rows[-1].id)
|
if back:
|
||||||
return IncidentsPage(items=[_to_schema(r) for r in rows], next_cursor=next_cursor)
|
rows = list(reversed(rows))
|
||||||
|
next_cursor: str | None = None
|
||||||
|
prev_cursor: str | None = None
|
||||||
|
if rows:
|
||||||
|
if back:
|
||||||
|
next_cursor = _encode_incident_cursor(rows[-1], sort)
|
||||||
|
if has_more:
|
||||||
|
prev_cursor = _encode_incident_cursor(rows[0], sort)
|
||||||
|
else:
|
||||||
|
if has_more:
|
||||||
|
next_cursor = _encode_incident_cursor(rows[-1], sort)
|
||||||
|
if cursor is not None:
|
||||||
|
prev_cursor = _encode_incident_cursor(rows[0], sort)
|
||||||
|
return IncidentsPage(
|
||||||
|
items=[_to_schema(r) for r in rows],
|
||||||
|
next_cursor=next_cursor,
|
||||||
|
prev_cursor=prev_cursor,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import and_, or_, select
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -10,36 +12,73 @@ from ..timeutil import to_utc
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# PH-002/PH-011/PH-018: explicit sort + filter contracts.
|
||||||
|
OutboxState = Literal["pending", "sending", "sent", "retry", "failed", "discarded"]
|
||||||
|
OutboxEventType = Literal["firing", "resolved"]
|
||||||
|
OutboxNotifier = Literal["debug", "telegram", "webhook", "alertmanager"]
|
||||||
|
_SORT_COLUMNS = {
|
||||||
|
"created_at": NotificationOutbox.created_at,
|
||||||
|
"updated_at": NotificationOutbox.updated_at,
|
||||||
|
}
|
||||||
|
SortField = Literal["created_at", "updated_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_outbox_cursor(row: NotificationOutbox, sort: SortField) -> str:
|
||||||
|
return encode(to_utc(getattr(row, sort)).isoformat(), row.id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/notifiers/outbox", response_model=OutboxPage)
|
@router.get("/notifiers/outbox", response_model=OutboxPage)
|
||||||
async def list_outbox(
|
async def list_outbox(
|
||||||
state: str | None = Query(default=None),
|
state: OutboxState | None = Query(default=None),
|
||||||
|
notifier: OutboxNotifier | None = Query(default=None),
|
||||||
|
event_type: OutboxEventType | None = Query(default=None),
|
||||||
|
sort: SortField = Query(default="updated_at"),
|
||||||
|
direction: Literal["asc", "desc"] = Query(default="desc"),
|
||||||
cursor: str | None = Query(default=None, max_length=512),
|
cursor: str | None = Query(default=None, max_length=512),
|
||||||
|
back: bool = Query(default=False),
|
||||||
limit: int = Query(default=100, ge=1, le=500),
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> OutboxPage:
|
) -> OutboxPage:
|
||||||
stmt = select(NotificationOutbox).order_by(
|
col = _SORT_COLUMNS[sort]
|
||||||
NotificationOutbox.created_at.desc(), NotificationOutbox.id.desc()
|
# PH-review: bidirectional cursor — for `back=True` we run the query in the
|
||||||
)
|
# opposite direction relative to the requested sort and reverse the result
|
||||||
|
# client-side. This avoids the unbounded cursor_stack in the URL.
|
||||||
|
effective_desc = (direction == "desc") ^ back
|
||||||
|
order_col = col.desc() if effective_desc else col.asc()
|
||||||
|
tiebreak = NotificationOutbox.id.desc() if effective_desc else NotificationOutbox.id.asc()
|
||||||
|
stmt = select(NotificationOutbox).order_by(order_col, tiebreak)
|
||||||
if state is not None:
|
if state is not None:
|
||||||
stmt = stmt.where(NotificationOutbox.state == state)
|
stmt = stmt.where(NotificationOutbox.state == state)
|
||||||
|
if notifier is not None:
|
||||||
|
stmt = stmt.where(NotificationOutbox.notifier == notifier)
|
||||||
|
if event_type is not None:
|
||||||
|
stmt = stmt.where(NotificationOutbox.event_type == event_type)
|
||||||
if cursor is not None:
|
if cursor is not None:
|
||||||
ts_str, last_id = decode(cursor, 2)
|
ts_str, last_id = decode(cursor, 2)
|
||||||
ts = decode_datetime(ts_str)
|
ts = decode_datetime(ts_str)
|
||||||
stmt = stmt.where(
|
cmp = col < ts if effective_desc else col > ts
|
||||||
or_(
|
tie = NotificationOutbox.id < last_id if effective_desc else NotificationOutbox.id > last_id
|
||||||
NotificationOutbox.created_at < ts,
|
stmt = stmt.where(or_(cmp, and_(col == ts, tie)))
|
||||||
and_(
|
|
||||||
NotificationOutbox.created_at == ts,
|
|
||||||
NotificationOutbox.id < last_id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
rows = (await session.execute(stmt.limit(limit + 1))).scalars().all()
|
||||||
next_cursor = None
|
has_more = len(rows) > limit
|
||||||
if len(rows) > limit:
|
|
||||||
rows = rows[:limit]
|
rows = rows[:limit]
|
||||||
next_cursor = encode(to_utc(rows[-1].created_at).isoformat(), rows[-1].id)
|
if back:
|
||||||
|
rows = list(reversed(rows))
|
||||||
|
next_cursor: str | None = None
|
||||||
|
prev_cursor: str | None = None
|
||||||
|
if rows:
|
||||||
|
if back:
|
||||||
|
# Came from a forward page → there is always a next page (the one
|
||||||
|
# we just came from). `has_more` here means there are still earlier
|
||||||
|
# pages remaining.
|
||||||
|
next_cursor = _encode_outbox_cursor(rows[-1], sort)
|
||||||
|
if has_more:
|
||||||
|
prev_cursor = _encode_outbox_cursor(rows[0], sort)
|
||||||
|
else:
|
||||||
|
if has_more:
|
||||||
|
next_cursor = _encode_outbox_cursor(rows[-1], sort)
|
||||||
|
if cursor is not None:
|
||||||
|
prev_cursor = _encode_outbox_cursor(rows[0], sort)
|
||||||
return OutboxPage(
|
return OutboxPage(
|
||||||
items=[
|
items=[
|
||||||
NotificationOutboxItem(
|
NotificationOutboxItem(
|
||||||
@@ -56,4 +95,5 @@ async def list_outbox(
|
|||||||
for r in rows
|
for r in rows
|
||||||
],
|
],
|
||||||
next_cursor=next_cursor,
|
next_cursor=next_cursor,
|
||||||
|
prev_cursor=prev_cursor,
|
||||||
)
|
)
|
||||||
|
|||||||
43
server/monlet_server/api/system_summary.py
Normal file
43
server/monlet_server/api/system_summary.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from ..db import get_session
|
||||||
|
from ..models import Agent, Check, Incident
|
||||||
|
from ..schemas import SystemSummaryResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system-summary", response_model=SystemSummaryResponse)
|
||||||
|
async def system_summary(session: AsyncSession = Depends(get_session)) -> SystemSummaryResponse:
|
||||||
|
agents_rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(Agent.status, func.count())
|
||||||
|
.where(Agent.accepted_key_hash.is_not(None))
|
||||||
|
.group_by(Agent.status)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
checks_rows = (
|
||||||
|
await session.execute(select(Check.status, func.count()).group_by(Check.status))
|
||||||
|
).all()
|
||||||
|
open_incidents = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count()).select_from(Incident).where(Incident.state == "open")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
agents = {status: count for status, count in agents_rows}
|
||||||
|
checks = {status: count for status, count in checks_rows}
|
||||||
|
return SystemSummaryResponse(
|
||||||
|
agents_online=agents.get("alive", 0),
|
||||||
|
agents_offline=agents.get("stale", 0) + agents.get("dead", 0),
|
||||||
|
checks_ok=checks.get("ok", 0),
|
||||||
|
checks_warning=checks.get("warning", 0),
|
||||||
|
checks_critical=checks.get("critical", 0),
|
||||||
|
checks_unknown=checks.get("unknown", 0),
|
||||||
|
open_incidents=open_incidents,
|
||||||
|
generated_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
@@ -17,7 +17,17 @@ def get_engine() -> AsyncEngine:
|
|||||||
global _engine, _sessionmaker
|
global _engine, _sessionmaker
|
||||||
if _engine is None:
|
if _engine is None:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
_engine = create_async_engine(s.database_url, pool_pre_ping=True, future=True)
|
# PH-016: explicit pool sizing. pool_pre_ping protects against stale
|
||||||
|
# connections after PG restarts; pool_recycle bounds connection age.
|
||||||
|
_engine = create_async_engine(
|
||||||
|
s.database_url,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_size=s.db_pool_size,
|
||||||
|
max_overflow=s.db_max_overflow,
|
||||||
|
pool_timeout=s.db_pool_timeout_sec,
|
||||||
|
pool_recycle=s.db_pool_recycle_sec,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False)
|
_sessionmaker = async_sessionmaker(_engine, expire_on_commit=False)
|
||||||
return _engine
|
return _engine
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ def error_response(
|
|||||||
_STATUS_TO_CODE: dict[int, ErrorCode] = {
|
_STATUS_TO_CODE: dict[int, ErrorCode] = {
|
||||||
400: "validation",
|
400: "validation",
|
||||||
401: "unauthorized",
|
401: "unauthorized",
|
||||||
|
403: "forbidden",
|
||||||
|
409: "conflict",
|
||||||
404: "not_found",
|
404: "not_found",
|
||||||
413: "payload_too_large",
|
413: "payload_too_large",
|
||||||
429: "rate_limited",
|
429: "rate_limited",
|
||||||
@@ -44,7 +46,11 @@ def install_error_handlers(app: FastAPI) -> None:
|
|||||||
|
|
||||||
@app.exception_handler(StarletteHTTPException)
|
@app.exception_handler(StarletteHTTPException)
|
||||||
async def _http(request: Request, exc: StarletteHTTPException):
|
async def _http(request: Request, exc: StarletteHTTPException):
|
||||||
code = _STATUS_TO_CODE.get(exc.status_code, "internal")
|
code = (
|
||||||
|
"agent_blocked"
|
||||||
|
if exc.status_code == 403 and exc.detail == "agent blocked"
|
||||||
|
else _STATUS_TO_CODE.get(exc.status_code, "internal")
|
||||||
|
)
|
||||||
msg = exc.detail if isinstance(exc.detail, str) else code
|
msg = exc.detail if isinstance(exc.detail, str) else code
|
||||||
return error_response(request, exc.status_code, code, msg)
|
return error_response(request, exc.status_code, code, msg)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
from . import metrics
|
from . import metrics
|
||||||
from .api import agents, checks, events, health, heartbeat, incidents, outbox
|
from .api import agents, checks, events, health, heartbeat, incidents, outbox, system_summary
|
||||||
from .db import dispose_engine, get_engine, get_sessionmaker
|
from .db import dispose_engine, get_engine, get_sessionmaker
|
||||||
from .errors import install_error_handlers
|
from .errors import install_error_handlers
|
||||||
from .logging_config import configure_logging, get_logger
|
from .logging_config import configure_logging, get_logger
|
||||||
@@ -86,6 +86,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(checks.router, prefix="/api/v1")
|
app.include_router(checks.router, prefix="/api/v1")
|
||||||
app.include_router(incidents.router, prefix="/api/v1")
|
app.include_router(incidents.router, prefix="/api/v1")
|
||||||
app.include_router(outbox.router, prefix="/api/v1")
|
app.include_router(outbox.router, prefix="/api/v1")
|
||||||
|
app.include_router(system_summary.router, prefix="/api/v1")
|
||||||
|
|
||||||
@app.get("/metrics")
|
@app.get("/metrics")
|
||||||
async def metrics_endpoint() -> Response:
|
async def metrics_endpoint() -> Response:
|
||||||
|
|||||||
@@ -1,22 +1,42 @@
|
|||||||
|
import hmac
|
||||||
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from ..agent_auth import credential_from_token
|
||||||
from ..errors import error_response
|
from ..errors import error_response
|
||||||
from ..settings import get_settings
|
from ..settings import get_settings
|
||||||
|
|
||||||
NO_AUTH_PATHS = {"/api/v1/health", "/api/v1/ready", "/metrics"}
|
NO_AUTH_PATHS = {"/api/v1/health", "/api/v1/ready", "/metrics"}
|
||||||
|
|
||||||
|
AGENT_PATHS = {"/api/v1/heartbeat", "/api/v1/events"}
|
||||||
|
|
||||||
|
|
||||||
|
def _match_token(presented: str, accepted: list[str]) -> bool:
|
||||||
|
matched = False
|
||||||
|
# Constant-time across all accepted tokens to avoid timing oracle.
|
||||||
|
for t in accepted:
|
||||||
|
if hmac.compare_digest(presented, t):
|
||||||
|
matched = True
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
class BearerAuthMiddleware(BaseHTTPMiddleware):
|
class BearerAuthMiddleware(BaseHTTPMiddleware):
|
||||||
async def dispatch(self, request: Request, call_next):
|
async def dispatch(self, request: Request, call_next):
|
||||||
if request.url.path in NO_AUTH_PATHS:
|
path = request.url.path
|
||||||
|
if path in NO_AUTH_PATHS:
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
expected = get_settings().auth_token
|
|
||||||
header = request.headers.get("authorization")
|
header = request.headers.get("authorization")
|
||||||
if not header or not header.lower().startswith("bearer "):
|
if not header or not header.lower().startswith("bearer "):
|
||||||
return error_response(request, 401, "unauthorized", "authentication required")
|
return error_response(request, 401, "unauthorized", "authentication required")
|
||||||
token = header.split(" ", 1)[1].strip()
|
token = header.split(" ", 1)[1].strip()
|
||||||
if token != expected:
|
|
||||||
|
if path in AGENT_PATHS:
|
||||||
|
if len(token) < 32 or len(token) > 512:
|
||||||
|
return error_response(request, 401, "unauthorized", "authentication required")
|
||||||
|
request.state.agent_credential = credential_from_token(token)
|
||||||
|
else:
|
||||||
|
if not _match_token(token, get_settings().auth_tokens):
|
||||||
return error_response(request, 401, "unauthorized", "authentication required")
|
return error_response(request, 401, "unauthorized", "authentication required")
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy import (
|
|||||||
JSON,
|
JSON,
|
||||||
Boolean,
|
Boolean,
|
||||||
CheckConstraint,
|
CheckConstraint,
|
||||||
|
Computed,
|
||||||
DateTime,
|
DateTime,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
@@ -51,11 +52,57 @@ class Agent(Base):
|
|||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
)
|
)
|
||||||
|
accepted_key_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
accepted_key_fingerprint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
pending_key_hash: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
pending_key_fingerprint: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
pending_hostname: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
pending_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
pending_features: Mapped[dict | None] = mapped_column(_jsonb(), nullable=True)
|
||||||
|
pending_labels: Mapped[dict | None] = mapped_column(_jsonb(), nullable=True)
|
||||||
|
admission_rank: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
Computed("CASE WHEN pending_key_hash IS NOT NULL THEN 0 ELSE 1 END", persisted=True),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("status IN ('alive','stale','dead')", name="agents_status_chk"),
|
CheckConstraint("status IN ('alive','stale','dead')", name="agents_status_chk"),
|
||||||
Index("agents_status_idx", "status"),
|
Index("agents_status_idx", "status"),
|
||||||
Index("agents_last_seen_idx", "last_seen_at"),
|
Index("agents_last_seen_idx", "last_seen_at"),
|
||||||
|
Index("agents_admission_rank_idx", "admission_rank", "agent_id"),
|
||||||
|
Index("agents_hostname_idx", "hostname", unique=True),
|
||||||
|
Index(
|
||||||
|
"agents_accepted_key_hash_idx",
|
||||||
|
"accepted_key_hash",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("accepted_key_hash IS NOT NULL"),
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"agents_pending_key_hash_idx",
|
||||||
|
"pending_key_hash",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("pending_key_hash IS NOT NULL"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBlacklist(Base):
|
||||||
|
__tablename__ = "agent_blacklist"
|
||||||
|
|
||||||
|
key_hash: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||||
|
key_fingerprint: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
agent_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
hostname: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||||
|
)
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("agent_blacklist_fingerprint_idx", "key_fingerprint"),
|
||||||
|
Index("agent_blacklist_last_seen_idx", "last_seen_at"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +164,19 @@ class Event(Base):
|
|||||||
),
|
),
|
||||||
Index("events_observed_at_idx", text("observed_at DESC")),
|
Index("events_observed_at_idx", text("observed_at DESC")),
|
||||||
Index("events_received_at_idx", text("received_at DESC")),
|
Index("events_received_at_idx", text("received_at DESC")),
|
||||||
|
Index("events_received_event_idx", text("received_at DESC"), text("event_id DESC")),
|
||||||
|
Index(
|
||||||
|
"events_agent_received_event_idx",
|
||||||
|
"agent_id",
|
||||||
|
text("received_at DESC"),
|
||||||
|
text("event_id DESC"),
|
||||||
|
),
|
||||||
|
Index(
|
||||||
|
"events_check_received_event_idx",
|
||||||
|
"check_id",
|
||||||
|
text("received_at DESC"),
|
||||||
|
text("event_id DESC"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -144,6 +204,25 @@ class Incident(Base):
|
|||||||
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
last_event_id: Mapped[UUID] = mapped_column(_uuid(), nullable=False)
|
last_event_id: Mapped[UUID] = mapped_column(_uuid(), nullable=False)
|
||||||
|
# PH-review: stored generated column so the default UI sort
|
||||||
|
# (status_rank, opened_at, id) is served by a plain B-tree index without
|
||||||
|
# planner mismatches between query-time and index-time CASE expressions.
|
||||||
|
# Keep aligned with `_STATUS_RANK` in api/incidents.py.
|
||||||
|
status_rank: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
Computed(
|
||||||
|
"CASE "
|
||||||
|
"WHEN state = 'open' AND severity = 'critical' THEN 5 "
|
||||||
|
"WHEN state = 'open' AND severity = 'warning' THEN 4 "
|
||||||
|
"WHEN state = 'open' AND severity = 'unknown' THEN 3 "
|
||||||
|
"WHEN state = 'resolved' AND severity = 'critical' THEN 2 "
|
||||||
|
"WHEN state = 'resolved' AND severity = 'warning' THEN 1 "
|
||||||
|
"WHEN state = 'resolved' AND severity = 'unknown' THEN 0 "
|
||||||
|
"END",
|
||||||
|
persisted=True,
|
||||||
|
),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
CheckConstraint("state IN ('open','resolved')", name="incidents_state_chk"),
|
CheckConstraint("state IN ('open','resolved')", name="incidents_state_chk"),
|
||||||
@@ -156,7 +235,12 @@ class Incident(Base):
|
|||||||
unique=True,
|
unique=True,
|
||||||
postgresql_where=text("state = 'open'"),
|
postgresql_where=text("state = 'open'"),
|
||||||
),
|
),
|
||||||
Index("incidents_state_opened_idx", "state", text("opened_at DESC")),
|
Index(
|
||||||
|
"incidents_status_rank_idx",
|
||||||
|
text("status_rank DESC"),
|
||||||
|
text("opened_at DESC"),
|
||||||
|
text("id DESC"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -193,4 +277,6 @@ class NotificationOutbox(Base):
|
|||||||
"next_attempt_at",
|
"next_attempt_at",
|
||||||
postgresql_where=text("state IN ('pending','retry')"),
|
postgresql_where=text("state IN ('pending','retry')"),
|
||||||
),
|
),
|
||||||
|
Index("outbox_created_at_idx", text("created_at DESC"), text("id DESC")),
|
||||||
|
Index("outbox_updated_at_idx", text("updated_at DESC"), text("id DESC")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,18 +23,22 @@ SENSITIVE_LABEL_WORDS = (
|
|||||||
|
|
||||||
Status = Literal["ok", "warning", "critical", "unknown"]
|
Status = Literal["ok", "warning", "critical", "unknown"]
|
||||||
AgentStatus = Literal["alive", "stale", "dead"]
|
AgentStatus = Literal["alive", "stale", "dead"]
|
||||||
|
AgentAdmissionState = Literal["pending", "accepted"]
|
||||||
IncidentState = Literal["open", "resolved"]
|
IncidentState = Literal["open", "resolved"]
|
||||||
Severity = Literal["warning", "critical", "unknown"]
|
Severity = Literal["warning", "critical", "unknown"]
|
||||||
OutboxState = Literal["pending", "sending", "sent", "retry", "failed", "discarded"]
|
OutboxState = Literal["pending", "sending", "sent", "retry", "failed", "discarded"]
|
||||||
OutboxEventType = Literal["firing", "resolved"]
|
OutboxEventType = Literal["firing", "resolved"]
|
||||||
ErrorCode = Literal[
|
ErrorCode = Literal[
|
||||||
"unauthorized",
|
"unauthorized",
|
||||||
|
"forbidden",
|
||||||
|
"conflict",
|
||||||
"not_found",
|
"not_found",
|
||||||
"validation",
|
"validation",
|
||||||
"payload_too_large",
|
"payload_too_large",
|
||||||
"rate_limited",
|
"rate_limited",
|
||||||
"internal",
|
"internal",
|
||||||
"not_ready",
|
"not_ready",
|
||||||
|
"agent_blocked",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -140,6 +144,9 @@ class Agent(BaseModel):
|
|||||||
agent_id: str
|
agent_id: str
|
||||||
hostname: str
|
hostname: str
|
||||||
status: AgentStatus
|
status: AgentStatus
|
||||||
|
admission_state: AgentAdmissionState
|
||||||
|
rotation_pending: bool = False
|
||||||
|
key_fingerprint: str | None = None
|
||||||
last_seen_at: datetime
|
last_seen_at: datetime
|
||||||
features: AgentFeatures
|
features: AgentFeatures
|
||||||
labels: dict[str, str] = Field(default_factory=dict)
|
labels: dict[str, str] = Field(default_factory=dict)
|
||||||
@@ -186,12 +193,30 @@ class NotificationOutboxItem(BaseModel):
|
|||||||
|
|
||||||
class PageEnvelope(BaseModel):
|
class PageEnvelope(BaseModel):
|
||||||
next_cursor: str | None = None
|
next_cursor: str | None = None
|
||||||
|
prev_cursor: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AgentsPage(PageEnvelope):
|
class AgentsPage(PageEnvelope):
|
||||||
items: list[Agent]
|
items: list[Agent]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBlacklistItem(BaseModel):
|
||||||
|
id: str
|
||||||
|
key_fingerprint: str
|
||||||
|
agent_id: str
|
||||||
|
hostname: str
|
||||||
|
created_at: datetime
|
||||||
|
last_seen_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBlacklistPage(PageEnvelope):
|
||||||
|
items: list[AgentBlacklistItem]
|
||||||
|
|
||||||
|
|
||||||
|
class ActionCountResponse(BaseModel):
|
||||||
|
count: int = Field(ge=0)
|
||||||
|
|
||||||
|
|
||||||
class ChecksPage(PageEnvelope):
|
class ChecksPage(PageEnvelope):
|
||||||
items: list[CheckState]
|
items: list[CheckState]
|
||||||
|
|
||||||
@@ -212,3 +237,14 @@ class UUIDStr(BaseModel):
|
|||||||
"""Helper to validate generated UUIDs."""
|
"""Helper to validate generated UUIDs."""
|
||||||
|
|
||||||
value: UUID
|
value: UUID
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSummaryResponse(BaseModel):
|
||||||
|
agents_online: int
|
||||||
|
agents_offline: int
|
||||||
|
checks_ok: int
|
||||||
|
checks_warning: int
|
||||||
|
checks_critical: int
|
||||||
|
checks_unknown: int
|
||||||
|
open_incidents: int
|
||||||
|
generated_at: datetime
|
||||||
|
|||||||
@@ -202,14 +202,16 @@ async def _apply_liveness(session, agent: Agent, next_status: str, now: datetime
|
|||||||
summary=summary,
|
summary=summary,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
elif changed:
|
||||||
cur.last_observed_at = now
|
# PH-009: update liveness check row only on status transitions to avoid
|
||||||
cur.summary = summary
|
# O(N) per-tick rewrites of every agent_liveness row. last_observed_at
|
||||||
if changed:
|
# then reflects the meaningful transition timestamp, not detector ticks.
|
||||||
cur.status = liveness_status
|
cur.status = liveness_status
|
||||||
cur.exit_code = exit_code
|
cur.exit_code = exit_code
|
||||||
cur.last_event_id = event_id
|
cur.last_event_id = event_id
|
||||||
cur.incident_key = incident_key
|
cur.incident_key = incident_key
|
||||||
|
cur.last_observed_at = now
|
||||||
|
cur.summary = summary
|
||||||
|
|
||||||
if next_status in ("stale", "dead"):
|
if next_status in ("stale", "dead"):
|
||||||
await _open_liveness_incident(session, agent, next_status, now, event_id)
|
await _open_liveness_incident(session, agent, next_status, now, event_id)
|
||||||
@@ -219,6 +221,34 @@ async def _apply_liveness(session, agent: Agent, next_status: str, now: datetime
|
|||||||
|
|
||||||
async def _prune_history(session) -> None:
|
async def _prune_history(session) -> None:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
|
if s.pending_agent_ttl_days > 0:
|
||||||
|
cutoff = datetime.now(UTC) - timedelta(days=s.pending_agent_ttl_days)
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(Agent)
|
||||||
|
.where(
|
||||||
|
Agent.pending_key_hash.is_not(None),
|
||||||
|
Agent.pending_seen_at < cutoff,
|
||||||
|
)
|
||||||
|
.order_by(Agent.pending_seen_at)
|
||||||
|
.limit(max(s.pending_agent_prune_batch_size, 1))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for agent in rows:
|
||||||
|
if agent.accepted_key_hash is None:
|
||||||
|
await session.delete(agent)
|
||||||
|
else:
|
||||||
|
agent.pending_key_hash = None
|
||||||
|
agent.pending_key_fingerprint = None
|
||||||
|
agent.pending_hostname = None
|
||||||
|
agent.pending_seen_at = None
|
||||||
|
agent.pending_features = None
|
||||||
|
agent.pending_labels = None
|
||||||
|
|
||||||
if s.event_dedup_retention_days > 0:
|
if s.event_dedup_retention_days > 0:
|
||||||
cutoff = datetime.now(UTC) - timedelta(days=s.event_dedup_retention_days)
|
cutoff = datetime.now(UTC) - timedelta(days=s.event_dedup_retention_days)
|
||||||
batch = max(s.event_dedup_prune_batch_size, 1)
|
batch = max(s.event_dedup_prune_batch_size, 1)
|
||||||
@@ -233,17 +263,29 @@ async def _prune_history(session) -> None:
|
|||||||
await session.execute(delete(EventIngestDedup).where(EventIngestDedup.event_id.in_(sub)))
|
await session.execute(delete(EventIngestDedup).where(EventIngestDedup.event_id.in_(sub)))
|
||||||
|
|
||||||
if s.outbox_retention_max_rows > 0:
|
if s.outbox_retention_max_rows > 0:
|
||||||
|
# PH-012: keep the N newest terminal rows. Delete in a bounded batch to
|
||||||
|
# avoid one huge DELETE under failure storms. Active rows ('pending',
|
||||||
|
# 'sending', 'retry') are never eligible — terminal-only filter
|
||||||
|
# protects them.
|
||||||
keep_outbox = (
|
keep_outbox = (
|
||||||
select(NotificationOutbox.id)
|
select(NotificationOutbox.id)
|
||||||
.where(NotificationOutbox.state.in_(OUTBOX_TERMINAL_STATES))
|
.where(NotificationOutbox.state.in_(OUTBOX_TERMINAL_STATES))
|
||||||
.order_by(NotificationOutbox.created_at.desc(), NotificationOutbox.id.desc())
|
.order_by(NotificationOutbox.created_at.desc(), NotificationOutbox.id.desc())
|
||||||
.limit(s.outbox_retention_max_rows)
|
.limit(s.outbox_retention_max_rows)
|
||||||
)
|
)
|
||||||
await session.execute(
|
prune_ids = (
|
||||||
delete(NotificationOutbox)
|
select(NotificationOutbox.id)
|
||||||
.where(NotificationOutbox.state.in_(OUTBOX_TERMINAL_STATES))
|
.where(NotificationOutbox.state.in_(OUTBOX_TERMINAL_STATES))
|
||||||
.where(NotificationOutbox.id.not_in(keep_outbox))
|
.where(NotificationOutbox.id.not_in(keep_outbox))
|
||||||
|
.limit(max(s.outbox_prune_batch_size, 1))
|
||||||
|
.scalar_subquery()
|
||||||
)
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(NotificationOutbox).where(NotificationOutbox.id.in_(prune_ids))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DETECTOR_BATCH_SIZE = 500
|
||||||
|
|
||||||
|
|
||||||
async def _tick(sm: async_sessionmaker) -> None:
|
async def _tick(sm: async_sessionmaker) -> None:
|
||||||
@@ -252,7 +294,27 @@ async def _tick(sm: async_sessionmaker) -> None:
|
|||||||
stale_cut = now - timedelta(seconds=s.stale_after_sec)
|
stale_cut = now - timedelta(seconds=s.stale_after_sec)
|
||||||
dead_cut = now - timedelta(seconds=s.dead_after_sec)
|
dead_cut = now - timedelta(seconds=s.dead_after_sec)
|
||||||
async with sm() as session:
|
async with sm() as session:
|
||||||
agents = (await session.execute(select(Agent))).scalars().all()
|
# PH-009: keyset-paginate agents by agent_id to bound per-tick memory
|
||||||
|
# and per-batch transaction work. Each batch is committed separately so
|
||||||
|
# large inventories do not produce one huge transaction.
|
||||||
|
# Race trade-off: a new agent inserted with agent_id < last_seen_id
|
||||||
|
# mid-tick is skipped this tick and processed next tick. That delay is
|
||||||
|
# bounded by detector_tick_sec (5s by default) and is acceptable —
|
||||||
|
# liveness detection has a stale_after_sec / dead_after_sec horizon
|
||||||
|
# measured in tens of seconds, so a one-tick lag is invisible.
|
||||||
|
last_seen_id: str | None = None
|
||||||
|
while True:
|
||||||
|
q = (
|
||||||
|
select(Agent)
|
||||||
|
.where(Agent.accepted_key_hash.is_not(None))
|
||||||
|
.order_by(Agent.agent_id)
|
||||||
|
.limit(DETECTOR_BATCH_SIZE)
|
||||||
|
)
|
||||||
|
if last_seen_id is not None:
|
||||||
|
q = q.where(Agent.agent_id > last_seen_id)
|
||||||
|
agents = (await session.execute(q)).scalars().all()
|
||||||
|
if not agents:
|
||||||
|
break
|
||||||
for agent in agents:
|
for agent in agents:
|
||||||
if agent.last_seen_at <= dead_cut:
|
if agent.last_seen_at <= dead_cut:
|
||||||
next_status = "dead"
|
next_status = "dead"
|
||||||
@@ -263,12 +325,19 @@ async def _tick(sm: async_sessionmaker) -> None:
|
|||||||
if agent.status != next_status:
|
if agent.status != next_status:
|
||||||
agent.status = next_status
|
agent.status = next_status
|
||||||
await _apply_liveness(session, agent, next_status, now)
|
await _apply_liveness(session, agent, next_status, now)
|
||||||
|
last_seen_id = agents[-1].agent_id
|
||||||
|
await session.commit()
|
||||||
|
if len(agents) < DETECTOR_BATCH_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
await _prune_history(session)
|
await _prune_history(session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
for status in ("alive", "stale", "dead"):
|
for status in ("alive", "stale", "dead"):
|
||||||
r = await session.execute(
|
r = await session.execute(
|
||||||
select(func.count()).select_from(Agent).where(Agent.status == status)
|
select(func.count())
|
||||||
|
.select_from(Agent)
|
||||||
|
.where(Agent.accepted_key_hash.is_not(None), Agent.status == status)
|
||||||
)
|
)
|
||||||
metrics.agents_gauge.labels(status=status).set(r.scalar_one())
|
metrics.agents_gauge.labels(status=status).set(r.scalar_one())
|
||||||
r = await session.execute(
|
r = await session.execute(
|
||||||
|
|||||||
@@ -2,13 +2,22 @@ from datetime import UTC, datetime
|
|||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import func, select, text
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from .. import metrics
|
from .. import metrics
|
||||||
|
from ..agent_auth import AgentCredential
|
||||||
from ..logging_config import get_logger
|
from ..logging_config import get_logger
|
||||||
from ..models import Agent, Check, Event, EventIngestDedup, Incident, NotificationOutbox
|
from ..models import (
|
||||||
|
Agent,
|
||||||
|
AgentBlacklist,
|
||||||
|
Check,
|
||||||
|
Event,
|
||||||
|
EventIngestDedup,
|
||||||
|
Incident,
|
||||||
|
NotificationOutbox,
|
||||||
|
)
|
||||||
from ..redaction import redact
|
from ..redaction import redact
|
||||||
from ..schemas import CheckResultEvent, HeartbeatRequest
|
from ..schemas import CheckResultEvent, HeartbeatRequest
|
||||||
from ..settings import get_settings
|
from ..settings import get_settings
|
||||||
@@ -37,28 +46,143 @@ def _incident_key(agent_id: str, ev: CheckResultEvent) -> str:
|
|||||||
return key
|
return key
|
||||||
|
|
||||||
|
|
||||||
async def upsert_agent_heartbeat(session: AsyncSession, hb: HeartbeatRequest) -> None:
|
async def _blacklist_row(session: AsyncSession, cred: AgentCredential) -> AgentBlacklist | None:
|
||||||
|
res = await session.execute(
|
||||||
|
select(AgentBlacklist).where(AgentBlacklist.key_hash == cred.key_hash)
|
||||||
|
)
|
||||||
|
return res.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def reject_if_blocked(
|
||||||
|
session: AsyncSession,
|
||||||
|
cred: AgentCredential,
|
||||||
|
agent_id: str,
|
||||||
|
hostname: str,
|
||||||
|
observed: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
row = await _blacklist_row(session, cred)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
raise HTTPException(status_code=403, detail="agent blocked")
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_pending_capacity(session: AsyncSession) -> None:
|
||||||
|
max_pending = get_settings().max_pending_agents
|
||||||
|
if max_pending <= 0:
|
||||||
|
return
|
||||||
|
count = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count()).select_from(Agent).where(Agent.pending_key_hash.is_not(None))
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
if count >= max_pending:
|
||||||
|
raise HTTPException(status_code=429, detail="too many pending agents")
|
||||||
|
|
||||||
|
|
||||||
|
async def _reject_hostname_collision(session: AsyncSession, agent_id: str, hostname: str) -> None:
|
||||||
|
row = (
|
||||||
|
await session.execute(
|
||||||
|
select(Agent).where(Agent.hostname == hostname, Agent.agent_id != agent_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if row is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="hostname already belongs to another agent")
|
||||||
|
|
||||||
|
|
||||||
|
def _set_pending(
|
||||||
|
agent: Agent,
|
||||||
|
hb: HeartbeatRequest,
|
||||||
|
cred: AgentCredential,
|
||||||
|
observed: datetime,
|
||||||
|
features: dict,
|
||||||
|
) -> None:
|
||||||
|
agent.pending_key_hash = cred.key_hash
|
||||||
|
agent.pending_key_fingerprint = cred.key_fingerprint
|
||||||
|
agent.pending_hostname = hb.hostname
|
||||||
|
agent.pending_seen_at = observed
|
||||||
|
agent.pending_features = features
|
||||||
|
agent.pending_labels = hb.labels
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_agent_heartbeat(
|
||||||
|
session: AsyncSession, hb: HeartbeatRequest, cred: AgentCredential
|
||||||
|
) -> str:
|
||||||
observed = to_utc(hb.observed_at)
|
observed = to_utc(hb.observed_at)
|
||||||
stmt = pg_insert(Agent).values(
|
await reject_if_blocked(session, cred, hb.agent_id, hb.hostname, observed)
|
||||||
|
|
||||||
|
features = hb.features.model_dump()
|
||||||
|
res = await session.execute(select(Agent).where(Agent.accepted_key_hash == cred.key_hash))
|
||||||
|
accepted = res.scalar_one_or_none()
|
||||||
|
if accepted is not None:
|
||||||
|
await _reject_hostname_collision(session, accepted.agent_id, hb.hostname)
|
||||||
|
accepted.hostname = hb.hostname
|
||||||
|
accepted.status = "alive"
|
||||||
|
accepted.last_seen_at = observed
|
||||||
|
accepted.features = features
|
||||||
|
accepted.labels = hb.labels
|
||||||
|
metrics.heartbeats_total.labels(result="ok").inc()
|
||||||
|
return "accepted"
|
||||||
|
|
||||||
|
res = await session.execute(select(Agent).where(Agent.pending_key_hash == cred.key_hash))
|
||||||
|
pending = res.scalar_one_or_none()
|
||||||
|
if pending is not None:
|
||||||
|
await _reject_hostname_collision(session, pending.agent_id, hb.hostname)
|
||||||
|
_set_pending(pending, hb, cred, observed, features)
|
||||||
|
if pending.accepted_key_hash is None:
|
||||||
|
pending.hostname = hb.hostname
|
||||||
|
pending.status = "alive"
|
||||||
|
pending.last_seen_at = observed
|
||||||
|
pending.features = features
|
||||||
|
pending.labels = hb.labels
|
||||||
|
metrics.heartbeats_total.labels(result="pending").inc()
|
||||||
|
return "pending"
|
||||||
|
|
||||||
|
hostname_row = (
|
||||||
|
await session.execute(select(Agent).where(Agent.hostname == hb.hostname))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if hostname_row is not None:
|
||||||
|
if hostname_row.pending_key_hash is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="agent already has a different pending key",
|
||||||
|
)
|
||||||
|
await _ensure_pending_capacity(session)
|
||||||
|
_set_pending(hostname_row, hb, cred, observed, features)
|
||||||
|
metrics.heartbeats_total.labels(result="pending").inc()
|
||||||
|
return "pending"
|
||||||
|
|
||||||
|
agent_id_row = (
|
||||||
|
await session.execute(select(Agent).where(Agent.agent_id == hb.agent_id))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if agent_id_row is not None:
|
||||||
|
raise HTTPException(status_code=409, detail="agent_id already exists")
|
||||||
|
|
||||||
|
await _ensure_pending_capacity(session)
|
||||||
|
session.add(
|
||||||
|
Agent(
|
||||||
agent_id=hb.agent_id,
|
agent_id=hb.agent_id,
|
||||||
hostname=hb.hostname,
|
hostname=hb.hostname,
|
||||||
status="alive",
|
status="alive",
|
||||||
last_seen_at=observed,
|
last_seen_at=observed,
|
||||||
features=hb.features.model_dump(),
|
features=features,
|
||||||
labels=hb.labels,
|
labels=hb.labels,
|
||||||
|
pending_key_hash=cred.key_hash,
|
||||||
|
pending_key_fingerprint=cred.key_fingerprint,
|
||||||
|
pending_hostname=hb.hostname,
|
||||||
|
pending_seen_at=observed,
|
||||||
|
pending_features=features,
|
||||||
|
pending_labels=hb.labels,
|
||||||
)
|
)
|
||||||
stmt = stmt.on_conflict_do_update(
|
|
||||||
index_elements=[Agent.agent_id],
|
|
||||||
set_={
|
|
||||||
"hostname": stmt.excluded.hostname,
|
|
||||||
"status": "alive",
|
|
||||||
"last_seen_at": stmt.excluded.last_seen_at,
|
|
||||||
"features": stmt.excluded.features,
|
|
||||||
"labels": stmt.excluded.labels,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
await session.execute(stmt)
|
metrics.heartbeats_total.labels(result="pending").inc()
|
||||||
metrics.heartbeats_total.labels(result="ok").inc()
|
return "pending"
|
||||||
|
|
||||||
|
|
||||||
|
async def accepted_agent_for_key(session: AsyncSession, cred: AgentCredential) -> Agent | None:
|
||||||
|
if await _blacklist_row(session, cred) is not None:
|
||||||
|
raise HTTPException(status_code=403, detail="agent blocked")
|
||||||
|
res = await session.execute(select(Agent).where(Agent.accepted_key_hash == cred.key_hash))
|
||||||
|
return res.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def _apply_incident(
|
async def _apply_incident(
|
||||||
|
|||||||
@@ -25,6 +25,43 @@ def _backoff(attempts: int) -> timedelta:
|
|||||||
return timedelta(seconds=_BACKOFF_SCHEDULE_SEC[idx])
|
return timedelta(seconds=_BACKOFF_SCHEDULE_SEC[idx])
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_output_policy(payload: dict) -> dict:
|
||||||
|
"""PH-020: enforce a single notifier output policy.
|
||||||
|
|
||||||
|
Per-notifier code keeps its own format-specific shortening (e.g. Telegram
|
||||||
|
300 chars), but a hard cap and include-flag apply consistently before any
|
||||||
|
delivery. Truncation uses a clear marker so receivers can see it happened.
|
||||||
|
"""
|
||||||
|
settings = get_settings()
|
||||||
|
out = dict(payload)
|
||||||
|
text_value = out.get("output")
|
||||||
|
if text_value is None:
|
||||||
|
return out
|
||||||
|
if not settings.notifier_include_output:
|
||||||
|
out["output"] = None
|
||||||
|
out["output_truncated"] = True
|
||||||
|
return out
|
||||||
|
max_bytes = max(settings.notifier_max_output_bytes, 0)
|
||||||
|
encoded = text_value.encode("utf-8")
|
||||||
|
if len(encoded) <= max_bytes:
|
||||||
|
return out
|
||||||
|
# Reserve room for the marker so the final string never exceeds max_bytes.
|
||||||
|
# If max_bytes is smaller than even the marker, drop the body entirely —
|
||||||
|
# this keeps the absolute cap a hard guarantee under any setting.
|
||||||
|
dropped = len(encoded) - max_bytes
|
||||||
|
marker = f"...[truncated {dropped} bytes]"
|
||||||
|
marker_bytes = len(marker.encode("utf-8"))
|
||||||
|
if marker_bytes >= max_bytes:
|
||||||
|
out["output"] = ""
|
||||||
|
out["output_truncated"] = True
|
||||||
|
return out
|
||||||
|
prefix_budget = max_bytes - marker_bytes
|
||||||
|
truncated = encoded[:prefix_budget].decode("utf-8", errors="ignore")
|
||||||
|
out["output"] = f"{truncated}{marker}"
|
||||||
|
out["output_truncated"] = True
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
async def _claim_batch(session, limit: int, lease_sec: int) -> list[dict]:
|
async def _claim_batch(session, limit: int, lease_sec: int) -> list[dict]:
|
||||||
res = await session.execute(
|
res = await session.execute(
|
||||||
text(
|
text(
|
||||||
@@ -107,8 +144,10 @@ async def _process_row(
|
|||||||
metrics.notifications_total.labels(notifier=row["notifier"], result="discarded").inc()
|
metrics.notifications_total.labels(notifier=row["notifier"], result="discarded").inc()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# PH-020: apply central output policy before handing to any notifier.
|
||||||
|
delivery_payload = _apply_output_policy(row["payload"])
|
||||||
try:
|
try:
|
||||||
result = await notifier.deliver(row["event_type"], row["payload"])
|
result = await notifier.deliver(row["event_type"], delivery_payload)
|
||||||
except Exception as exc: # defensive: notifier should not raise
|
except Exception as exc: # defensive: notifier should not raise
|
||||||
result_ok = False
|
result_ok = False
|
||||||
result_perm = False
|
result_perm = False
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ class Settings(BaseSettings):
|
|||||||
body_limit_bytes: int = 1_048_576
|
body_limit_bytes: int = 1_048_576
|
||||||
output_max_bytes: int = 8192
|
output_max_bytes: int = 8192
|
||||||
max_batch_events: int = 200
|
max_batch_events: int = 200
|
||||||
stale_after_sec: int = 90
|
stale_after_sec: int = 20
|
||||||
dead_after_sec: int = 300
|
dead_after_sec: int = 30
|
||||||
detector_tick_sec: int = 15
|
detector_tick_sec: int = 5
|
||||||
host: str = "0.0.0.0"
|
host: str = "0.0.0.0"
|
||||||
port: int = 8000
|
port: int = 8000
|
||||||
enable_detector: bool = True
|
enable_detector: bool = True
|
||||||
@@ -24,8 +24,18 @@ class Settings(BaseSettings):
|
|||||||
events_future_partitions: int = 3
|
events_future_partitions: int = 3
|
||||||
event_dedup_retention_days: int = 30
|
event_dedup_retention_days: int = 30
|
||||||
event_dedup_prune_batch_size: int = 5000
|
event_dedup_prune_batch_size: int = 5000
|
||||||
|
max_pending_agents: int = 10_000
|
||||||
|
pending_agent_ttl_days: int = 7
|
||||||
|
pending_agent_prune_batch_size: int = 1000
|
||||||
partition_maintenance_interval_sec: int = 3600
|
partition_maintenance_interval_sec: int = 3600
|
||||||
outbox_retention_max_rows: int = 50_000
|
outbox_retention_max_rows: int = 50_000
|
||||||
|
# PH-012: bounded prune per maintenance tick to avoid one huge DELETE.
|
||||||
|
outbox_prune_batch_size: int = 5000
|
||||||
|
# PH-016: explicit DB pool sizing for production tuning.
|
||||||
|
db_pool_size: int = 10
|
||||||
|
db_max_overflow: int = 10
|
||||||
|
db_pool_timeout_sec: float = 30.0
|
||||||
|
db_pool_recycle_sec: int = 1800
|
||||||
notifier_tick_sec: int = 5
|
notifier_tick_sec: int = 5
|
||||||
notifier_batch_size: int = 20
|
notifier_batch_size: int = 20
|
||||||
notifier_max_attempts: int = 8
|
notifier_max_attempts: int = 8
|
||||||
@@ -33,6 +43,11 @@ class Settings(BaseSettings):
|
|||||||
notifier_http_timeout_sec: float = 10.0
|
notifier_http_timeout_sec: float = 10.0
|
||||||
notification_time_zone: str = "UTC"
|
notification_time_zone: str = "UTC"
|
||||||
|
|
||||||
|
# PH-020: notifier output policy. include controls whether output text is
|
||||||
|
# sent at all; max_bytes truncates it before delivery (after redaction).
|
||||||
|
notifier_include_output: bool = True
|
||||||
|
notifier_max_output_bytes: int = 1024
|
||||||
|
|
||||||
notifier_debug_enabled: bool = True
|
notifier_debug_enabled: bool = True
|
||||||
notifier_telegram_enabled: bool = False
|
notifier_telegram_enabled: bool = False
|
||||||
notifier_telegram_token: str = ""
|
notifier_telegram_token: str = ""
|
||||||
@@ -43,12 +58,26 @@ class Settings(BaseSettings):
|
|||||||
notifier_alertmanager_enabled: bool = False
|
notifier_alertmanager_enabled: bool = False
|
||||||
notifier_alertmanager_url: str = ""
|
notifier_alertmanager_url: str = ""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _validate_token_list(value: str, env_name: str) -> str:
|
||||||
|
if not value or value == "changeme":
|
||||||
|
raise ValueError(f"{env_name} must be set to a non-default value")
|
||||||
|
tokens = [t for t in value.replace(",", " ").split() if t]
|
||||||
|
if not tokens:
|
||||||
|
raise ValueError(f"{env_name} must contain at least one token")
|
||||||
|
if any(t == "changeme" for t in tokens):
|
||||||
|
raise ValueError(f"{env_name} must be set to a non-default value")
|
||||||
|
return value
|
||||||
|
|
||||||
@field_validator("auth_token")
|
@field_validator("auth_token")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _validate_auth_token(cls, value: str) -> str:
|
def _validate_auth_token(cls, value: str) -> str:
|
||||||
if not value or value == "changeme":
|
return cls._validate_token_list(value, "MONLET_AUTH_TOKEN")
|
||||||
raise ValueError("MONLET_AUTH_TOKEN must be set to a non-default value")
|
|
||||||
return value
|
@property
|
||||||
|
def auth_tokens(self) -> list[str]:
|
||||||
|
"""Active Bearer tokens (>=1). Multiple tokens enable overlap rotation."""
|
||||||
|
return [t for t in self.auth_token.replace(",", " ").split() if t]
|
||||||
|
|
||||||
@field_validator("notification_time_zone")
|
@field_validator("notification_time_zone")
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -49,7 +49,28 @@ def make_heartbeat(agent_id: str = "agent-1") -> dict:
|
|||||||
return {
|
return {
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"observed_at": now_iso(),
|
"observed_at": now_iso(),
|
||||||
"hostname": "host-1",
|
"hostname": agent_id,
|
||||||
"features": {"push": True, "metrics": True},
|
"features": {"push": True, "metrics": True},
|
||||||
"labels": {},
|
"labels": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def agent_token(agent_id: str = "agent-1") -> str:
|
||||||
|
safe = "".join(ch if ch.isalnum() else "0" for ch in agent_id)
|
||||||
|
return f"test-agent-key-{safe}-00000000000000000000000000000000"
|
||||||
|
|
||||||
|
|
||||||
|
def agent_headers(agent_id: str = "agent-1") -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {agent_token(agent_id)}"}
|
||||||
|
|
||||||
|
|
||||||
|
async def register_agent(
|
||||||
|
client, ui_headers: dict[str, str], agent_id: str = "agent-1"
|
||||||
|
) -> dict[str, str]:
|
||||||
|
headers = agent_headers(agent_id)
|
||||||
|
r = await client.post("/api/v1/heartbeat", json=make_heartbeat(agent_id), headers=headers)
|
||||||
|
assert r.status_code == 202
|
||||||
|
r = await client.post(f"/api/v1/agents/{agent_id}/accept", headers=ui_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["count"] == 1
|
||||||
|
return headers
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from testcontainers.postgres import PostgresContainer
|
|||||||
|
|
||||||
from alembic import command
|
from alembic import command
|
||||||
|
|
||||||
os.environ.setdefault("MONLET_AUTH_TOKEN", "test-token")
|
os.environ.setdefault("MONLET_AUTH_TOKEN", "test-ui-token")
|
||||||
os.environ.setdefault("MONLET_ENABLE_DETECTOR", "false")
|
os.environ.setdefault("MONLET_ENABLE_DETECTOR", "false")
|
||||||
os.environ.setdefault("MONLET_ENABLE_NOTIFIER_WORKER", "false")
|
os.environ.setdefault("MONLET_ENABLE_NOTIFIER_WORKER", "false")
|
||||||
|
|
||||||
@@ -97,7 +97,7 @@ async def _truncate_tables(engine) -> AsyncIterator[None]:
|
|||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text(
|
||||||
"TRUNCATE TABLE notification_outbox, incidents, events, event_ingest_dedup, checks, agents RESTART IDENTITY CASCADE"
|
"TRUNCATE TABLE notification_outbox, incidents, events, event_ingest_dedup, checks, agent_blacklist, agents RESTART IDENTITY CASCADE"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,4 +116,11 @@ async def app_client(database_url: str) -> AsyncIterator[AsyncClient]:
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def auth_headers() -> dict[str, str]:
|
def auth_headers() -> dict[str, str]:
|
||||||
return {"Authorization": "Bearer test-token"}
|
from tests._helpers import agent_headers
|
||||||
|
|
||||||
|
return agent_headers()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ui_auth_headers() -> dict[str, str]:
|
||||||
|
return {"Authorization": "Bearer test-ui-token"}
|
||||||
|
|||||||
354
server/tests/test_agent_admission.py
Normal file
354
server/tests/test_agent_admission.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
import pytest
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from monlet_server.models import Agent, AgentBlacklist, Check
|
||||||
|
from monlet_server.services.detector import _prune_history
|
||||||
|
from monlet_server.settings import reset_settings_cache
|
||||||
|
|
||||||
|
from ._helpers import agent_headers, make_event, make_heartbeat, register_agent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_heartbeat_creates_pending_agent(app_client, auth_headers, session):
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/heartbeat", json=make_heartbeat("agent-pending"), headers=auth_headers
|
||||||
|
)
|
||||||
|
assert r.status_code == 202
|
||||||
|
agent = (
|
||||||
|
await session.execute(select(Agent).where(Agent.agent_id == "agent-pending"))
|
||||||
|
).scalar_one()
|
||||||
|
assert agent.pending_key_hash
|
||||||
|
assert agent.accepted_key_hash is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pending_events_are_dropped_until_accept(app_client, auth_headers, session):
|
||||||
|
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
||||||
|
ev = make_event(status="critical", exit_code=2)
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||||
|
)
|
||||||
|
assert r.status_code == 202
|
||||||
|
assert r.json() == {"accepted": 0, "deduplicated": 0}
|
||||||
|
n = (await session.execute(select(func.count()).select_from(Check))).scalar_one()
|
||||||
|
assert n == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_accept_enables_events(app_client, ui_auth_headers, session):
|
||||||
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
|
ev = make_event(status="critical", exit_code=2)
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||||
|
)
|
||||||
|
assert r.json() == {"accepted": 1, "deduplicated": 0}
|
||||||
|
n = (await session.execute(select(func.count()).select_from(Check))).scalar_one()
|
||||||
|
assert n == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_block_pending_key(app_client, auth_headers, ui_auth_headers, session):
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/heartbeat", json=make_heartbeat("agent-block"), headers=auth_headers
|
||||||
|
)
|
||||||
|
r = await app_client.post("/api/v1/agents/agent-block/block", headers=ui_auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["count"] == 1
|
||||||
|
assert (await session.execute(select(func.count()).select_from(Agent))).scalar_one() == 0
|
||||||
|
assert (
|
||||||
|
await session.execute(select(func.count()).select_from(AgentBlacklist))
|
||||||
|
).scalar_one() == 1
|
||||||
|
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/heartbeat", json=make_heartbeat("agent-block"), headers=auth_headers
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json()["error"]["code"] == "agent_blocked"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blacklist_list_delete_and_clear(app_client, auth_headers, ui_auth_headers):
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/heartbeat", json=make_heartbeat("agent-block-a"), headers=auth_headers
|
||||||
|
)
|
||||||
|
await app_client.post("/api/v1/agents/agent-block-a/block", headers=ui_auth_headers)
|
||||||
|
|
||||||
|
other_headers = agent_headers("agent-block-b")
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/heartbeat",
|
||||||
|
json=make_heartbeat("agent-block-b"),
|
||||||
|
headers=other_headers,
|
||||||
|
)
|
||||||
|
await app_client.post("/api/v1/agents/agent-block-b/block", headers=ui_auth_headers)
|
||||||
|
|
||||||
|
r = await app_client.get("/api/v1/agents/admission/blacklist", headers=ui_auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert len(items) == 2
|
||||||
|
|
||||||
|
blocked_id = items[0]["id"]
|
||||||
|
r = await app_client.delete(
|
||||||
|
f"/api/v1/agents/admission/blacklist/{blocked_id}",
|
||||||
|
headers=ui_auth_headers,
|
||||||
|
)
|
||||||
|
assert r.json()["count"] == 1
|
||||||
|
|
||||||
|
r = await app_client.delete("/api/v1/agents/admission/blacklist", headers=ui_auth_headers)
|
||||||
|
assert r.json()["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_accepted_agent_reappears_pending(app_client, ui_auth_headers):
|
||||||
|
auth_headers = await register_agent(app_client, ui_auth_headers, "agent-remove")
|
||||||
|
r = await app_client.delete("/api/v1/agents/agent-remove", headers=ui_auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/heartbeat", json=make_heartbeat("agent-remove"), headers=auth_headers
|
||||||
|
)
|
||||||
|
assert r.status_code == 202
|
||||||
|
r = await app_client.get("/api/v1/agents/agent-remove", headers=ui_auth_headers)
|
||||||
|
assert r.json()["admission_state"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_key_same_hostname_is_pending_replacement(app_client, ui_auth_headers, session):
|
||||||
|
old_headers = await register_agent(app_client, ui_auth_headers, "old-id")
|
||||||
|
hb = make_heartbeat("new-id")
|
||||||
|
hb["hostname"] = "old-id"
|
||||||
|
new_headers = agent_headers("new-id")
|
||||||
|
|
||||||
|
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=new_headers)
|
||||||
|
assert r.status_code == 202
|
||||||
|
r = await app_client.get("/api/v1/agents/old-id", headers=ui_auth_headers)
|
||||||
|
assert r.json()["admission_state"] == "pending"
|
||||||
|
assert r.json()["rotation_pending"] is True
|
||||||
|
|
||||||
|
ev = make_event(status="ok")
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/events", json={"agent_id": "old-id", "events": [ev]}, headers=old_headers
|
||||||
|
)
|
||||||
|
assert r.json()["accepted"] == 1
|
||||||
|
|
||||||
|
r = await app_client.post("/api/v1/agents/old-id/block", headers=ui_auth_headers)
|
||||||
|
assert r.json()["count"] == 1
|
||||||
|
agent = (await session.execute(select(Agent).where(Agent.agent_id == "old-id"))).scalar_one()
|
||||||
|
assert agent.accepted_key_hash
|
||||||
|
assert agent.pending_key_hash is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_accept_all_skips_rotations_without_explicit_flag(
|
||||||
|
app_client, ui_auth_headers, session
|
||||||
|
):
|
||||||
|
await register_agent(app_client, ui_auth_headers, "stable-host")
|
||||||
|
hb = make_heartbeat("replacement-id")
|
||||||
|
hb["hostname"] = "stable-host"
|
||||||
|
await app_client.post("/api/v1/heartbeat", json=hb, headers=agent_headers("replacement"))
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/heartbeat", json=make_heartbeat("new-host"), headers=agent_headers("new-host")
|
||||||
|
)
|
||||||
|
|
||||||
|
r = await app_client.post("/api/v1/agents/admission/accept-all", headers=ui_auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["count"] == 1
|
||||||
|
|
||||||
|
stable = (
|
||||||
|
await session.execute(select(Agent).where(Agent.agent_id == "stable-host"))
|
||||||
|
).scalar_one()
|
||||||
|
assert stable.pending_key_hash is not None
|
||||||
|
new_host = (
|
||||||
|
await session.execute(select(Agent).where(Agent.agent_id == "new-host"))
|
||||||
|
).scalar_one()
|
||||||
|
assert new_host.pending_key_hash is None
|
||||||
|
assert new_host.accepted_key_hash is not None
|
||||||
|
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/agents/admission/accept-all?include_rotation=true",
|
||||||
|
headers=ui_auth_headers,
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["count"] == 1
|
||||||
|
await session.refresh(stable)
|
||||||
|
assert stable.pending_key_hash is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_key_cannot_hijack_existing_pending_hostname(app_client, auth_headers):
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/heartbeat", json=make_heartbeat("pending-host"), headers=auth_headers
|
||||||
|
)
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/heartbeat",
|
||||||
|
json=make_heartbeat("pending-host"),
|
||||||
|
headers=agent_headers("other-key"),
|
||||||
|
)
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["error"]["code"] == "conflict"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_new_key_agent_id_collision_is_rejected(app_client, ui_auth_headers):
|
||||||
|
await register_agent(app_client, ui_auth_headers, "agent-a")
|
||||||
|
await register_agent(app_client, ui_auth_headers, "agent-b")
|
||||||
|
|
||||||
|
hb = make_heartbeat("agent-a")
|
||||||
|
hb["hostname"] = "new-host"
|
||||||
|
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=agent_headers("new-key"))
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["error"]["code"] == "conflict"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_accepted_key_cannot_take_another_hostname(app_client, ui_auth_headers):
|
||||||
|
headers = await register_agent(app_client, ui_auth_headers, "agent-a")
|
||||||
|
await register_agent(app_client, ui_auth_headers, "agent-b")
|
||||||
|
|
||||||
|
hb = make_heartbeat("agent-a")
|
||||||
|
hb["hostname"] = "agent-b"
|
||||||
|
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=headers)
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["error"]["code"] == "conflict"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_hostname_replacement_still_wins_over_body_agent_id(app_client, ui_auth_headers):
|
||||||
|
await register_agent(app_client, ui_auth_headers, "agent-a")
|
||||||
|
await register_agent(app_client, ui_auth_headers, "agent-b")
|
||||||
|
|
||||||
|
hb = make_heartbeat("agent-a")
|
||||||
|
hb["hostname"] = "agent-b"
|
||||||
|
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=agent_headers("new-key"))
|
||||||
|
assert r.status_code == 202
|
||||||
|
|
||||||
|
assert (await app_client.get("/api/v1/agents/agent-b", headers=ui_auth_headers)).json()[
|
||||||
|
"admission_state"
|
||||||
|
] == "pending"
|
||||||
|
assert (await app_client.get("/api/v1/agents/agent-a", headers=ui_auth_headers)).json()[
|
||||||
|
"admission_state"
|
||||||
|
] == "accepted"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_remove_pending_replacement_keeps_accepted_agent(
|
||||||
|
app_client, ui_auth_headers, session
|
||||||
|
):
|
||||||
|
old_headers = await register_agent(app_client, ui_auth_headers, "stable-host")
|
||||||
|
hb = make_heartbeat("replacement-id")
|
||||||
|
hb["hostname"] = "stable-host"
|
||||||
|
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=agent_headers("replacement"))
|
||||||
|
assert r.status_code == 202
|
||||||
|
|
||||||
|
r = await app_client.delete("/api/v1/agents/stable-host", headers=ui_auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
agent = (
|
||||||
|
await session.execute(select(Agent).where(Agent.agent_id == "stable-host"))
|
||||||
|
).scalar_one()
|
||||||
|
assert agent.accepted_key_hash
|
||||||
|
assert agent.pending_key_hash is None
|
||||||
|
|
||||||
|
ev = make_event(status="ok")
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/events", json={"agent_id": "stable-host", "events": [ev]}, headers=old_headers
|
||||||
|
)
|
||||||
|
assert r.json()["accepted"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pending_agent_cap_rejects_new_pending(app_client, auth_headers, monkeypatch):
|
||||||
|
monkeypatch.setenv("MONLET_MAX_PENDING_AGENTS", "1")
|
||||||
|
reset_settings_cache()
|
||||||
|
r = await app_client.post("/api/v1/heartbeat", json=make_heartbeat("one"), headers=auth_headers)
|
||||||
|
assert r.status_code == 202
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/heartbeat", json=make_heartbeat("two"), headers=agent_headers("two")
|
||||||
|
)
|
||||||
|
assert r.status_code == 429
|
||||||
|
assert r.json()["error"]["code"] == "rate_limited"
|
||||||
|
monkeypatch.delenv("MONLET_MAX_PENDING_AGENTS", raising=False)
|
||||||
|
reset_settings_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pending_prune_deletes_pending_only_and_clears_replacement(
|
||||||
|
app_client, ui_auth_headers, session, monkeypatch
|
||||||
|
):
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/heartbeat", json=make_heartbeat("pending-only"), headers=agent_headers("p1")
|
||||||
|
)
|
||||||
|
await register_agent(app_client, ui_auth_headers, "accepted")
|
||||||
|
hb = make_heartbeat("replacement")
|
||||||
|
hb["hostname"] = "accepted"
|
||||||
|
await app_client.post("/api/v1/heartbeat", json=hb, headers=agent_headers("p2"))
|
||||||
|
|
||||||
|
old = datetime.now(UTC) - timedelta(days=2)
|
||||||
|
rows = (
|
||||||
|
(await session.execute(select(Agent).where(Agent.pending_key_hash.is_not(None))))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
row.pending_seen_at = old
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
monkeypatch.setenv("MONLET_PENDING_AGENT_TTL_DAYS", "1")
|
||||||
|
reset_settings_cache()
|
||||||
|
await _prune_history(session)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
await session.execute(select(Agent).where(Agent.agent_id == "pending-only"))
|
||||||
|
).scalar_one_or_none() is None
|
||||||
|
accepted = (
|
||||||
|
await session.execute(select(Agent).where(Agent.agent_id == "accepted"))
|
||||||
|
).scalar_one()
|
||||||
|
assert accepted.accepted_key_hash
|
||||||
|
assert accepted.pending_key_hash is None
|
||||||
|
monkeypatch.delenv("MONLET_PENDING_AGENT_TTL_DAYS", raising=False)
|
||||||
|
reset_settings_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_blacklist_delete_uses_unique_row_id(app_client, ui_auth_headers, session):
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
same_fingerprint = "0123456789abcdef"
|
||||||
|
first_hash = "a" * 64
|
||||||
|
second_hash = "b" * 64
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
AgentBlacklist(
|
||||||
|
key_hash=first_hash,
|
||||||
|
key_fingerprint=same_fingerprint,
|
||||||
|
agent_id="agent-a",
|
||||||
|
hostname="host-a",
|
||||||
|
last_seen_at=now,
|
||||||
|
),
|
||||||
|
AgentBlacklist(
|
||||||
|
key_hash=second_hash,
|
||||||
|
key_fingerprint=same_fingerprint,
|
||||||
|
agent_id="agent-b",
|
||||||
|
hostname="host-b",
|
||||||
|
last_seen_at=now,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
r = await app_client.get("/api/v1/agents/admission/blacklist", headers=ui_auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert [i["id"] for i in items] == [first_hash, second_hash]
|
||||||
|
|
||||||
|
r = await app_client.delete(
|
||||||
|
f"/api/v1/agents/admission/blacklist/{first_hash}",
|
||||||
|
headers=ui_auth_headers,
|
||||||
|
)
|
||||||
|
assert r.json()["count"] == 1
|
||||||
|
|
||||||
|
r = await app_client.get("/api/v1/agents/admission/blacklist", headers=ui_auth_headers)
|
||||||
|
assert [i["id"] for i in r.json()["items"]] == [second_hash]
|
||||||
@@ -30,6 +30,69 @@ async def test_wrong_token_401(app_client):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_valid_token_200(app_client, auth_headers):
|
async def test_valid_token_200(app_client, ui_auth_headers):
|
||||||
r = await app_client.get("/api/v1/agents", headers=auth_headers)
|
r = await app_client.get("/api/v1/agents", headers=ui_auth_headers)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ui_token_is_separate_from_agent_self_key(monkeypatch, database_url):
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from monlet_server import db as db_module
|
||||||
|
from monlet_server.main import create_app
|
||||||
|
from monlet_server.settings import reset_settings_cache
|
||||||
|
from tests._helpers import agent_headers, make_heartbeat
|
||||||
|
|
||||||
|
monkeypatch.setenv("MONLET_AUTH_TOKEN", "ui-token")
|
||||||
|
reset_settings_cache()
|
||||||
|
await db_module.dispose_engine()
|
||||||
|
app = create_app()
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
ui = {"Authorization": "Bearer ui-token"}
|
||||||
|
agent_a = agent_headers("agent-a")
|
||||||
|
agent_b = agent_headers("agent-b")
|
||||||
|
assert (await client.get("/api/v1/agents", headers=ui)).status_code == 200
|
||||||
|
assert (await client.get("/api/v1/agents", headers=agent_a)).status_code == 401
|
||||||
|
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/heartbeat", json=make_heartbeat("agent-a"), headers=agent_a
|
||||||
|
)
|
||||||
|
assert r.status_code == 202
|
||||||
|
r = await client.post(
|
||||||
|
"/api/v1/heartbeat", json=make_heartbeat("agent-b"), headers=agent_b
|
||||||
|
)
|
||||||
|
assert r.status_code == 202
|
||||||
|
r = await client.post("/api/v1/heartbeat", json=make_heartbeat("agent-c"), headers=ui)
|
||||||
|
assert r.status_code == 401
|
||||||
|
await db_module.dispose_engine()
|
||||||
|
monkeypatch.setenv("MONLET_AUTH_TOKEN", "test-ui-token")
|
||||||
|
reset_settings_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_multi_token_overlap(monkeypatch, database_url):
|
||||||
|
"""Two active tokens both authenticate; rotation can overlap without 401s."""
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from monlet_server import db as db_module
|
||||||
|
from monlet_server.main import create_app
|
||||||
|
from monlet_server.settings import reset_settings_cache
|
||||||
|
|
||||||
|
monkeypatch.setenv("MONLET_AUTH_TOKEN", "old-token new-token")
|
||||||
|
reset_settings_cache()
|
||||||
|
await db_module.dispose_engine()
|
||||||
|
app = create_app()
|
||||||
|
async with app.router.lifespan_context(app):
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||||
|
for tok in ("old-token", "new-token"):
|
||||||
|
r = await client.get("/api/v1/agents", headers={"Authorization": f"Bearer {tok}"})
|
||||||
|
assert r.status_code == 200, tok
|
||||||
|
r = await client.get("/api/v1/agents", headers={"Authorization": "Bearer revoked"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
await db_module.dispose_engine()
|
||||||
|
monkeypatch.setenv("MONLET_AUTH_TOKEN", "test-ui-token")
|
||||||
|
reset_settings_cache()
|
||||||
|
|||||||
@@ -9,20 +9,14 @@ from monlet_server.models import Agent, Check, Event, Incident, NotificationOutb
|
|||||||
from monlet_server.services.detector import LIVENESS_CHECK_ID, _tick
|
from monlet_server.services.detector import LIVENESS_CHECK_ID, _tick
|
||||||
from monlet_server.settings import reset_settings_cache
|
from monlet_server.settings import reset_settings_cache
|
||||||
|
|
||||||
from ._helpers import make_event, make_heartbeat
|
from ._helpers import make_event, register_agent
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_detector_transitions(app_client, auth_headers, engine, session):
|
async def test_detector_transitions(app_client, ui_auth_headers, engine, session):
|
||||||
await app_client.post(
|
await register_agent(app_client, ui_auth_headers, "agent-alive")
|
||||||
"/api/v1/heartbeat", json=make_heartbeat("agent-alive"), headers=auth_headers
|
await register_agent(app_client, ui_auth_headers, "agent-stale")
|
||||||
)
|
await register_agent(app_client, ui_auth_headers, "agent-dead")
|
||||||
await app_client.post(
|
|
||||||
"/api/v1/heartbeat", json=make_heartbeat("agent-stale"), headers=auth_headers
|
|
||||||
)
|
|
||||||
await app_client.post(
|
|
||||||
"/api/v1/heartbeat", json=make_heartbeat("agent-dead"), headers=auth_headers
|
|
||||||
)
|
|
||||||
|
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
await session.execute(
|
await session.execute(
|
||||||
@@ -82,10 +76,57 @@ async def test_detector_transitions(app_client, auth_headers, engine, session):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_detector_owns_liveness_check_and_events(app_client, auth_headers, engine, session):
|
async def test_detector_noop_tick_does_not_rewrite_liveness(
|
||||||
await app_client.post(
|
app_client, ui_auth_headers, engine, session
|
||||||
"/api/v1/heartbeat", json=make_heartbeat("agent-flap"), headers=auth_headers
|
):
|
||||||
|
"""PH-009: when no agent's liveness status changes, the detector must not
|
||||||
|
rewrite the liveness Check rows. We verify by tagging last_observed_at to a
|
||||||
|
sentinel before the tick and checking the row was untouched."""
|
||||||
|
await register_agent(app_client, ui_auth_headers, "agent-still")
|
||||||
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
# Force a transition first so a liveness Check row exists.
|
||||||
|
await session.execute(
|
||||||
|
update(Agent)
|
||||||
|
.where(Agent.agent_id == "agent-still")
|
||||||
|
.values(last_seen_at=datetime.now(UTC) - timedelta(minutes=10))
|
||||||
)
|
)
|
||||||
|
await session.commit()
|
||||||
|
await _tick(sm)
|
||||||
|
session.expire_all()
|
||||||
|
row_before = (
|
||||||
|
await session.execute(
|
||||||
|
select(Check).where(
|
||||||
|
Check.agent_id == "agent-still", Check.check_id == LIVENESS_CHECK_ID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
sentinel = datetime(2030, 1, 1, tzinfo=UTC)
|
||||||
|
await session.execute(
|
||||||
|
update(Check)
|
||||||
|
.where(Check.agent_id == "agent-still", Check.check_id == LIVENESS_CHECK_ID)
|
||||||
|
.values(last_observed_at=sentinel)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
# Second tick with no liveness change: row must NOT be rewritten.
|
||||||
|
await _tick(sm)
|
||||||
|
session.expire_all()
|
||||||
|
row_after = (
|
||||||
|
await session.execute(
|
||||||
|
select(Check).where(
|
||||||
|
Check.agent_id == "agent-still", Check.check_id == LIVENESS_CHECK_ID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert row_after.last_observed_at == sentinel
|
||||||
|
assert row_after.last_event_id == row_before.last_event_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_detector_owns_liveness_check_and_events(
|
||||||
|
app_client, ui_auth_headers, engine, session
|
||||||
|
):
|
||||||
|
await register_agent(app_client, ui_auth_headers, "agent-flap")
|
||||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
# Tick 1: alive → Check exists with status=ok, one event.
|
# Tick 1: alive → Check exists with status=ok, one event.
|
||||||
@@ -144,13 +185,11 @@ async def test_detector_owns_liveness_check_and_events(app_client, auth_headers,
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_detector_prunes_outbox(app_client, auth_headers, engine, session, monkeypatch):
|
async def test_detector_prunes_outbox(app_client, ui_auth_headers, engine, session, monkeypatch):
|
||||||
monkeypatch.setenv("MONLET_OUTBOX_RETENTION_MAX_ROWS", "2")
|
monkeypatch.setenv("MONLET_OUTBOX_RETENTION_MAX_ROWS", "2")
|
||||||
reset_settings_cache()
|
reset_settings_cache()
|
||||||
try:
|
try:
|
||||||
await app_client.post(
|
auth_headers = await register_agent(app_client, ui_auth_headers, "agent-prune")
|
||||||
"/api/v1/heartbeat", json=make_heartbeat("agent-prune"), headers=auth_headers
|
|
||||||
)
|
|
||||||
critical = make_event(check_id="critical_prune", status="critical", exit_code=2)
|
critical = make_event(check_id="critical_prune", status="critical", exit_code=2)
|
||||||
await app_client.post(
|
await app_client.post(
|
||||||
"/api/v1/events",
|
"/api/v1/events",
|
||||||
@@ -192,3 +231,73 @@ async def test_detector_prunes_outbox(app_client, auth_headers, engine, session,
|
|||||||
assert terminal_outbox == 2
|
assert terminal_outbox == 2
|
||||||
finally:
|
finally:
|
||||||
reset_settings_cache()
|
reset_settings_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_detector_prune_preserves_active_outbox(
|
||||||
|
app_client, ui_auth_headers, engine, session, monkeypatch
|
||||||
|
):
|
||||||
|
"""PH-012: prune must never delete active rows (pending/sending/retry),
|
||||||
|
even when retention is exceeded. The keep-N-newest list applies only to
|
||||||
|
terminal states; active rows are filtered out of the eligible set."""
|
||||||
|
monkeypatch.setenv("MONLET_OUTBOX_RETENTION_MAX_ROWS", "1")
|
||||||
|
reset_settings_cache()
|
||||||
|
try:
|
||||||
|
auth_headers = await register_agent(app_client, ui_auth_headers, "agent-prune-active")
|
||||||
|
critical = make_event(check_id="active_prune", status="critical", exit_code=2)
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/events",
|
||||||
|
json={"agent_id": "agent-prune-active", "events": [critical]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
inc = (
|
||||||
|
await session.execute(select(Incident).where(Incident.check_id == "active_prune"))
|
||||||
|
).scalar_one()
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
# Mix of active and terminal states.
|
||||||
|
for state, count in (("sent", 5), ("pending", 3), ("retry", 2), ("sending", 1)):
|
||||||
|
for _ in range(count):
|
||||||
|
session.add(
|
||||||
|
NotificationOutbox(
|
||||||
|
id=uuid4(),
|
||||||
|
incident_id=inc.id,
|
||||||
|
notifier="debug",
|
||||||
|
event_type="firing",
|
||||||
|
state=state,
|
||||||
|
attempts=1,
|
||||||
|
next_attempt_at=None,
|
||||||
|
payload={},
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
await _tick(sm)
|
||||||
|
|
||||||
|
# Active rows must be untouched. Ingestion may add extra pending rows
|
||||||
|
# for the debug notifier; we only assert ours are still present and
|
||||||
|
# that no active rows were deleted by the prune.
|
||||||
|
for state, injected in (("pending", 3), ("retry", 2), ("sending", 1)):
|
||||||
|
n = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(NotificationOutbox)
|
||||||
|
.where(NotificationOutbox.state == state)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert n >= injected, (state, n, injected)
|
||||||
|
# Terminal rows must be capped to retention=1 (or 2 after notifier worker
|
||||||
|
# produces extras from the critical event we ingested; but worker is
|
||||||
|
# disabled in tests so we just check terminal pruning).
|
||||||
|
sent = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count())
|
||||||
|
.select_from(NotificationOutbox)
|
||||||
|
.where(NotificationOutbox.state == "sent")
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert sent <= 2 # 1 retained + at most 1 created by detector enqueue
|
||||||
|
finally:
|
||||||
|
reset_settings_cache()
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from monlet_server.models import Check, Event
|
from monlet_server.models import Agent, Check, Event
|
||||||
|
|
||||||
from ._helpers import make_event, make_heartbeat
|
from ._helpers import make_event, register_agent
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_event_accepted_and_state(app_client, auth_headers, session):
|
async def test_event_accepted_and_state(app_client, ui_auth_headers, session):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
ev = make_event(status="ok", output="disk ok")
|
ev = make_event(status="ok", output="disk ok")
|
||||||
r = await app_client.post(
|
r = await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||||
@@ -23,8 +23,20 @@ async def test_event_accepted_and_state(app_client, auth_headers, session):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_duplicate_event_deduplicated(app_client, auth_headers):
|
async def test_event_before_heartbeat_is_dropped(app_client, auth_headers, session):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
ev = make_event(status="warning", exit_code=1)
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/events", json={"agent_id": "agent-replay", "events": [ev]}, headers=auth_headers
|
||||||
|
)
|
||||||
|
assert r.status_code == 202
|
||||||
|
assert r.json() == {"accepted": 0, "deduplicated": 0}
|
||||||
|
n = (await session.execute(select(func.count()).select_from(Agent))).scalar_one()
|
||||||
|
assert n == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_duplicate_event_deduplicated(app_client, ui_auth_headers):
|
||||||
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
ev = make_event(status="critical", exit_code=2)
|
ev = make_event(status="critical", exit_code=2)
|
||||||
r1 = await app_client.post(
|
r1 = await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||||
@@ -46,8 +58,8 @@ async def test_empty_batch_400(app_client, auth_headers):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize("observed_at", ["2026-05-27T09:00:00", "2026-05-27T09:00:00+04:00"])
|
@pytest.mark.parametrize("observed_at", ["2026-05-27T09:00:00", "2026-05-27T09:00:00+04:00"])
|
||||||
async def test_event_requires_utc_timestamp(app_client, auth_headers, observed_at):
|
async def test_event_requires_utc_timestamp(app_client, ui_auth_headers, observed_at):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
ev = make_event(observed_at=observed_at)
|
ev = make_event(observed_at=observed_at)
|
||||||
r = await app_client.post(
|
r = await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||||
@@ -66,8 +78,8 @@ async def test_oversize_batch_400(app_client, auth_headers):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_repeated_same_status_not_stored(app_client, auth_headers, session):
|
async def test_repeated_same_status_not_stored(app_client, ui_auth_headers, session):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
evs = [make_event(status="ok") for _ in range(5)]
|
evs = [make_event(status="ok") for _ in range(5)]
|
||||||
r = await app_client.post(
|
r = await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers
|
||||||
@@ -79,8 +91,8 @@ async def test_repeated_same_status_not_stored(app_client, auth_headers, session
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_status_transitions_stored(app_client, auth_headers, session):
|
async def test_status_transitions_stored(app_client, ui_auth_headers, session):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
seq = [
|
seq = [
|
||||||
make_event(status="ok"),
|
make_event(status="ok"),
|
||||||
make_event(status="warning", exit_code=1),
|
make_event(status="warning", exit_code=1),
|
||||||
@@ -98,8 +110,8 @@ async def test_status_transitions_stored(app_client, auth_headers, session):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_same_status_different_exit_code_stored(app_client, auth_headers, session):
|
async def test_same_status_different_exit_code_stored(app_client, ui_auth_headers, session):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
seq = [
|
seq = [
|
||||||
make_event(status="warning", exit_code=1),
|
make_event(status="warning", exit_code=1),
|
||||||
make_event(status="warning", exit_code=3),
|
make_event(status="warning", exit_code=3),
|
||||||
@@ -113,10 +125,10 @@ async def test_same_status_different_exit_code_stored(app_client, auth_headers,
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_late_event_does_not_move_state(app_client, auth_headers, session):
|
async def test_late_event_does_not_move_state(app_client, ui_auth_headers, session):
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
newer = datetime.now(UTC).replace(microsecond=0)
|
newer = datetime.now(UTC).replace(microsecond=0)
|
||||||
older = (newer - timedelta(minutes=5)).isoformat()
|
older = (newer - timedelta(minutes=5)).isoformat()
|
||||||
ev_now = make_event(
|
ev_now = make_event(
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ async def test_heartbeat_creates_agent(app_client, auth_headers, session):
|
|||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
res = await session.execute(select(Agent).where(Agent.agent_id == "agent-1"))
|
res = await session.execute(select(Agent).where(Agent.agent_id == "agent-1"))
|
||||||
a = res.scalar_one()
|
a = res.scalar_one()
|
||||||
assert a.hostname == "host-1"
|
assert a.hostname == "agent-1"
|
||||||
assert a.status == "alive"
|
assert a.status == "alive"
|
||||||
|
assert a.pending_key_hash
|
||||||
|
assert a.accepted_key_hash is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -24,7 +26,7 @@ async def test_heartbeat_upsert(app_client, auth_headers, session):
|
|||||||
await app_client.post("/api/v1/heartbeat", json=hb, headers=auth_headers)
|
await app_client.post("/api/v1/heartbeat", json=hb, headers=auth_headers)
|
||||||
res = await session.execute(select(Agent).where(Agent.agent_id == "agent-1"))
|
res = await session.execute(select(Agent).where(Agent.agent_id == "agent-1"))
|
||||||
a = res.scalar_one()
|
a = res.scalar_one()
|
||||||
assert a.features == {"push": True, "metrics": False}
|
assert a.pending_features == {"push": True, "metrics": False}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ._helpers import make_event, make_heartbeat
|
from ._helpers import make_event, register_agent
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_incident_key_ownership_rejected(app_client, auth_headers):
|
async def test_incident_key_ownership_rejected(app_client, ui_auth_headers):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat("agent-1"), headers=auth_headers)
|
agent_1_headers = await register_agent(app_client, ui_auth_headers, "agent-1")
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat("agent-2"), headers=auth_headers)
|
agent_2_headers = await register_agent(app_client, ui_auth_headers, "agent-2")
|
||||||
|
|
||||||
ev1 = make_event(status="critical", exit_code=2, incident_key="shared-key")
|
ev1 = make_event(status="critical", exit_code=2, incident_key="shared-key")
|
||||||
r1 = await app_client.post(
|
r1 = await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev1]}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev1]}, headers=agent_1_headers
|
||||||
)
|
)
|
||||||
assert r1.status_code == 202
|
assert r1.status_code == 202
|
||||||
|
|
||||||
ev2 = make_event(status="critical", exit_code=2, incident_key="shared-key")
|
ev2 = make_event(status="critical", exit_code=2, incident_key="shared-key")
|
||||||
r2 = await app_client.post(
|
r2 = await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-2", "events": [ev2]}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-2", "events": [ev2]}, headers=agent_2_headers
|
||||||
)
|
)
|
||||||
assert r2.status_code == 400
|
assert r2.status_code == 400
|
||||||
assert r2.json()["error"]["code"] == "validation"
|
assert r2.json()["error"]["code"] == "validation"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_incident_key_too_long(app_client, auth_headers):
|
async def test_incident_key_too_long(app_client, ui_auth_headers):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
ev = make_event(status="critical", exit_code=2, incident_key="x" * 257)
|
ev = make_event(status="critical", exit_code=2, incident_key="x" * 257)
|
||||||
r = await app_client.post(
|
r = await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||||
@@ -33,20 +33,20 @@ async def test_incident_key_too_long(app_client, auth_headers):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_events_query_cursor_pagination(app_client, auth_headers):
|
async def test_events_query_cursor_pagination(app_client, auth_headers, ui_auth_headers):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
evs = [make_event(check_id=f"chk-{i}") for i in range(5)]
|
evs = [make_event(check_id=f"chk-{i}") for i in range(5)]
|
||||||
await app_client.post(
|
await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers
|
||||||
)
|
)
|
||||||
r = await app_client.get("/api/v1/events/query?limit=2", headers=auth_headers)
|
r = await app_client.get("/api/v1/events/query?limit=2", headers=ui_auth_headers)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
page1 = r.json()
|
page1 = r.json()
|
||||||
assert len(page1["items"]) == 2
|
assert len(page1["items"]) == 2
|
||||||
assert page1["next_cursor"]
|
assert page1["next_cursor"]
|
||||||
|
|
||||||
r2 = await app_client.get(
|
r2 = await app_client.get(
|
||||||
f"/api/v1/events/query?limit=2&cursor={page1['next_cursor']}", headers=auth_headers
|
f"/api/v1/events/query?limit=2&cursor={page1['next_cursor']}", headers=ui_auth_headers
|
||||||
)
|
)
|
||||||
page2 = r2.json()
|
page2 = r2.json()
|
||||||
assert len(page2["items"]) == 2
|
assert len(page2["items"]) == 2
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ from sqlalchemy import select
|
|||||||
|
|
||||||
from monlet_server.models import Incident, NotificationOutbox
|
from monlet_server.models import Incident, NotificationOutbox
|
||||||
|
|
||||||
from ._helpers import make_event, make_heartbeat
|
from ._helpers import make_event, register_agent
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_open_escalate_resolve_reopen(app_client, auth_headers, session):
|
async def test_open_escalate_resolve_reopen(app_client, ui_auth_headers, session):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
a = "agent-1"
|
a = "agent-1"
|
||||||
|
|
||||||
# warning -> open warning
|
# warning -> open warning
|
||||||
@@ -52,8 +52,8 @@ async def test_open_escalate_resolve_reopen(app_client, auth_headers, session):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_outbox_skipped_when_notifications_disabled(app_client, auth_headers, session):
|
async def test_outbox_skipped_when_notifications_disabled(app_client, ui_auth_headers, session):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
ev = make_event(status="critical", exit_code=2, notifications_enabled=False)
|
ev = make_event(status="critical", exit_code=2, notifications_enabled=False)
|
||||||
await app_client.post(
|
await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||||
@@ -63,8 +63,8 @@ async def test_outbox_skipped_when_notifications_disabled(app_client, auth_heade
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_outbox_firing_and_resolved(app_client, auth_headers, session):
|
async def test_outbox_firing_and_resolved(app_client, ui_auth_headers, session):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
a = "agent-1"
|
a = "agent-1"
|
||||||
ev_open = make_event(status="critical", exit_code=2)
|
ev_open = make_event(status="critical", exit_code=2)
|
||||||
await app_client.post(
|
await app_client.post(
|
||||||
@@ -77,3 +77,42 @@ async def test_outbox_firing_and_resolved(app_client, auth_headers, session):
|
|||||||
rows = (await session.execute(select(NotificationOutbox))).scalars().all()
|
rows = (await session.execute(select(NotificationOutbox))).scalars().all()
|
||||||
types = sorted(r.event_type for r in rows)
|
types = sorted(r.event_type for r in rows)
|
||||||
assert types == ["firing", "resolved"]
|
assert types == ["firing", "resolved"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_rank_map_covers_all_pairs():
|
||||||
|
"""PH-review: the SQL-side rank is computed by a STORED generated column
|
||||||
|
(`incidents.status_rank`). The Python `_STATUS_RANK` map mirrors that CASE
|
||||||
|
for cursor encoding only. This test pins the map so the two cannot drift —
|
||||||
|
if a new (state, severity) pair is added, both must update."""
|
||||||
|
from monlet_server.api.incidents import _STATUS_RANK
|
||||||
|
|
||||||
|
assert _STATUS_RANK == {
|
||||||
|
("open", "critical"): 5,
|
||||||
|
("open", "warning"): 4,
|
||||||
|
("open", "unknown"): 3,
|
||||||
|
("resolved", "critical"): 2,
|
||||||
|
("resolved", "warning"): 1,
|
||||||
|
("resolved", "unknown"): 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_rank_generated_column_matches_python_map(
|
||||||
|
app_client, ui_auth_headers, session
|
||||||
|
):
|
||||||
|
"""End-to-end check: insert one event per (state, severity) pair and
|
||||||
|
confirm the DB-side generated column equals the Python map value."""
|
||||||
|
from monlet_server.api.incidents import _STATUS_RANK
|
||||||
|
|
||||||
|
# Open critical incident comes from a single critical event.
|
||||||
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
|
crit = make_event(check_id="crit", status="critical", exit_code=2)
|
||||||
|
warn = make_event(check_id="warn", status="warning", exit_code=1)
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/events",
|
||||||
|
json={"agent_id": "agent-1", "events": [crit, warn]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
rows = (await session.execute(select(Incident))).scalars().all()
|
||||||
|
for r in rows:
|
||||||
|
assert r.status_rank == _STATUS_RANK[(r.state, r.severity)]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ EXAMPLES = os.path.join(os.path.dirname(__file__), "..", "..", "examples")
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_examples_payload(app_client, auth_headers, session):
|
async def test_examples_payload(app_client, auth_headers, ui_auth_headers, session):
|
||||||
with open(os.path.join(EXAMPLES, "heartbeat.json")) as f:
|
with open(os.path.join(EXAMPLES, "heartbeat.json")) as f:
|
||||||
hb = json.load(f)
|
hb = json.load(f)
|
||||||
with open(os.path.join(EXAMPLES, "event-batch.json")) as f:
|
with open(os.path.join(EXAMPLES, "event-batch.json")) as f:
|
||||||
@@ -18,6 +18,8 @@ async def test_examples_payload(app_client, auth_headers, session):
|
|||||||
|
|
||||||
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=auth_headers)
|
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=auth_headers)
|
||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
|
r = await app_client.post(f"/api/v1/agents/{hb['agent_id']}/accept", headers=ui_auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
r = await app_client.post("/api/v1/events", json=batch, headers=auth_headers)
|
r = await app_client.post("/api/v1/events", json=batch, headers=auth_headers)
|
||||||
assert r.status_code == 202
|
assert r.status_code == 202
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ async def _seed_incident_and_outbox(
|
|||||||
event_type="firing",
|
event_type="firing",
|
||||||
state="pending",
|
state="pending",
|
||||||
attempts=0,
|
attempts=0,
|
||||||
next_attempt_at=datetime.now(UTC),
|
next_attempt_at=datetime.now(UTC) - timedelta(seconds=1),
|
||||||
payload=_payload(),
|
payload=_payload(),
|
||||||
)
|
)
|
||||||
session.add(row)
|
session.add(row)
|
||||||
@@ -386,7 +386,7 @@ async def test_telegram_redacts_secret_in_incident_key():
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fanout_creates_row_per_enabled_notifier(
|
async def test_fanout_creates_row_per_enabled_notifier(
|
||||||
app_client, auth_headers, session, monkeypatch
|
app_client, ui_auth_headers, session, monkeypatch
|
||||||
):
|
):
|
||||||
# Enable telegram (token+chat_id satisfied) and webhook in addition to debug.
|
# Enable telegram (token+chat_id satisfied) and webhook in addition to debug.
|
||||||
os.environ["MONLET_NOTIFIER_TELEGRAM_ENABLED"] = "true"
|
os.environ["MONLET_NOTIFIER_TELEGRAM_ENABLED"] = "true"
|
||||||
@@ -398,9 +398,9 @@ async def test_fanout_creates_row_per_enabled_notifier(
|
|||||||
|
|
||||||
reset_settings_cache()
|
reset_settings_cache()
|
||||||
try:
|
try:
|
||||||
from tests._helpers import make_event, make_heartbeat
|
from tests._helpers import make_event, register_agent
|
||||||
|
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
ev = make_event(status="critical", exit_code=2)
|
ev = make_event(status="critical", exit_code=2)
|
||||||
r = await app_client.post(
|
r = await app_client.post(
|
||||||
"/api/v1/events",
|
"/api/v1/events",
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from ._helpers import make_event, make_heartbeat
|
from ._helpers import agent_headers, make_event, make_heartbeat, register_agent
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_agents_cursor(app_client, auth_headers):
|
async def test_agents_cursor(app_client, ui_auth_headers):
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
await app_client.post(
|
await register_agent(app_client, ui_auth_headers, f"a-{i}")
|
||||||
"/api/v1/heartbeat", json=make_heartbeat(f"a-{i}"), headers=auth_headers
|
r1 = await app_client.get("/api/v1/agents?limit=2", headers=ui_auth_headers)
|
||||||
)
|
|
||||||
r1 = await app_client.get("/api/v1/agents?limit=2", headers=auth_headers)
|
|
||||||
p1 = r1.json()
|
p1 = r1.json()
|
||||||
assert len(p1["items"]) == 2
|
assert len(p1["items"]) == 2
|
||||||
assert p1["next_cursor"]
|
assert p1["next_cursor"]
|
||||||
|
|
||||||
r2 = await app_client.get(
|
r2 = await app_client.get(
|
||||||
f"/api/v1/agents?limit=2&cursor={p1['next_cursor']}", headers=auth_headers
|
f"/api/v1/agents?limit=2&cursor={p1['next_cursor']}", headers=ui_auth_headers
|
||||||
)
|
)
|
||||||
p2 = r2.json()
|
p2 = r2.json()
|
||||||
assert len(p2["items"]) == 2
|
assert len(p2["items"]) == 2
|
||||||
@@ -25,76 +23,204 @@ async def test_agents_cursor(app_client, auth_headers):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_checks_cursor(app_client, auth_headers):
|
async def test_agents_cursor_sorts_pending_before_accepted(app_client, ui_auth_headers):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
for i in range(3):
|
||||||
|
await register_agent(app_client, ui_auth_headers, f"accepted-{i}")
|
||||||
|
for i in range(2):
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/heartbeat",
|
||||||
|
json=make_heartbeat(f"pending-{i}"),
|
||||||
|
headers=agent_headers(f"pending-{i}"),
|
||||||
|
)
|
||||||
|
|
||||||
|
r1 = await app_client.get("/api/v1/agents?limit=3", headers=ui_auth_headers)
|
||||||
|
p1 = r1.json()
|
||||||
|
assert [i["admission_state"] for i in p1["items"][:2]] == ["pending", "pending"]
|
||||||
|
assert p1["next_cursor"]
|
||||||
|
|
||||||
|
r2 = await app_client.get(
|
||||||
|
f"/api/v1/agents?limit=3&cursor={p1['next_cursor']}", headers=ui_auth_headers
|
||||||
|
)
|
||||||
|
assert {i["agent_id"] for i in r2.json()["items"]}.isdisjoint(
|
||||||
|
{i["agent_id"] for i in p1["items"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_checks_cursor(app_client, auth_headers, ui_auth_headers):
|
||||||
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
ev = make_event(check_id=f"chk-{i}")
|
ev = make_event(check_id=f"chk-{i}")
|
||||||
await app_client.post(
|
await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||||
)
|
)
|
||||||
r1 = await app_client.get("/api/v1/checks?limit=2", headers=auth_headers)
|
r1 = await app_client.get("/api/v1/checks?limit=2", headers=ui_auth_headers)
|
||||||
p1 = r1.json()
|
p1 = r1.json()
|
||||||
assert len(p1["items"]) == 2
|
assert len(p1["items"]) == 2
|
||||||
assert p1["next_cursor"]
|
assert p1["next_cursor"]
|
||||||
r2 = await app_client.get(
|
r2 = await app_client.get(
|
||||||
f"/api/v1/checks?limit=2&cursor={p1['next_cursor']}", headers=auth_headers
|
f"/api/v1/checks?limit=2&cursor={p1['next_cursor']}", headers=ui_auth_headers
|
||||||
)
|
)
|
||||||
p2 = r2.json()
|
p2 = r2.json()
|
||||||
assert len(p2["items"]) == 2
|
assert len(p2["items"]) == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_checks_rejects_invalid_agent_id_query(app_client, auth_headers):
|
async def test_checks_rejects_invalid_agent_id_query(app_client, ui_auth_headers):
|
||||||
r = await app_client.get("/api/v1/checks?agent_id=bad%20id", headers=auth_headers)
|
r = await app_client.get("/api/v1/checks?agent_id=bad%20id", headers=ui_auth_headers)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert r.json()["error"]["code"] == "validation"
|
assert r.json()["error"]["code"] == "validation"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_events_query_rejects_invalid_id_queries(app_client, auth_headers):
|
async def test_events_query_rejects_invalid_id_queries(app_client, ui_auth_headers):
|
||||||
r = await app_client.get("/api/v1/events/query?agent_id=bad%20id", headers=auth_headers)
|
r = await app_client.get("/api/v1/events/query?agent_id=bad%20id", headers=ui_auth_headers)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert r.json()["error"]["code"] == "validation"
|
assert r.json()["error"]["code"] == "validation"
|
||||||
|
|
||||||
r = await app_client.get("/api/v1/events/query?check_id=bad%20id", headers=auth_headers)
|
r = await app_client.get("/api/v1/events/query?check_id=bad%20id", headers=ui_auth_headers)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
assert r.json()["error"]["code"] == "validation"
|
assert r.json()["error"]["code"] == "validation"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_incidents_cursor(app_client, auth_headers):
|
async def test_incidents_cursor(app_client, auth_headers, ui_auth_headers):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
ev = make_event(check_id=f"chk-{i}", status="critical", exit_code=2)
|
ev = make_event(check_id=f"chk-{i}", status="critical", exit_code=2)
|
||||||
await app_client.post(
|
await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||||
)
|
)
|
||||||
r1 = await app_client.get("/api/v1/incidents?limit=2", headers=auth_headers)
|
r1 = await app_client.get("/api/v1/incidents?limit=2", headers=ui_auth_headers)
|
||||||
p1 = r1.json()
|
p1 = r1.json()
|
||||||
assert len(p1["items"]) == 2
|
assert len(p1["items"]) == 2
|
||||||
assert p1["next_cursor"]
|
assert p1["next_cursor"]
|
||||||
r2 = await app_client.get(
|
r2 = await app_client.get(
|
||||||
f"/api/v1/incidents?limit=2&cursor={p1['next_cursor']}", headers=auth_headers
|
f"/api/v1/incidents?limit=2&cursor={p1['next_cursor']}", headers=ui_auth_headers
|
||||||
)
|
)
|
||||||
p2 = r2.json()
|
p2 = r2.json()
|
||||||
assert len(p2["items"]) == 1
|
assert len(p2["items"]) == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_outbox_cursor(app_client, auth_headers):
|
async def test_incidents_default_sort_is_status(app_client, auth_headers, ui_auth_headers):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
|
warning = make_event(check_id="warn-check", status="warning", exit_code=1)
|
||||||
|
critical = make_event(check_id="crit-check", status="critical", exit_code=2)
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/events",
|
||||||
|
json={"agent_id": "agent-1", "events": [warning, critical]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
r = await app_client.get("/api/v1/incidents?limit=10", headers=ui_auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
items = r.json()["items"]
|
||||||
|
assert [i["severity"] for i in items[:2]] == ["critical", "warning"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_outbox_cursor(app_client, auth_headers, ui_auth_headers):
|
||||||
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
ev = make_event(check_id=f"chk-{i}", status="critical", exit_code=2)
|
ev = make_event(check_id=f"chk-{i}", status="critical", exit_code=2)
|
||||||
await app_client.post(
|
await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||||
)
|
)
|
||||||
r1 = await app_client.get("/api/v1/notifiers/outbox?limit=2", headers=auth_headers)
|
r1 = await app_client.get("/api/v1/notifiers/outbox?limit=2", headers=ui_auth_headers)
|
||||||
p1 = r1.json()
|
p1 = r1.json()
|
||||||
assert len(p1["items"]) == 2
|
assert len(p1["items"]) == 2
|
||||||
assert p1["next_cursor"]
|
assert p1["next_cursor"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_cursor_400(app_client, auth_headers):
|
async def test_invalid_cursor_400(app_client, ui_auth_headers):
|
||||||
r = await app_client.get("/api/v1/agents?cursor=$$$bad", headers=auth_headers)
|
r = await app_client.get("/api/v1/agents?cursor=$$$bad", headers=ui_auth_headers)
|
||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_events_query_invalid_cursor_uuid_400(app_client, ui_auth_headers):
|
||||||
|
"""PH-010: cursor encodes (received_at, event_id) and event_id must parse
|
||||||
|
as a UUID before reaching the SQL layer."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
bad = base64.urlsafe_b64encode(b"2026-01-01T00:00:00+00:00|not-a-uuid").decode().rstrip("=")
|
||||||
|
r = await app_client.get(f"/api/v1/events/query?cursor={bad}", headers=ui_auth_headers)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_incidents_bidirectional_cursor_round_trip(app_client, auth_headers, ui_auth_headers):
|
||||||
|
"""PH-review: page1 → next → prev returns page1 with the same items and
|
||||||
|
order. prev_cursor must be null on the first page."""
|
||||||
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
|
for i in range(6):
|
||||||
|
ev = make_event(check_id=f"chk-{i}", status="critical", exit_code=2)
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/events",
|
||||||
|
json={"agent_id": "agent-1", "events": [ev]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
r1 = await app_client.get("/api/v1/incidents?limit=3", headers=ui_auth_headers)
|
||||||
|
p1 = r1.json()
|
||||||
|
assert len(p1["items"]) == 3
|
||||||
|
assert p1["next_cursor"]
|
||||||
|
assert p1.get("prev_cursor") is None # first page
|
||||||
|
|
||||||
|
r2 = await app_client.get(
|
||||||
|
f"/api/v1/incidents?limit=3&cursor={p1['next_cursor']}", headers=ui_auth_headers
|
||||||
|
)
|
||||||
|
p2 = r2.json()
|
||||||
|
assert len(p2["items"]) == 3
|
||||||
|
assert p2["prev_cursor"]
|
||||||
|
ids1 = [i["id"] for i in p1["items"]]
|
||||||
|
ids2 = [i["id"] for i in p2["items"]]
|
||||||
|
assert set(ids1).isdisjoint(set(ids2))
|
||||||
|
|
||||||
|
r_back = await app_client.get(
|
||||||
|
f"/api/v1/incidents?limit=3&cursor={p2['prev_cursor']}&back=true",
|
||||||
|
headers=ui_auth_headers,
|
||||||
|
)
|
||||||
|
p_back = r_back.json()
|
||||||
|
assert [i["id"] for i in p_back["items"]] == ids1
|
||||||
|
# back to first page => prev_cursor must be null again
|
||||||
|
assert p_back.get("prev_cursor") is None
|
||||||
|
assert p_back["next_cursor"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_outbox_bidirectional_cursor_round_trip(app_client, auth_headers, ui_auth_headers):
|
||||||
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
|
for i in range(5):
|
||||||
|
ev = make_event(check_id=f"chk-{i}", status="critical", exit_code=2)
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/events",
|
||||||
|
json={"agent_id": "agent-1", "events": [ev]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
r1 = await app_client.get("/api/v1/notifiers/outbox?limit=2", headers=ui_auth_headers)
|
||||||
|
p1 = r1.json()
|
||||||
|
assert len(p1["items"]) == 2
|
||||||
|
assert p1["next_cursor"]
|
||||||
|
assert p1.get("prev_cursor") is None
|
||||||
|
|
||||||
|
r2 = await app_client.get(
|
||||||
|
f"/api/v1/notifiers/outbox?limit=2&cursor={p1['next_cursor']}",
|
||||||
|
headers=ui_auth_headers,
|
||||||
|
)
|
||||||
|
p2 = r2.json()
|
||||||
|
assert p2["prev_cursor"]
|
||||||
|
ids1 = [i["id"] for i in p1["items"]]
|
||||||
|
ids2 = [i["id"] for i in p2["items"]]
|
||||||
|
assert set(ids1).isdisjoint(set(ids2))
|
||||||
|
|
||||||
|
r_back = await app_client.get(
|
||||||
|
f"/api/v1/notifiers/outbox?limit=2&cursor={p2['prev_cursor']}&back=true",
|
||||||
|
headers=ui_auth_headers,
|
||||||
|
)
|
||||||
|
p_back = r_back.json()
|
||||||
|
assert [i["id"] for i in p_back["items"]] == ids1
|
||||||
|
assert p_back.get("prev_cursor") is None
|
||||||
|
|||||||
@@ -67,14 +67,14 @@ async def test_drop_old_partitions_uses_retention(engine, monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dedup_prevents_replay_via_ingest(app_client, auth_headers, session):
|
async def test_dedup_prevents_replay_via_ingest(app_client, ui_auth_headers, session):
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
from monlet_server.models import Event
|
from monlet_server.models import Event
|
||||||
|
|
||||||
from ._helpers import make_event, make_heartbeat
|
from ._helpers import make_event, register_agent
|
||||||
|
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
auth_headers = await register_agent(app_client, ui_auth_headers)
|
||||||
ev = make_event(status="critical", exit_code=2)
|
ev = make_event(status="critical", exit_code=2)
|
||||||
|
|
||||||
r1 = await app_client.post(
|
r1 = await app_client.post(
|
||||||
|
|||||||
35
server/tests/test_system_summary.py
Normal file
35
server/tests/test_system_summary.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from ._helpers import make_event, register_agent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_summary_aggregate(app_client, ui_auth_headers):
|
||||||
|
auth_headers = await register_agent(app_client, ui_auth_headers, "agent-1")
|
||||||
|
await register_agent(app_client, ui_auth_headers, "agent-2")
|
||||||
|
ev = make_event(check_id="c1", status="critical", exit_code=2)
|
||||||
|
await app_client.post(
|
||||||
|
"/api/v1/events",
|
||||||
|
json={"agent_id": "agent-1", "events": [ev]},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
r = await app_client.get("/api/v1/system-summary", headers=ui_auth_headers)
|
||||||
|
assert r.status_code == 200
|
||||||
|
body = r.json()
|
||||||
|
assert body["agents_online"] == 2
|
||||||
|
assert body["agents_offline"] == 0
|
||||||
|
assert body["checks_critical"] >= 1
|
||||||
|
assert "generated_at" in body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_summary_requires_auth(app_client):
|
||||||
|
r = await app_client.get("/api/v1/system-summary")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_outbox_invalid_state_400(app_client, ui_auth_headers):
|
||||||
|
r = await app_client.get("/api/v1/notifiers/outbox?state=bogus", headers=ui_auth_headers)
|
||||||
|
assert r.status_code == 400
|
||||||
@@ -20,5 +20,9 @@ COPY --from=builder /app/.next ./.next
|
|||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/package.json ./package.json
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
# PH-015: run as the prebuilt non-root `node` user shipped by the base image.
|
||||||
|
RUN chown -R node:node /app
|
||||||
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
STOPSIGNAL SIGTERM
|
||||||
CMD ["npm", "run", "start"]
|
CMD ["npm", "run", "start"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Monlet Web
|
# Monlet Web
|
||||||
|
|
||||||
Read-only dashboard for Monlet.
|
Dashboard for Monlet. Monitoring pages are read-only; agent admission and blacklist controls can mutate server state.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -36,13 +36,17 @@ Do not install Node packages globally. Project dependencies live in `web/node_mo
|
|||||||
| `MONLET_API_BASE_URL` | `http://127.0.0.1:8000` | Server base URL (server-side fetch) |
|
| `MONLET_API_BASE_URL` | `http://127.0.0.1:8000` | Server base URL (server-side fetch) |
|
||||||
| `MONLET_API_TOKEN` | _(none)_ | Bearer token sent to server |
|
| `MONLET_API_TOKEN` | _(none)_ | Bearer token sent to server |
|
||||||
| `MONLET_WEB_TIME_ZONE` | `UTC` | IANA timezone used to render timestamps |
|
| `MONLET_WEB_TIME_ZONE` | `UTC` | IANA timezone used to render timestamps |
|
||||||
|
| `MONLET_WEB_AUTH_USERNAME` | _(none)_ | Optional Basic Auth username for the web UI |
|
||||||
|
| `MONLET_WEB_AUTH_PASSWORD` | _(none)_ | Optional Basic Auth password for the web UI |
|
||||||
|
| `MONLET_WEB_TRUST_PROXY_AUTH` | `false` | Trust `X-Forwarded-User` from an upstream auth proxy |
|
||||||
|
|
||||||
The token never reaches the browser — all server calls happen in Server Components / route handlers.
|
The API token never reaches the browser — all server calls happen in Server Components / route handlers. Agent admission actions are blocked unless Basic Auth is configured or trusted proxy auth is enabled.
|
||||||
|
|
||||||
## Pages
|
## Pages
|
||||||
|
|
||||||
- `/` — overview
|
- `/` — redirects to `/agents`
|
||||||
- `/agents` — agent list
|
- `/agents` — agent list
|
||||||
|
- `/agents/blacklist` — blocked agent keys
|
||||||
- `/agents/[id]` — agent detail
|
- `/agents/[id]` — agent detail
|
||||||
- `/checks` — current check states
|
- `/checks` — current check states
|
||||||
- `/incidents` — incident list
|
- `/incidents` — incident list
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { AgentDetailBrowser } from "@/components/AgentDetailBrowser";
|
|||||||
import { ErrorPanel } from "@/components/DataPanel";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { ApiError, api } from "@/lib/api";
|
import { ApiError, api } from "@/lib/api";
|
||||||
import { loadChecks } from "@/lib/loaders";
|
import { loadChecks } from "@/lib/loaders";
|
||||||
import { normalizePageSize } from "@/lib/pagination";
|
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -30,6 +30,8 @@ export default async function AgentDetailPage({
|
|||||||
tab?: string;
|
tab?: string;
|
||||||
events_limit?: string;
|
events_limit?: string;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
back?: string;
|
||||||
|
page?: string;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const { agent_id } = await params;
|
const { agent_id } = await params;
|
||||||
@@ -38,10 +40,12 @@ export default async function AgentDetailPage({
|
|||||||
const tab = normalizeTab(sp.tab);
|
const tab = normalizeTab(sp.tab);
|
||||||
const eventsLimit = normalizePageSize(sp.events_limit, 50);
|
const eventsLimit = normalizePageSize(sp.events_limit, 50);
|
||||||
const eventsCursor = sp.cursor;
|
const eventsCursor = sp.cursor;
|
||||||
|
const eventsPage = normalizePageNumber(sp.page);
|
||||||
|
const eventsBack = normalizeBack(sp.back);
|
||||||
|
|
||||||
let agent;
|
let agent;
|
||||||
let checks;
|
let checks;
|
||||||
let events: EventsPage = { items: [], next_cursor: null };
|
let events: EventsPage = { items: [], next_cursor: null, prev_cursor: null };
|
||||||
try {
|
try {
|
||||||
[agent, checks] = await Promise.all([api.getAgent(agent_id), loadChecks(agent_id)]);
|
[agent, checks] = await Promise.all([api.getAgent(agent_id), loadChecks(agent_id)]);
|
||||||
if (tab === "events") {
|
if (tab === "events") {
|
||||||
@@ -49,6 +53,7 @@ export default async function AgentDetailPage({
|
|||||||
agent_id,
|
agent_id,
|
||||||
limit: eventsLimit,
|
limit: eventsLimit,
|
||||||
cursor: eventsCursor,
|
cursor: eventsCursor,
|
||||||
|
back: eventsBack,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -59,13 +64,15 @@ export default async function AgentDetailPage({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<AgentDetailBrowser
|
<AgentDetailBrowser
|
||||||
key={`${agent_id}:${tab}:${sort}:${eventsLimit}:${eventsCursor ?? ""}`}
|
key={`${agent_id}:${tab}:${sort}:${eventsLimit}:${eventsCursor ?? ""}:${eventsBack ? "b" : "f"}:${eventsPage}`}
|
||||||
initialData={{ agent, checks }}
|
initialData={{ agent, checks }}
|
||||||
initialEvents={events}
|
initialEvents={events}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
eventsLimit={eventsLimit}
|
eventsLimit={eventsLimit}
|
||||||
eventsCursor={eventsCursor}
|
eventsCursor={eventsCursor}
|
||||||
|
eventsBack={eventsBack}
|
||||||
|
eventsPage={eventsPage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
15
web/src/app/agents/blacklist/page.tsx
Normal file
15
web/src/app/agents/blacklist/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { AgentBlacklistBrowser } from "@/components/AgentBlacklistBrowser";
|
||||||
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
|
import { loadAgentBlacklist } from "@/lib/loaders";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function AgentBlacklistPage() {
|
||||||
|
let rows;
|
||||||
|
try {
|
||||||
|
rows = await loadAgentBlacklist();
|
||||||
|
} catch (e) {
|
||||||
|
return <ErrorPanel error={e} />;
|
||||||
|
}
|
||||||
|
return <AgentBlacklistBrowser initialRows={rows} />;
|
||||||
|
}
|
||||||
56
web/src/app/api/agents/action/route.ts
Normal file
56
web/src/app/api/agents/action/route.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { jsonError } from "@/lib/poll-response";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| "accept"
|
||||||
|
| "block"
|
||||||
|
| "remove"
|
||||||
|
| "accept_all"
|
||||||
|
| "block_all"
|
||||||
|
| "remove_all"
|
||||||
|
| "blacklist_delete"
|
||||||
|
| "blacklist_clear";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
action?: Action;
|
||||||
|
agent_id?: string;
|
||||||
|
blacklist_id?: string;
|
||||||
|
include_rotation?: boolean;
|
||||||
|
};
|
||||||
|
switch (body.action) {
|
||||||
|
case "accept":
|
||||||
|
return NextResponse.json(await api.acceptAgent(required(body.agent_id, "agent_id")));
|
||||||
|
case "block":
|
||||||
|
return NextResponse.json(await api.blockAgent(required(body.agent_id, "agent_id")));
|
||||||
|
case "remove":
|
||||||
|
return NextResponse.json(await api.removeAgent(required(body.agent_id, "agent_id")));
|
||||||
|
case "accept_all":
|
||||||
|
return NextResponse.json(await api.acceptAllPendingAgents(body.include_rotation === true));
|
||||||
|
case "block_all":
|
||||||
|
return NextResponse.json(await api.blockAllPendingAgents());
|
||||||
|
case "remove_all":
|
||||||
|
return NextResponse.json(await api.removeAllPendingAgents());
|
||||||
|
case "blacklist_delete":
|
||||||
|
return NextResponse.json(
|
||||||
|
await api.deleteAgentBlacklistItem(required(body.blacklist_id, "blacklist_id")),
|
||||||
|
);
|
||||||
|
case "blacklist_clear":
|
||||||
|
return NextResponse.json(await api.clearAgentBlacklist());
|
||||||
|
default:
|
||||||
|
return NextResponse.json({ error: "unknown action" }, { status: 400 });
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return jsonError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function required(value: string | undefined, name: string): string {
|
||||||
|
if (!value) throw new Error(`${name} is required`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
7
web/src/app/api/health/route.ts
Normal file
7
web/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ export async function GET(request: Request) {
|
|||||||
agentId: url.searchParams.get("agent_id") ?? undefined,
|
agentId: url.searchParams.get("agent_id") ?? undefined,
|
||||||
checkId: url.searchParams.get("check_id") ?? undefined,
|
checkId: url.searchParams.get("check_id") ?? undefined,
|
||||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||||
|
back: url.searchParams.get("back") === "1",
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { loadIncidents } from "@/lib/loaders";
|
import { loadIncidentsPage } from "@/lib/loaders";
|
||||||
import { jsonError } from "@/lib/poll-response";
|
import { jsonError } from "@/lib/poll-response";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const SORTS = new Set(["status", "opened_at", "resolved_at"]);
|
||||||
|
const DIRS = new Set(["asc", "desc"]);
|
||||||
|
|
||||||
|
// PH-001: poll a single page, not the entire incident history. Sort and
|
||||||
|
// direction are forwarded so the server orders consistently across pages.
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const stateParam = url.searchParams.get("state");
|
const stateParam = url.searchParams.get("state");
|
||||||
const state = stateParam === "open" || stateParam === "resolved" ? stateParam : undefined;
|
const state = stateParam === "open" || stateParam === "resolved" ? stateParam : undefined;
|
||||||
|
const cursor = url.searchParams.get("cursor") ?? undefined;
|
||||||
|
const back = url.searchParams.get("back") === "1";
|
||||||
|
const limitRaw = url.searchParams.get("limit");
|
||||||
|
const parsed = limitRaw ? Number(limitRaw) : undefined;
|
||||||
|
const limit = parsed && Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 500) : 100;
|
||||||
|
const sortRaw = url.searchParams.get("sort") ?? undefined;
|
||||||
|
const sort =
|
||||||
|
sortRaw && SORTS.has(sortRaw)
|
||||||
|
? (sortRaw as "status" | "opened_at" | "resolved_at")
|
||||||
|
: undefined;
|
||||||
|
const dirRaw = url.searchParams.get("direction") ?? undefined;
|
||||||
|
const direction = dirRaw && DIRS.has(dirRaw) ? (dirRaw as "asc" | "desc") : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = await loadIncidents(state);
|
const page = await loadIncidentsPage({ state, cursor, back, limit, sort, direction });
|
||||||
return NextResponse.json({ items });
|
return NextResponse.json(page);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return jsonError(e);
|
return jsonError(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,38 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { loadOutboxAll } from "@/lib/loaders";
|
import { loadOutboxPage } from "@/lib/loaders";
|
||||||
import { jsonError } from "@/lib/poll-response";
|
import { jsonError } from "@/lib/poll-response";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
const SORTS = new Set(["created_at", "updated_at"]);
|
||||||
|
const DIRS = new Set(["asc", "desc"]);
|
||||||
|
|
||||||
|
// PH-002: poll a single outbox page; the table can grow under notifier failures.
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const cursor = url.searchParams.get("cursor") ?? undefined;
|
||||||
|
const back = url.searchParams.get("back") === "1";
|
||||||
|
const limitRaw = url.searchParams.get("limit");
|
||||||
|
const parsed = limitRaw ? Number(limitRaw) : undefined;
|
||||||
|
const limit = parsed && Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 500) : 100;
|
||||||
|
const state = url.searchParams.get("state") ?? undefined;
|
||||||
|
const sortRaw = url.searchParams.get("sort") ?? undefined;
|
||||||
|
const sort =
|
||||||
|
sortRaw && SORTS.has(sortRaw) ? (sortRaw as "created_at" | "updated_at") : undefined;
|
||||||
|
const dirRaw = url.searchParams.get("direction") ?? undefined;
|
||||||
|
const direction = dirRaw && DIRS.has(dirRaw) ? (dirRaw as "asc" | "desc") : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const items = await loadOutboxAll();
|
const page = await loadOutboxPage({
|
||||||
return NextResponse.json({ items });
|
cursor,
|
||||||
|
back,
|
||||||
|
limit,
|
||||||
|
state: state as never,
|
||||||
|
sort,
|
||||||
|
direction,
|
||||||
|
});
|
||||||
|
return NextResponse.json(page);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return jsonError(e);
|
return jsonError(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { loadOverview } from "@/lib/loaders";
|
import { loadSystemSummary } from "@/lib/loaders";
|
||||||
import { jsonError } from "@/lib/poll-response";
|
import { jsonError } from "@/lib/poll-response";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
return NextResponse.json(await loadOverview());
|
return NextResponse.json(await loadSystemSummary());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return jsonError(e);
|
return jsonError(e);
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import { CheckDetailBrowser } from "@/components/CheckDetailBrowser";
|
|||||||
import { ErrorPanel } from "@/components/DataPanel";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { loadInventory } from "@/lib/loaders";
|
import { loadInventory } from "@/lib/loaders";
|
||||||
import { normalizePageSize } from "@/lib/pagination";
|
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -18,16 +18,24 @@ export default async function CheckDetailPage({
|
|||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ check_id: string }>;
|
params: Promise<{ check_id: string }>;
|
||||||
searchParams: Promise<{ tab?: string; events_limit?: string; cursor?: string }>;
|
searchParams: Promise<{
|
||||||
|
tab?: string;
|
||||||
|
events_limit?: string;
|
||||||
|
cursor?: string;
|
||||||
|
back?: string;
|
||||||
|
page?: string;
|
||||||
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const { check_id } = await params;
|
const { check_id } = await params;
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const tab = normalizeTab(sp.tab);
|
const tab = normalizeTab(sp.tab);
|
||||||
const eventsLimit = normalizePageSize(sp.events_limit, 50);
|
const eventsLimit = normalizePageSize(sp.events_limit, 50);
|
||||||
const eventsCursor = sp.cursor;
|
const eventsCursor = sp.cursor;
|
||||||
|
const eventsPage = normalizePageNumber(sp.page);
|
||||||
|
const eventsBack = normalizeBack(sp.back);
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
let events: EventsPage = { items: [], next_cursor: null };
|
let events: EventsPage = { items: [], next_cursor: null, prev_cursor: null };
|
||||||
try {
|
try {
|
||||||
data = await loadInventory();
|
data = await loadInventory();
|
||||||
if (tab === "events") {
|
if (tab === "events") {
|
||||||
@@ -35,6 +43,7 @@ export default async function CheckDetailPage({
|
|||||||
check_id,
|
check_id,
|
||||||
limit: eventsLimit,
|
limit: eventsLimit,
|
||||||
cursor: eventsCursor,
|
cursor: eventsCursor,
|
||||||
|
back: eventsBack,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -43,13 +52,15 @@ export default async function CheckDetailPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CheckDetailBrowser
|
<CheckDetailBrowser
|
||||||
key={`${check_id}:${tab}:${eventsLimit}:${eventsCursor ?? ""}`}
|
key={`${check_id}:${tab}:${eventsLimit}:${eventsCursor ?? ""}:${eventsBack ? "b" : "f"}:${eventsPage}`}
|
||||||
checkId={check_id}
|
checkId={check_id}
|
||||||
initialData={data}
|
initialData={data}
|
||||||
initialEvents={events}
|
initialEvents={events}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
eventsLimit={eventsLimit}
|
eventsLimit={eventsLimit}
|
||||||
eventsCursor={eventsCursor}
|
eventsCursor={eventsCursor}
|
||||||
|
eventsBack={eventsBack}
|
||||||
|
eventsPage={eventsPage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ErrorPanel } from "@/components/DataPanel";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { EventsBrowser } from "@/components/EventsBrowser";
|
import { EventsBrowser } from "@/components/EventsBrowser";
|
||||||
import { loadEvents } from "@/lib/loaders";
|
import { loadEvents } from "@/lib/loaders";
|
||||||
import { normalizePageSize } from "@/lib/pagination";
|
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -10,19 +10,24 @@ export default async function EventsPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
back?: string;
|
||||||
agent_id?: string;
|
agent_id?: string;
|
||||||
check_id?: string;
|
check_id?: string;
|
||||||
limit?: string;
|
limit?: string;
|
||||||
|
page?: string;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const limit = normalizePageSize(sp.limit);
|
const limit = normalizePageSize(sp.limit);
|
||||||
|
const page = normalizePageNumber(sp.page);
|
||||||
|
const back = normalizeBack(sp.back);
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
data = await loadEvents({
|
data = await loadEvents({
|
||||||
agentId: sp.agent_id,
|
agentId: sp.agent_id,
|
||||||
checkId: sp.check_id,
|
checkId: sp.check_id,
|
||||||
cursor: sp.cursor,
|
cursor: sp.cursor,
|
||||||
|
back,
|
||||||
limit,
|
limit,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -30,12 +35,14 @@ export default async function EventsPage({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<EventsBrowser
|
<EventsBrowser
|
||||||
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}:${limit}`}
|
key={`${sp.cursor ?? ""}:${back ? "b" : "f"}:${page}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}:${limit}`}
|
||||||
initialData={data}
|
initialData={data}
|
||||||
cursor={sp.cursor}
|
cursor={sp.cursor}
|
||||||
|
back={back}
|
||||||
agentId={sp.agent_id}
|
agentId={sp.agent_id}
|
||||||
checkId={sp.check_id}
|
checkId={sp.check_id}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
|
page={page}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,55 @@
|
|||||||
import { ErrorPanel } from "@/components/DataPanel";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { IncidentsBrowser } from "@/components/IncidentsBrowser";
|
import { IncidentsBrowser } from "@/components/IncidentsBrowser";
|
||||||
import { loadIncidents, loadInventory } from "@/lib/loaders";
|
import { loadIncidentsPage, loadInventory } from "@/lib/loaders";
|
||||||
|
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function IncidentsPage({
|
export default async function IncidentsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ state?: string }>;
|
searchParams: Promise<{
|
||||||
|
state?: string;
|
||||||
|
cursor?: string;
|
||||||
|
back?: string;
|
||||||
|
limit?: string;
|
||||||
|
sort?: string;
|
||||||
|
direction?: string;
|
||||||
|
page?: string;
|
||||||
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
|
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
|
||||||
let items;
|
const sort =
|
||||||
|
sp.sort === "opened_at" || sp.sort === "resolved_at" || sp.sort === "status"
|
||||||
|
? sp.sort
|
||||||
|
: "status";
|
||||||
|
const direction = sp.direction === "asc" || sp.direction === "desc" ? sp.direction : "desc";
|
||||||
|
const limit = normalizePageSize(sp.limit);
|
||||||
|
const pageNumber = normalizePageNumber(sp.page);
|
||||||
|
const back = normalizeBack(sp.back);
|
||||||
|
let page;
|
||||||
let inventory;
|
let inventory;
|
||||||
try {
|
try {
|
||||||
[items, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
|
[page, inventory] = await Promise.all([
|
||||||
|
loadIncidentsPage({ state, cursor: sp.cursor, back, limit, sort, direction }),
|
||||||
|
loadInventory(),
|
||||||
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return <ErrorPanel error={e} />;
|
return <ErrorPanel error={e} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<IncidentsBrowser
|
<IncidentsBrowser
|
||||||
key={state ?? ""}
|
key={`${sp.cursor ?? ""}:${back ? "b" : "f"}:${pageNumber}:${state ?? ""}:${sort}:${direction}:${limit}`}
|
||||||
initialItems={items}
|
initialData={page}
|
||||||
initialInventory={inventory}
|
initialInventory={inventory}
|
||||||
state={state}
|
state={state}
|
||||||
|
cursor={sp.cursor}
|
||||||
|
back={back}
|
||||||
|
sort={sort}
|
||||||
|
direction={direction}
|
||||||
|
limit={limit}
|
||||||
|
page={pageNumber}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import type { ReactNode } from "react";
|
||||||
import { TimeZoneCarousel, TimeZoneProvider } from "@/components/TimeZoneControls";
|
import { TimeZoneProvider } from "@/components/TimeZoneControls";
|
||||||
|
import { TopNav } from "@/components/TopNav";
|
||||||
|
import { loadSystemSummary } from "@/lib/loaders";
|
||||||
import { getUiTimeZone } from "@/lib/timezone";
|
import { getUiTimeZone } from "@/lib/timezone";
|
||||||
|
import type { SystemSummaryData } from "@/lib/view-types";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -9,50 +12,25 @@ export const metadata: Metadata = {
|
|||||||
description: "Read-only monitoring dashboard",
|
description: "Read-only monitoring dashboard",
|
||||||
};
|
};
|
||||||
|
|
||||||
const NAV = [
|
export const dynamic = "force-dynamic";
|
||||||
{ href: "/", label: "Overview" },
|
|
||||||
{ href: "/agents", label: "Agents" },
|
|
||||||
{ href: "/checks", label: "Checks" },
|
|
||||||
{ href: "/incidents", label: "Incidents" },
|
|
||||||
{ href: "/events", label: "Events" },
|
|
||||||
{ href: "/outbox", label: "Outbox" },
|
|
||||||
];
|
|
||||||
|
|
||||||
function Brand() {
|
async function safeLoadSystemSummary(): Promise<SystemSummaryData | null> {
|
||||||
return (
|
try {
|
||||||
<Link href="/" aria-label="Monlet overview" className="group flex shrink-0 items-center gap-2">
|
return await loadSystemSummary();
|
||||||
<span className="relative h-8 w-8 overflow-hidden rounded-md border border-emerald-400/40 bg-neutral-900 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]">
|
} catch {
|
||||||
<span className="absolute left-2 top-2 h-1.5 w-1.5 rounded-full bg-emerald-300 shadow-[0_0_12px_rgba(110,231,183,0.75)]" />
|
return null;
|
||||||
<span className="absolute bottom-2 left-2 h-2 w-0.5 rounded-full bg-cyan-300/80" />
|
}
|
||||||
<span className="absolute bottom-2 left-3.5 h-4 w-0.5 rounded-full bg-emerald-300" />
|
|
||||||
<span className="absolute bottom-2 left-5 h-2.5 w-0.5 rounded-full bg-sky-300/80" />
|
|
||||||
<span className="absolute right-1.5 top-1.5 h-1 w-1 rounded-full bg-neutral-500 group-hover:bg-emerald-200" />
|
|
||||||
</span>
|
|
||||||
<span className="text-xl font-semibold tracking-wide text-white">
|
|
||||||
mon<span className="text-emerald-300">let</span>
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||||
const configuredTimeZone = getUiTimeZone();
|
const configuredTimeZone = getUiTimeZone();
|
||||||
|
const summary = await safeLoadSystemSummary();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="h-full antialiased">
|
<html lang="en" className="h-full antialiased">
|
||||||
<body className="min-h-full flex flex-col bg-neutral-950 text-neutral-100 font-mono">
|
<body className="min-h-full flex flex-col bg-neutral-950 text-neutral-100 font-mono">
|
||||||
<TimeZoneProvider configuredTimeZone={configuredTimeZone}>
|
<TimeZoneProvider configuredTimeZone={configuredTimeZone}>
|
||||||
<header className="border-b border-neutral-800 px-4 py-3 flex flex-wrap items-center gap-4">
|
<TopNav initialSummary={summary} />
|
||||||
<Brand />
|
|
||||||
<nav className="flex flex-wrap gap-4 text-sm text-neutral-300">
|
|
||||||
{NAV.map((n) => (
|
|
||||||
<Link key={n.href} href={n.href} className="hover:text-white">
|
|
||||||
{n.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
<TimeZoneCarousel />
|
|
||||||
</header>
|
|
||||||
<main className="flex-1 px-4 py-6">{children}</main>
|
<main className="flex-1 px-4 py-6">{children}</main>
|
||||||
</TimeZoneProvider>
|
</TimeZoneProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,15 +1,44 @@
|
|||||||
import { ErrorPanel } from "@/components/DataPanel";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { OutboxBrowser } from "@/components/OutboxBrowser";
|
import { OutboxBrowser } from "@/components/OutboxBrowser";
|
||||||
import { loadOutboxAll } from "@/lib/loaders";
|
import { loadOutboxPage } from "@/lib/loaders";
|
||||||
|
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function OutboxPage() {
|
export default async function OutboxPage({
|
||||||
let items;
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{
|
||||||
|
cursor?: string;
|
||||||
|
back?: string;
|
||||||
|
limit?: string;
|
||||||
|
sort?: string;
|
||||||
|
direction?: string;
|
||||||
|
page?: string;
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const sp = await searchParams;
|
||||||
|
const limit = normalizePageSize(sp.limit);
|
||||||
|
const sort = sp.sort === "created_at" || sp.sort === "updated_at" ? sp.sort : "updated_at";
|
||||||
|
const direction = sp.direction === "asc" || sp.direction === "desc" ? sp.direction : "desc";
|
||||||
|
const pageNumber = normalizePageNumber(sp.page);
|
||||||
|
const back = normalizeBack(sp.back);
|
||||||
|
let page;
|
||||||
try {
|
try {
|
||||||
items = await loadOutboxAll();
|
page = await loadOutboxPage({ cursor: sp.cursor, back, limit, sort, direction });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return <ErrorPanel error={e} />;
|
return <ErrorPanel error={e} />;
|
||||||
}
|
}
|
||||||
return <OutboxBrowser initialItems={items} />;
|
return (
|
||||||
|
<OutboxBrowser
|
||||||
|
key={`${sp.cursor ?? ""}:${back ? "b" : "f"}:${pageNumber}:${sort}:${direction}:${limit}`}
|
||||||
|
initialData={page}
|
||||||
|
cursor={sp.cursor}
|
||||||
|
back={back}
|
||||||
|
sort={sort}
|
||||||
|
direction={direction}
|
||||||
|
limit={limit}
|
||||||
|
page={pageNumber}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import { ErrorPanel } from "@/components/DataPanel";
|
import { redirect } from "next/navigation";
|
||||||
import { OverviewDashboard } from "@/components/OverviewDashboard";
|
|
||||||
import { loadOverview } from "@/lib/loaders";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function OverviewPage() {
|
export default function HomePage() {
|
||||||
let data;
|
redirect("/agents");
|
||||||
try {
|
|
||||||
data = await loadOverview();
|
|
||||||
} catch (e) {
|
|
||||||
return <ErrorPanel error={e} />;
|
|
||||||
}
|
|
||||||
return <OverviewDashboard initialData={data} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
104
web/src/components/AgentBlacklistBrowser.tsx
Normal file
104
web/src/components/AgentBlacklistBrowser.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||||
|
import { DateTime } from "@/components/TimeZoneControls";
|
||||||
|
import type { AgentBlacklistItem } from "@/lib/api";
|
||||||
|
|
||||||
|
export function AgentBlacklistBrowser({ initialRows }: { initialRows: AgentBlacklistItem[] }) {
|
||||||
|
const [rows, setRows] = useState(initialRows);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const runAction = async (action: string, blacklistId?: string, confirmText?: string) => {
|
||||||
|
if (confirmText && !window.confirm(confirmText)) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/agents/action", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action, blacklist_id: blacklistId }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(body?.error?.message ?? res.statusText);
|
||||||
|
}
|
||||||
|
if (action === "blacklist_clear") {
|
||||||
|
setRows([]);
|
||||||
|
} else if (blacklistId) {
|
||||||
|
setRows((current) => current.filter((r) => r.id !== blacklistId));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Link href="/agents" className="mb-4 inline-block text-sm text-blue-300 hover:text-blue-200">
|
||||||
|
← agents
|
||||||
|
</Link>
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<PageHeader title="Blacklist" count={rows.length} />
|
||||||
|
{rows.length > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => runAction("blacklist_clear", undefined, "Clear all blocked agent keys?")}
|
||||||
|
className="rounded border border-red-900 px-3 py-1.5 text-sm text-red-300 hover:border-red-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
clear all
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<EmptyPanel />
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<Th>fingerprint</Th>
|
||||||
|
<Th>host</Th>
|
||||||
|
<Th>agent_id</Th>
|
||||||
|
<Th>last_seen_at</Th>
|
||||||
|
<Th>actions</Th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<Td>{row.key_fingerprint}</Td>
|
||||||
|
<Td>{row.hostname}</Td>
|
||||||
|
<Td>{row.agent_id}</Td>
|
||||||
|
<Td>
|
||||||
|
<DateTime iso={row.last_seen_at} />
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Remove from blacklist"
|
||||||
|
aria-label="Remove from blacklist"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
runAction(
|
||||||
|
"blacklist_delete",
|
||||||
|
row.id,
|
||||||
|
`Remove ${row.hostname} from blacklist?`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="rounded border border-neutral-800 px-2 py-1 hover:border-neutral-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
🗑
|
||||||
|
</button>
|
||||||
|
</Td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ import { usePolling } from "@/lib/usePolling";
|
|||||||
type Agent = components["schemas"]["Agent"];
|
type Agent = components["schemas"]["Agent"];
|
||||||
type CheckState = components["schemas"]["CheckState"];
|
type CheckState = components["schemas"]["CheckState"];
|
||||||
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||||
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
|
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null };
|
||||||
type SortKey = "severity" | "check_id" | "last_seen";
|
type SortKey = "severity" | "check_id" | "last_seen";
|
||||||
type TabKey = "checks" | "events";
|
type TabKey = "checks" | "events";
|
||||||
type AgentDetailData = { agent: Agent; checks: CheckState[] };
|
type AgentDetailData = { agent: Agent; checks: CheckState[] };
|
||||||
@@ -47,6 +47,8 @@ export function AgentDetailBrowser({
|
|||||||
tab,
|
tab,
|
||||||
eventsLimit,
|
eventsLimit,
|
||||||
eventsCursor,
|
eventsCursor,
|
||||||
|
eventsBack,
|
||||||
|
eventsPage,
|
||||||
}: {
|
}: {
|
||||||
initialData: AgentDetailData;
|
initialData: AgentDetailData;
|
||||||
initialEvents: EventsPageData;
|
initialEvents: EventsPageData;
|
||||||
@@ -54,6 +56,8 @@ export function AgentDetailBrowser({
|
|||||||
tab: TabKey;
|
tab: TabKey;
|
||||||
eventsLimit: PageSize;
|
eventsLimit: PageSize;
|
||||||
eventsCursor?: string;
|
eventsCursor?: string;
|
||||||
|
eventsBack: boolean;
|
||||||
|
eventsPage: number;
|
||||||
}) {
|
}) {
|
||||||
const agentId = initialData.agent.agent_id;
|
const agentId = initialData.agent.agent_id;
|
||||||
const pollUrl = `/api/poll/agent?agent_id=${encodeURIComponent(agentId)}`;
|
const pollUrl = `/api/poll/agent?agent_id=${encodeURIComponent(agentId)}`;
|
||||||
@@ -110,6 +114,8 @@ export function AgentDetailBrowser({
|
|||||||
sort={sort}
|
sort={sort}
|
||||||
eventsLimit={eventsLimit}
|
eventsLimit={eventsLimit}
|
||||||
eventsCursor={eventsCursor}
|
eventsCursor={eventsCursor}
|
||||||
|
eventsBack={eventsBack}
|
||||||
|
eventsPage={eventsPage}
|
||||||
initialEvents={initialEvents}
|
initialEvents={initialEvents}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -188,20 +194,25 @@ function EventsTable({
|
|||||||
sort,
|
sort,
|
||||||
eventsLimit,
|
eventsLimit,
|
||||||
eventsCursor,
|
eventsCursor,
|
||||||
|
eventsBack,
|
||||||
|
eventsPage,
|
||||||
initialEvents,
|
initialEvents,
|
||||||
}: {
|
}: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
sort: SortKey;
|
sort: SortKey;
|
||||||
eventsLimit: PageSize;
|
eventsLimit: PageSize;
|
||||||
eventsCursor?: string;
|
eventsCursor?: string;
|
||||||
|
eventsBack: boolean;
|
||||||
|
eventsPage: number;
|
||||||
initialEvents: EventsPageData;
|
initialEvents: EventsPageData;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pollUrl = useMemo(() => {
|
const pollUrl = useMemo(() => {
|
||||||
const qs = new URLSearchParams({ agent_id: agentId, limit: String(eventsLimit) });
|
const qs = new URLSearchParams({ agent_id: agentId, limit: String(eventsLimit) });
|
||||||
if (eventsCursor) qs.set("cursor", eventsCursor);
|
if (eventsCursor) qs.set("cursor", eventsCursor);
|
||||||
|
if (eventsBack) qs.set("back", "1");
|
||||||
return `/api/poll/events?${qs.toString()}`;
|
return `/api/poll/events?${qs.toString()}`;
|
||||||
}, [agentId, eventsCursor, eventsLimit]);
|
}, [agentId, eventsCursor, eventsBack, eventsLimit]);
|
||||||
const { data } = usePolling(initialEvents, pollUrl);
|
const { data } = usePolling(initialEvents, pollUrl);
|
||||||
|
|
||||||
const onLimitChange = (next: PageSize) => {
|
const onLimitChange = (next: PageSize) => {
|
||||||
@@ -224,6 +235,7 @@ function EventsTable({
|
|||||||
<Th>check_id</Th>
|
<Th>check_id</Th>
|
||||||
<Th>status</Th>
|
<Th>status</Th>
|
||||||
<Th>duration_ms</Th>
|
<Th>duration_ms</Th>
|
||||||
|
<Th>SUMMARY</Th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -237,6 +249,7 @@ function EventsTable({
|
|||||||
</Td>
|
</Td>
|
||||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
||||||
<Td>{e.duration_ms}</Td>
|
<Td>{e.duration_ms}</Td>
|
||||||
|
<SummaryCell text={e.output} />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -244,8 +257,9 @@ function EventsTable({
|
|||||||
)}
|
)}
|
||||||
<Pager
|
<Pager
|
||||||
basePath={`/agents/${encodeURIComponent(agentId)}`}
|
basePath={`/agents/${encodeURIComponent(agentId)}`}
|
||||||
cursor={eventsCursor}
|
prevCursor={data.prev_cursor}
|
||||||
nextCursor={data.next_cursor}
|
nextCursor={data.next_cursor}
|
||||||
|
page={eventsPage}
|
||||||
extra={{ tab: "events", sort, events_limit: String(eventsLimit) }}
|
extra={{ tab: "events", sort, events_limit: String(eventsLimit) }}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||||
@@ -35,16 +36,25 @@ const statusRank: Record<Agent["status"], number> = { alive: 0, stale: 1, dead:
|
|||||||
const checkSortStatuses: CheckStatus[] = ["critical", "warning", "unknown", "ok"];
|
const checkSortStatuses: CheckStatus[] = ["critical", "warning", "unknown", "ok"];
|
||||||
|
|
||||||
export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
||||||
const { data } = usePolling(initialData, "/api/poll/inventory");
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
const { data } = usePolling(initialData, `/api/poll/inventory?v=${reloadKey}`);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [sort, setSort] = useState<SortField>("status");
|
const [sort, setSort] = useState<SortField>("status");
|
||||||
const [dir, setDir] = useState<SortDir>("desc");
|
const [dir, setDir] = useState<SortDir>("desc");
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const initialRows = useMemo(() => rowsFromInventory(data), [data]);
|
const initialRows = useMemo(() => rowsFromInventory(data), [data]);
|
||||||
|
|
||||||
const rows = useMemo(
|
const filteredRows = useMemo(
|
||||||
() => sortAgents(filterAgents(initialRows, query), sort, dir),
|
() => sortAgents(filterAgents(initialRows, query), sort, dir),
|
||||||
[dir, initialRows, query, sort],
|
[dir, initialRows, query, sort],
|
||||||
);
|
);
|
||||||
|
const pendingRows = filteredRows.filter(({ agent }) => agent.admission_state === "pending");
|
||||||
|
const acceptedRows = filteredRows.filter(({ agent }) => agent.admission_state === "accepted");
|
||||||
|
const rows = [...pendingRows, ...acceptedRows];
|
||||||
|
const allPendingRows = initialRows.filter(({ agent }) => agent.admission_state === "pending");
|
||||||
|
const allRotationPendingCount = allPendingRows.filter(
|
||||||
|
({ agent }) => agent.rotation_pending,
|
||||||
|
).length;
|
||||||
|
|
||||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@@ -69,15 +79,46 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
|||||||
}
|
}
|
||||||
setPage(1);
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
const runAction = async (
|
||||||
|
action: string,
|
||||||
|
agentId?: string,
|
||||||
|
confirmText?: string,
|
||||||
|
includeRotation = false,
|
||||||
|
) => {
|
||||||
|
if (confirmText && !window.confirm(confirmText)) return;
|
||||||
|
const key = `${action}:${agentId ?? "*"}`;
|
||||||
|
setBusy(key);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/agents/action", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ action, agent_id: agentId, include_rotation: includeRotation }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
throw new Error(body?.error?.message ?? res.statusText);
|
||||||
|
}
|
||||||
|
setReloadKey((v) => v + 1);
|
||||||
|
} catch (e) {
|
||||||
|
window.alert(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 flex items-baseline gap-3">
|
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-3">
|
||||||
|
<div className="flex items-baseline gap-3">
|
||||||
<h1 className="text-lg font-semibold">Agents</h1>
|
<h1 className="text-lg font-semibold">Agents</h1>
|
||||||
<span className="text-sm text-neutral-400">
|
<span className="text-sm text-neutral-400">
|
||||||
{rows.length} of {initialRows.length} items
|
{rows.length} of {initialRows.length} items
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<Link href="/agents/blacklist" className="text-sm text-blue-300 hover:text-blue-200">
|
||||||
|
Blacklist
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
<ListToolbar
|
<ListToolbar
|
||||||
query={query}
|
query={query}
|
||||||
onQueryChange={onQueryChange}
|
onQueryChange={onQueryChange}
|
||||||
@@ -88,6 +129,51 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
|||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<EmptyPanel />
|
<EmptyPanel />
|
||||||
) : (
|
) : (
|
||||||
|
<AgentsTable
|
||||||
|
rows={visible}
|
||||||
|
sort={sort}
|
||||||
|
dir={dir}
|
||||||
|
chooseSort={chooseSort}
|
||||||
|
busy={busy}
|
||||||
|
runAction={runAction}
|
||||||
|
pendingCount={allPendingRows.length}
|
||||||
|
rotationPendingCount={allRotationPendingCount}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentsTable({
|
||||||
|
rows,
|
||||||
|
sort,
|
||||||
|
dir,
|
||||||
|
chooseSort,
|
||||||
|
busy,
|
||||||
|
runAction,
|
||||||
|
pendingCount,
|
||||||
|
rotationPendingCount,
|
||||||
|
}: {
|
||||||
|
rows: AgentRow[];
|
||||||
|
sort: SortField;
|
||||||
|
dir: SortDir;
|
||||||
|
chooseSort: (field: SortField) => void;
|
||||||
|
busy: string | null;
|
||||||
|
runAction: (
|
||||||
|
action: string,
|
||||||
|
agentId?: string,
|
||||||
|
confirmText?: string,
|
||||||
|
includeRotation?: boolean,
|
||||||
|
) => void;
|
||||||
|
pendingCount: number;
|
||||||
|
rotationPendingCount: number;
|
||||||
|
}) {
|
||||||
|
const acceptAllText =
|
||||||
|
rotationPendingCount > 0
|
||||||
|
? `ROTATION: existing accepted keys will be replaced for ${rotationPendingCount} agents. Accept all pending agents including rotations?`
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -108,13 +194,50 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
|||||||
</Th>
|
</Th>
|
||||||
<Th>features</Th>
|
<Th>features</Th>
|
||||||
<Th>labels</Th>
|
<Th>labels</Th>
|
||||||
|
<Th>actions</Th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visible.map(({ agent: a, checks }) => (
|
{pendingCount > 0 ? (
|
||||||
<tr key={a.agent_id}>
|
<tr>
|
||||||
|
<td colSpan={6} className="border-b border-neutral-900 bg-amber-950/20 px-2 py-2 text-amber-300">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span>{pendingCount} pending</span>
|
||||||
|
<ActionButton
|
||||||
|
label="✅"
|
||||||
|
title="Accept all"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() =>
|
||||||
|
runAction("accept_all", undefined, acceptAllText, rotationPendingCount > 0)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="⛔"
|
||||||
|
title="Block all"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => runAction("block_all", undefined, "Block all pending agents?")}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="🗑"
|
||||||
|
title="Remove all"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => runAction("remove_all", undefined, "Remove all pending agents and their data?")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
{rows.map(({ agent: a, checks }) => (
|
||||||
|
<tr key={a.agent_id} className={a.admission_state === "pending" ? "bg-amber-950/10" : ""}>
|
||||||
<Td>
|
<Td>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<AgentLink agent={a} agentId={a.agent_id} />
|
<AgentLink agent={a} agentId={a.agent_id} />
|
||||||
|
{a.admission_state === "pending" ? (
|
||||||
|
<span className="rounded border border-amber-800 px-1.5 py-0.5 text-xs text-amber-300">
|
||||||
|
{a.rotation_pending ? "ROTATION" : "pending"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<AgentStatus status={a.status} checks={checks} />
|
<AgentStatus status={a.status} checks={checks} />
|
||||||
@@ -128,13 +251,90 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
|||||||
<Td>
|
<Td>
|
||||||
<LabelChips labels={a.labels} limit={4} />
|
<LabelChips labels={a.labels} limit={4} />
|
||||||
</Td>
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<AgentActions agent={a} busy={busy} runAction={runAction} />
|
||||||
|
</Td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
);
|
||||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
}
|
||||||
</>
|
|
||||||
|
function AgentActions({
|
||||||
|
agent,
|
||||||
|
busy,
|
||||||
|
runAction,
|
||||||
|
}: {
|
||||||
|
agent: Agent;
|
||||||
|
busy: string | null;
|
||||||
|
runAction: (
|
||||||
|
action: string,
|
||||||
|
agentId?: string,
|
||||||
|
confirmText?: string,
|
||||||
|
includeRotation?: boolean,
|
||||||
|
) => void;
|
||||||
|
}) {
|
||||||
|
const disabled = busy !== null;
|
||||||
|
if (agent.admission_state === "pending") {
|
||||||
|
const acceptText = agent.rotation_pending
|
||||||
|
? `ROTATION: existing accepted key for ${agent.hostname} will be replaced. Accept?`
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<ActionButton
|
||||||
|
label="✅"
|
||||||
|
title={agent.rotation_pending ? "Accept key rotation" : "Accept"}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => runAction("accept", agent.agent_id, acceptText)}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="⛔"
|
||||||
|
title="Block"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => runAction("block", agent.agent_id, `Block ${agent.hostname}?`)}
|
||||||
|
/>
|
||||||
|
<ActionButton
|
||||||
|
label="🗑"
|
||||||
|
title="Remove"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => runAction("remove", agent.agent_id, `Remove ${agent.hostname} and all its data?`)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ActionButton
|
||||||
|
label="🗑"
|
||||||
|
title="Remove"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => runAction("remove", agent.agent_id, `Remove ${agent.hostname} and all its data?`)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ActionButton({
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
disabled,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
title: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
className="rounded border border-neutral-800 bg-neutral-950 px-2 py-1 text-sm hover:border-neutral-500 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { usePolling } from "@/lib/usePolling";
|
|||||||
import type { InventoryData } from "@/lib/view-types";
|
import type { InventoryData } from "@/lib/view-types";
|
||||||
|
|
||||||
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||||
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
|
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null };
|
||||||
type TabKey = "agents" | "events";
|
type TabKey = "agents" | "events";
|
||||||
|
|
||||||
export function CheckDetailBrowser({
|
export function CheckDetailBrowser({
|
||||||
@@ -36,6 +36,8 @@ export function CheckDetailBrowser({
|
|||||||
tab,
|
tab,
|
||||||
eventsLimit,
|
eventsLimit,
|
||||||
eventsCursor,
|
eventsCursor,
|
||||||
|
eventsBack,
|
||||||
|
eventsPage,
|
||||||
}: {
|
}: {
|
||||||
checkId: string;
|
checkId: string;
|
||||||
initialData: InventoryData;
|
initialData: InventoryData;
|
||||||
@@ -43,6 +45,8 @@ export function CheckDetailBrowser({
|
|||||||
tab: TabKey;
|
tab: TabKey;
|
||||||
eventsLimit: PageSize;
|
eventsLimit: PageSize;
|
||||||
eventsCursor?: string;
|
eventsCursor?: string;
|
||||||
|
eventsBack: boolean;
|
||||||
|
eventsPage: number;
|
||||||
}) {
|
}) {
|
||||||
const { data } = usePolling(initialData, "/api/poll/inventory");
|
const { data } = usePolling(initialData, "/api/poll/inventory");
|
||||||
const agentsById = useMemo(
|
const agentsById = useMemo(
|
||||||
@@ -110,6 +114,8 @@ export function CheckDetailBrowser({
|
|||||||
agentsById={agentsById}
|
agentsById={agentsById}
|
||||||
eventsLimit={eventsLimit}
|
eventsLimit={eventsLimit}
|
||||||
eventsCursor={eventsCursor}
|
eventsCursor={eventsCursor}
|
||||||
|
eventsBack={eventsBack}
|
||||||
|
eventsPage={eventsPage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -177,12 +183,16 @@ function EventsForCheck({
|
|||||||
agentsById,
|
agentsById,
|
||||||
eventsLimit,
|
eventsLimit,
|
||||||
eventsCursor,
|
eventsCursor,
|
||||||
|
eventsBack,
|
||||||
|
eventsPage,
|
||||||
}: {
|
}: {
|
||||||
checkId: string;
|
checkId: string;
|
||||||
initialData: EventsPageData;
|
initialData: EventsPageData;
|
||||||
agentsById: Map<string, Agent>;
|
agentsById: Map<string, Agent>;
|
||||||
eventsLimit: PageSize;
|
eventsLimit: PageSize;
|
||||||
eventsCursor?: string;
|
eventsCursor?: string;
|
||||||
|
eventsBack: boolean;
|
||||||
|
eventsPage: number;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pollUrl = useMemo(() => {
|
const pollUrl = useMemo(() => {
|
||||||
@@ -191,8 +201,9 @@ function EventsForCheck({
|
|||||||
limit: String(eventsLimit),
|
limit: String(eventsLimit),
|
||||||
});
|
});
|
||||||
if (eventsCursor) qs.set("cursor", eventsCursor);
|
if (eventsCursor) qs.set("cursor", eventsCursor);
|
||||||
|
if (eventsBack) qs.set("back", "1");
|
||||||
return `/api/poll/events?${qs.toString()}`;
|
return `/api/poll/events?${qs.toString()}`;
|
||||||
}, [checkId, eventsCursor, eventsLimit]);
|
}, [checkId, eventsCursor, eventsBack, eventsLimit]);
|
||||||
const { data } = usePolling(initialData, pollUrl);
|
const { data } = usePolling(initialData, pollUrl);
|
||||||
|
|
||||||
const onLimitChange = (next: PageSize) => {
|
const onLimitChange = (next: PageSize) => {
|
||||||
@@ -245,8 +256,9 @@ function EventsForCheck({
|
|||||||
)}
|
)}
|
||||||
<Pager
|
<Pager
|
||||||
basePath={`/checks/${encodeURIComponent(checkId)}`}
|
basePath={`/checks/${encodeURIComponent(checkId)}`}
|
||||||
cursor={eventsCursor}
|
prevCursor={data.prev_cursor}
|
||||||
nextCursor={data.next_cursor}
|
nextCursor={data.next_cursor}
|
||||||
|
page={eventsPage}
|
||||||
extra={{ tab: "events", events_limit: String(eventsLimit) }}
|
extra={{ tab: "events", events_limit: String(eventsLimit) }}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -15,25 +15,29 @@ import { type PageSize } from "@/lib/pagination";
|
|||||||
import { usePolling } from "@/lib/usePolling";
|
import { usePolling } from "@/lib/usePolling";
|
||||||
|
|
||||||
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||||
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
|
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null };
|
||||||
|
|
||||||
export function EventsBrowser({
|
export function EventsBrowser({
|
||||||
initialData,
|
initialData,
|
||||||
cursor,
|
cursor,
|
||||||
|
back,
|
||||||
agentId,
|
agentId,
|
||||||
checkId,
|
checkId,
|
||||||
limit,
|
limit,
|
||||||
|
page,
|
||||||
}: {
|
}: {
|
||||||
initialData: EventsPageData;
|
initialData: EventsPageData;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
back: boolean;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
checkId?: string;
|
checkId?: string;
|
||||||
limit: PageSize;
|
limit: PageSize;
|
||||||
|
page: number;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const url = useMemo(
|
const url = useMemo(
|
||||||
() => eventsPollUrl({ cursor, agentId, checkId, limit }),
|
() => eventsPollUrl({ cursor, back, agentId, checkId, limit }),
|
||||||
[agentId, checkId, cursor, limit],
|
[agentId, back, checkId, cursor, limit],
|
||||||
);
|
);
|
||||||
const { data } = usePolling(initialData, url);
|
const { data } = usePolling(initialData, url);
|
||||||
|
|
||||||
@@ -86,8 +90,9 @@ export function EventsBrowser({
|
|||||||
)}
|
)}
|
||||||
<Pager
|
<Pager
|
||||||
basePath="/events"
|
basePath="/events"
|
||||||
cursor={cursor}
|
prevCursor={data.prev_cursor}
|
||||||
nextCursor={data.next_cursor}
|
nextCursor={data.next_cursor}
|
||||||
|
page={page}
|
||||||
extra={{ agent_id: agentId, check_id: checkId, limit: String(limit) }}
|
extra={{ agent_id: agentId, check_id: checkId, limit: String(limit) }}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -160,17 +165,20 @@ function eventsHref(params: {
|
|||||||
|
|
||||||
function eventsPollUrl({
|
function eventsPollUrl({
|
||||||
cursor,
|
cursor,
|
||||||
|
back,
|
||||||
agentId,
|
agentId,
|
||||||
checkId,
|
checkId,
|
||||||
limit,
|
limit,
|
||||||
}: {
|
}: {
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
|
back: boolean;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
checkId?: string;
|
checkId?: string;
|
||||||
limit: PageSize;
|
limit: PageSize;
|
||||||
}) {
|
}) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (cursor) qs.set("cursor", cursor);
|
if (cursor) qs.set("cursor", cursor);
|
||||||
|
if (back) qs.set("back", "1");
|
||||||
if (agentId) qs.set("agent_id", agentId);
|
if (agentId) qs.set("agent_id", agentId);
|
||||||
if (checkId) qs.set("check_id", checkId);
|
if (checkId) qs.set("check_id", checkId);
|
||||||
qs.set("limit", String(limit));
|
qs.set("limit", String(limit));
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { AgentLink } from "@/components/AgentLink";
|
import { AgentLink } from "@/components/AgentLink";
|
||||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||||
import { ClientPager } from "@/components/ClientPager";
|
|
||||||
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
|
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
|
||||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||||
|
import { Pager } from "@/components/Pager";
|
||||||
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
|
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
|
||||||
import { DateTime } from "@/components/TimeZoneControls";
|
import { DateTime } from "@/components/TimeZoneControls";
|
||||||
import type { components } from "@/lib/api-types";
|
import type { components } from "@/lib/api-types";
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
import { statusBadgeClass } from "@/lib/format";
|
||||||
import {
|
import { type PageSize } from "@/lib/pagination";
|
||||||
DEFAULT_PAGE_SIZE,
|
|
||||||
clampPage,
|
|
||||||
pageCountOf,
|
|
||||||
paginate,
|
|
||||||
type PageSize,
|
|
||||||
} from "@/lib/pagination";
|
|
||||||
import { usePolling } from "@/lib/usePolling";
|
import { usePolling } from "@/lib/usePolling";
|
||||||
import type { InventoryData } from "@/lib/view-types";
|
import type { InventoryData } from "@/lib/view-types";
|
||||||
|
|
||||||
@@ -26,70 +21,75 @@ const STATES = ["all", "open", "resolved"] as const;
|
|||||||
|
|
||||||
type Incident = components["schemas"]["Incident"];
|
type Incident = components["schemas"]["Incident"];
|
||||||
type Agent = components["schemas"]["Agent"];
|
type Agent = components["schemas"]["Agent"];
|
||||||
type IncidentsData = { items: Incident[] };
|
type IncidentsData = { items: Incident[]; next_cursor?: string | null; prev_cursor?: string | null };
|
||||||
type SortField = "status" | "host" | "check" | "opened_at" | "resolved_at";
|
type SortField = "status" | "opened_at" | "resolved_at";
|
||||||
|
|
||||||
const stateRank: Record<Incident["state"], number> = { open: 0, resolved: 1 };
|
|
||||||
const severityRank: Record<Incident["severity"], number> = {
|
|
||||||
critical: 0,
|
|
||||||
warning: 1,
|
|
||||||
unknown: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function IncidentsBrowser({
|
export function IncidentsBrowser({
|
||||||
initialItems,
|
initialData,
|
||||||
initialInventory,
|
initialInventory,
|
||||||
state,
|
state,
|
||||||
|
cursor,
|
||||||
|
back,
|
||||||
|
sort,
|
||||||
|
direction,
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
}: {
|
}: {
|
||||||
initialItems: Incident[];
|
initialData: IncidentsData;
|
||||||
initialInventory: InventoryData;
|
initialInventory: InventoryData;
|
||||||
state?: "open" | "resolved";
|
state?: "open" | "resolved";
|
||||||
|
cursor?: string;
|
||||||
|
back: boolean;
|
||||||
|
sort: SortField;
|
||||||
|
direction: SortDir;
|
||||||
|
limit: PageSize;
|
||||||
|
page: number;
|
||||||
}) {
|
}) {
|
||||||
const url = useMemo(
|
const router = useRouter();
|
||||||
() => (state ? `/api/poll/incidents?state=${state}` : "/api/poll/incidents"),
|
const url = useMemo(() => {
|
||||||
[state],
|
const q = new URLSearchParams();
|
||||||
);
|
if (state) q.set("state", state);
|
||||||
const { data } = usePolling<IncidentsData>({ items: initialItems }, url);
|
q.set("sort", sort);
|
||||||
|
q.set("direction", direction);
|
||||||
|
q.set("limit", String(limit));
|
||||||
|
if (cursor) q.set("cursor", cursor);
|
||||||
|
if (back) q.set("back", "1");
|
||||||
|
return `/api/poll/incidents?${q.toString()}`;
|
||||||
|
}, [cursor, back, direction, limit, state, sort]);
|
||||||
|
const { data } = usePolling<IncidentsData>(initialData, url);
|
||||||
const { data: inventory } = usePolling(initialInventory, "/api/poll/inventory");
|
const { data: inventory } = usePolling(initialInventory, "/api/poll/inventory");
|
||||||
const agentsById = useMemo(
|
const agentsById = useMemo(
|
||||||
() => new Map(inventory.agents.map((agent) => [agent.agent_id, agent])),
|
() => new Map(inventory.agents.map((agent) => [agent.agent_id, agent])),
|
||||||
[inventory.agents],
|
[inventory.agents],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [sort, setSort] = useState<SortField>("status");
|
|
||||||
const [dir, setDir] = useState<SortDir>("asc");
|
|
||||||
const sorted = useMemo(
|
|
||||||
() => sortIncidents(data.items, agentsById, sort, dir),
|
|
||||||
[agentsById, data.items, dir, sort],
|
|
||||||
);
|
|
||||||
|
|
||||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const onPageSizeChange = (next: PageSize) => {
|
const onPageSizeChange = (next: PageSize) => {
|
||||||
setPageSize(next);
|
router.push(incidentsHref({ state, sort, direction, limit: next }));
|
||||||
setPage(1);
|
|
||||||
};
|
};
|
||||||
const chooseSort = (field: SortField) => {
|
const chooseSort = (field: SortField) => {
|
||||||
|
let nextSort = sort;
|
||||||
|
let nextDirection = direction;
|
||||||
if (field === sort) {
|
if (field === sort) {
|
||||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
nextDirection = direction === "asc" ? "desc" : "asc";
|
||||||
} else {
|
} else {
|
||||||
setSort(field);
|
nextSort = field;
|
||||||
setDir(fieldDefaultDir(field));
|
nextDirection = fieldDefaultDir();
|
||||||
}
|
}
|
||||||
setPage(1);
|
router.push(incidentsHref({ state, sort: nextSort, direction: nextDirection, limit }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const pageCount = pageCountOf(sorted.length, pageSize);
|
|
||||||
const currentPage = clampPage(page, pageCount);
|
|
||||||
const visible = paginate(sorted, currentPage, pageSize);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Incidents" count={sorted.length} />
|
<PageHeader title="Incidents" count={data.items.length} />
|
||||||
<div className="mb-3 flex items-center justify-between gap-3 text-sm">
|
<div className="mb-3 flex items-center justify-between gap-3 text-sm">
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{STATES.map((s) => {
|
{STATES.map((s) => {
|
||||||
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`;
|
const href = incidentsHref({
|
||||||
|
state: s === "all" ? undefined : s,
|
||||||
|
sort: sort === "resolved_at" && s !== "resolved" ? "status" : sort,
|
||||||
|
direction,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
const active = (s === "all" && !state) || s === state;
|
const active = (s === "all" && !state) || s === state;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
@@ -102,44 +102,55 @@ export function IncidentsBrowser({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
<PageSizeSelect value={limit} onChange={onPageSizeChange} />
|
||||||
</div>
|
</div>
|
||||||
{sorted.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<EmptyPanel />
|
<EmptyPanel />
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<Th>
|
<Th>
|
||||||
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
<SortHeaderButton field="status" current={sort} dir={direction} onClick={chooseSort}>
|
||||||
STATUS
|
STATUS
|
||||||
</SortHeaderButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
|
<Th>HOST</Th>
|
||||||
|
<Th>CHECK</Th>
|
||||||
<Th>
|
<Th>
|
||||||
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
<SortHeaderButton
|
||||||
HOST
|
field="opened_at"
|
||||||
</SortHeaderButton>
|
current={sort}
|
||||||
</Th>
|
dir={direction}
|
||||||
<Th>
|
onClick={chooseSort}
|
||||||
<SortHeaderButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
>
|
||||||
CHECK
|
|
||||||
</SortHeaderButton>
|
|
||||||
</Th>
|
|
||||||
<Th>
|
|
||||||
<SortHeaderButton field="opened_at" current={sort} dir={dir} onClick={chooseSort}>
|
|
||||||
OPENED_AT
|
OPENED_AT
|
||||||
</SortHeaderButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
<Th>
|
<Th>
|
||||||
<SortHeaderButton field="resolved_at" current={sort} dir={dir} onClick={chooseSort}>
|
{state === "resolved" ? (
|
||||||
|
<SortHeaderButton
|
||||||
|
field="resolved_at"
|
||||||
|
current={sort}
|
||||||
|
dir={direction}
|
||||||
|
onClick={chooseSort}
|
||||||
|
>
|
||||||
RESOLVED_AT
|
RESOLVED_AT
|
||||||
</SortHeaderButton>
|
</SortHeaderButton>
|
||||||
|
) : (
|
||||||
|
// PH-review: backend rejects sort=resolved_at when state is
|
||||||
|
// not "resolved" (server/monlet_server/api/incidents.py
|
||||||
|
// auto-applies state=resolved when none is set, which would
|
||||||
|
// silently filter the "all" tab). Sort is available only on
|
||||||
|
// the resolved tab.
|
||||||
|
"RESOLVED_AT"
|
||||||
|
)}
|
||||||
</Th>
|
</Th>
|
||||||
<Th>SUMMARY</Th>
|
<Th>SUMMARY</Th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visible.map((incident) => (
|
{data.items.map((incident) => (
|
||||||
<IncidentRow
|
<IncidentRow
|
||||||
key={incident.id}
|
key={incident.id}
|
||||||
incident={incident}
|
incident={incident}
|
||||||
@@ -149,7 +160,13 @@ export function IncidentsBrowser({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
<Pager
|
||||||
|
basePath="/incidents"
|
||||||
|
prevCursor={data.prev_cursor}
|
||||||
|
nextCursor={data.next_cursor}
|
||||||
|
page={page}
|
||||||
|
extra={{ state, sort, direction, limit: String(limit) }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -203,56 +220,26 @@ function incidentCheckId(incident: Incident): string {
|
|||||||
return incident.check_id ?? "";
|
return incident.check_id ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function fieldDefaultDir(field: SortField): SortDir {
|
function fieldDefaultDir(): SortDir {
|
||||||
switch (field) {
|
|
||||||
case "status":
|
|
||||||
return "asc";
|
|
||||||
case "host":
|
|
||||||
case "check":
|
|
||||||
return "asc";
|
|
||||||
case "opened_at":
|
|
||||||
case "resolved_at":
|
|
||||||
return "desc";
|
return "desc";
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function sortIncidents(
|
function incidentsHref({
|
||||||
items: Incident[],
|
state,
|
||||||
agentsById: Map<string, Agent>,
|
sort,
|
||||||
sort: SortField,
|
direction,
|
||||||
dir: SortDir,
|
limit,
|
||||||
): Incident[] {
|
}: {
|
||||||
return [...items].sort((a, b) => {
|
state?: "open" | "resolved";
|
||||||
const primary = compareIncidents(a, b, agentsById, sort);
|
sort: SortField;
|
||||||
if (primary !== 0) return dir === "asc" ? primary : -primary;
|
direction: SortDir;
|
||||||
return (b.opened_at ?? "").localeCompare(a.opened_at ?? "");
|
limit: PageSize;
|
||||||
});
|
}) {
|
||||||
}
|
const qs = new URLSearchParams();
|
||||||
|
if (state) qs.set("state", state);
|
||||||
function compareIncidents(
|
qs.set("sort", sort);
|
||||||
a: Incident,
|
qs.set("direction", direction);
|
||||||
b: Incident,
|
qs.set("limit", String(limit));
|
||||||
agentsById: Map<string, Agent>,
|
const query = qs.toString();
|
||||||
sort: SortField,
|
return query ? `/incidents?${query}` : "/incidents";
|
||||||
): number {
|
|
||||||
switch (sort) {
|
|
||||||
case "status": {
|
|
||||||
const s = stateRank[a.state] - stateRank[b.state];
|
|
||||||
if (s !== 0) return s;
|
|
||||||
return severityRank[a.severity] - severityRank[b.severity];
|
|
||||||
}
|
|
||||||
case "host":
|
|
||||||
return hostFor(a, agentsById).localeCompare(hostFor(b, agentsById));
|
|
||||||
case "check":
|
|
||||||
return incidentCheckId(a).localeCompare(incidentCheckId(b));
|
|
||||||
case "opened_at":
|
|
||||||
return (a.opened_at ?? "").localeCompare(b.opened_at ?? "");
|
|
||||||
case "resolved_at":
|
|
||||||
return (a.resolved_at ?? "").localeCompare(b.resolved_at ?? "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hostFor(incident: Incident, agentsById: Map<string, Agent>): string {
|
|
||||||
const id = incidentAgentId(incident);
|
|
||||||
return agentsById.get(id)?.hostname || id;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,64 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { ClientPager } from "@/components/ClientPager";
|
|
||||||
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||||
|
import { Pager } from "@/components/Pager";
|
||||||
|
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
|
||||||
import { DateTime } from "@/components/TimeZoneControls";
|
import { DateTime } from "@/components/TimeZoneControls";
|
||||||
import type { components } from "@/lib/api-types";
|
import type { components } from "@/lib/api-types";
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
import { statusBadgeClass } from "@/lib/format";
|
||||||
import {
|
import { type PageSize } from "@/lib/pagination";
|
||||||
DEFAULT_PAGE_SIZE,
|
|
||||||
clampPage,
|
|
||||||
pageCountOf,
|
|
||||||
paginate,
|
|
||||||
type PageSize,
|
|
||||||
} from "@/lib/pagination";
|
|
||||||
import { usePolling } from "@/lib/usePolling";
|
import { usePolling } from "@/lib/usePolling";
|
||||||
|
|
||||||
type OutboxItem = components["schemas"]["NotificationOutboxItem"];
|
type OutboxItem = components["schemas"]["NotificationOutboxItem"];
|
||||||
type OutboxData = { items: OutboxItem[] };
|
type OutboxData = { items: OutboxItem[]; next_cursor?: string | null; prev_cursor?: string | null };
|
||||||
|
type SortField = "created_at" | "updated_at";
|
||||||
|
|
||||||
export function OutboxBrowser({ initialItems }: { initialItems: OutboxItem[] }) {
|
export function OutboxBrowser({
|
||||||
const { data } = usePolling<OutboxData>({ items: initialItems }, "/api/poll/outbox");
|
initialData,
|
||||||
|
cursor,
|
||||||
|
back,
|
||||||
|
sort,
|
||||||
|
direction,
|
||||||
|
limit,
|
||||||
|
page,
|
||||||
|
}: {
|
||||||
|
initialData: OutboxData;
|
||||||
|
cursor?: string;
|
||||||
|
back: boolean;
|
||||||
|
sort: SortField;
|
||||||
|
direction: SortDir;
|
||||||
|
limit: PageSize;
|
||||||
|
page: number;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const url = useMemo(() => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set("sort", sort);
|
||||||
|
q.set("direction", direction);
|
||||||
|
q.set("limit", String(limit));
|
||||||
|
if (cursor) q.set("cursor", cursor);
|
||||||
|
if (back) q.set("back", "1");
|
||||||
|
return `/api/poll/outbox?${q.toString()}`;
|
||||||
|
}, [cursor, back, direction, limit, sort]);
|
||||||
|
const { data } = usePolling<OutboxData>(initialData, url);
|
||||||
|
|
||||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const onPageSizeChange = (next: PageSize) => {
|
const onPageSizeChange = (next: PageSize) => {
|
||||||
setPageSize(next);
|
router.push(outboxHref({ sort, direction, limit: next }));
|
||||||
setPage(1);
|
};
|
||||||
|
const chooseSort = (field: SortField) => {
|
||||||
|
const nextDirection = field === sort && direction === "desc" ? "asc" : "desc";
|
||||||
|
router.push(outboxHref({ sort: field, direction: nextDirection, limit }));
|
||||||
};
|
};
|
||||||
|
|
||||||
const pageCount = pageCountOf(data.items.length, pageSize);
|
|
||||||
const currentPage = clampPage(page, pageCount);
|
|
||||||
const visible = paginate(data.items, currentPage, pageSize);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Notification outbox" count={data.items.length} />
|
<PageHeader title="Notification outbox" count={data.items.length} />
|
||||||
<div className="mb-3 flex justify-end">
|
<div className="mb-3 flex justify-end">
|
||||||
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
<PageSizeSelect value={limit} onChange={onPageSizeChange} />
|
||||||
</div>
|
</div>
|
||||||
{data.items.length === 0 ? (
|
{data.items.length === 0 ? (
|
||||||
<EmptyPanel />
|
<EmptyPanel />
|
||||||
@@ -46,7 +66,11 @@ export function OutboxBrowser({ initialItems }: { initialItems: OutboxItem[] })
|
|||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<Th>updated_at</Th>
|
<Th>
|
||||||
|
<SortHeaderButton field="updated_at" current={sort} dir={direction} onClick={chooseSort}>
|
||||||
|
updated_at
|
||||||
|
</SortHeaderButton>
|
||||||
|
</Th>
|
||||||
<Th>notifier</Th>
|
<Th>notifier</Th>
|
||||||
<Th>event</Th>
|
<Th>event</Th>
|
||||||
<Th>state</Th>
|
<Th>state</Th>
|
||||||
@@ -56,7 +80,7 @@ export function OutboxBrowser({ initialItems }: { initialItems: OutboxItem[] })
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{visible.map((o) => (
|
{data.items.map((o) => (
|
||||||
<tr key={o.id}>
|
<tr key={o.id}>
|
||||||
<Td>
|
<Td>
|
||||||
<DateTime iso={o.updated_at} />
|
<DateTime iso={o.updated_at} />
|
||||||
@@ -74,7 +98,29 @@ export function OutboxBrowser({ initialItems }: { initialItems: OutboxItem[] })
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
)}
|
)}
|
||||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
<Pager
|
||||||
|
basePath="/outbox"
|
||||||
|
prevCursor={data.prev_cursor}
|
||||||
|
nextCursor={data.next_cursor}
|
||||||
|
page={page}
|
||||||
|
extra={{ sort, direction, limit: String(limit) }}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function outboxHref({
|
||||||
|
sort,
|
||||||
|
direction,
|
||||||
|
limit,
|
||||||
|
}: {
|
||||||
|
sort: SortField;
|
||||||
|
direction: SortDir;
|
||||||
|
limit: PageSize;
|
||||||
|
}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set("sort", sort);
|
||||||
|
qs.set("direction", direction);
|
||||||
|
qs.set("limit", String(limit));
|
||||||
|
return `/outbox?${qs.toString()}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
|
||||||
import { usePolling } from "@/lib/usePolling";
|
|
||||||
import type { OverviewData } from "@/lib/view-types";
|
|
||||||
|
|
||||||
export function OverviewDashboard({ initialData }: { initialData: OverviewData }) {
|
|
||||||
const { data } = usePolling(initialData, "/api/poll/overview");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<h1 className="text-lg font-semibold">Overview</h1>
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
||||||
<Card title="Agents" total={data.totalAgents} href="/agents" tally={data.agents} />
|
|
||||||
<Card title="Checks" total={data.totalChecks} href="/checks" tally={data.checks} />
|
|
||||||
<Card
|
|
||||||
title="Open incidents"
|
|
||||||
total={data.openIncidents}
|
|
||||||
href="/incidents"
|
|
||||||
tally={{}}
|
|
||||||
highlight={data.openIncidents > 0 ? "open" : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Card({
|
|
||||||
title,
|
|
||||||
total,
|
|
||||||
href,
|
|
||||||
tally,
|
|
||||||
highlight,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
total: number;
|
|
||||||
href: string;
|
|
||||||
tally: Record<string, number>;
|
|
||||||
highlight?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link href={href} className="block border border-neutral-800 p-4 hover:border-neutral-600">
|
|
||||||
<div className="text-xs uppercase tracking-wide text-neutral-400">{title}</div>
|
|
||||||
<div className={`mt-1 text-3xl ${highlight ? statusBadgeClass(highlight) : ""}`}>{total}</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-3 text-xs">
|
|
||||||
{Object.entries(tally).map(([k, v]) => (
|
|
||||||
<span key={k} className={statusBadgeClass(k)}>
|
|
||||||
{k} {v}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,27 +2,55 @@ import Link from "next/link";
|
|||||||
|
|
||||||
export function Pager({
|
export function Pager({
|
||||||
basePath,
|
basePath,
|
||||||
cursor,
|
prevCursor,
|
||||||
nextCursor,
|
nextCursor,
|
||||||
|
page = 1,
|
||||||
extra = {},
|
extra = {},
|
||||||
}: {
|
}: {
|
||||||
basePath: string;
|
basePath: string;
|
||||||
cursor?: string;
|
prevCursor?: string | null;
|
||||||
nextCursor?: string | null;
|
nextCursor?: string | null;
|
||||||
|
page?: number;
|
||||||
extra?: Record<string, string | undefined>;
|
extra?: Record<string, string | undefined>;
|
||||||
}) {
|
}) {
|
||||||
const make = (c?: string) => {
|
const make = ({
|
||||||
|
nextPage,
|
||||||
|
nextPageCursor,
|
||||||
|
back,
|
||||||
|
}: {
|
||||||
|
nextPage: number;
|
||||||
|
nextPageCursor?: string;
|
||||||
|
back?: boolean;
|
||||||
|
}) => {
|
||||||
const sp = new URLSearchParams();
|
const sp = new URLSearchParams();
|
||||||
for (const [k, v] of Object.entries(extra)) if (v) sp.set(k, v);
|
for (const [k, v] of Object.entries(extra)) if (v) sp.set(k, v);
|
||||||
if (c) sp.set("cursor", c);
|
if (nextPageCursor) sp.set("cursor", nextPageCursor);
|
||||||
|
if (back) sp.set("back", "1");
|
||||||
|
if (nextPage > 1) sp.set("page", String(nextPage));
|
||||||
const qs = sp.toString();
|
const qs = sp.toString();
|
||||||
return qs ? `${basePath}?${qs}` : basePath;
|
return qs ? `${basePath}?${qs}` : basePath;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const previousHref =
|
||||||
|
page > 1 && prevCursor
|
||||||
|
? make({ nextPage: page - 1, nextPageCursor: prevCursor, back: true })
|
||||||
|
: null;
|
||||||
|
const nextHref = nextCursor
|
||||||
|
? make({ nextPage: page + 1, nextPageCursor: nextCursor })
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 flex justify-between text-sm">
|
<div className="mt-4 flex justify-between text-sm">
|
||||||
<span className="text-neutral-500">{cursor ? `cursor: ${cursor.slice(0, 12)}…` : ""}</span>
|
{previousHref ? (
|
||||||
{nextCursor ? (
|
<Link href={previousHref} className="text-blue-300 hover:text-blue-200">
|
||||||
<Link href={make(nextCursor)} className="text-blue-300 hover:text-blue-200">
|
← prev
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-600">← prev</span>
|
||||||
|
)}
|
||||||
|
<span className="text-neutral-500">page {page}</span>
|
||||||
|
{nextHref ? (
|
||||||
|
<Link href={nextHref} className="text-blue-300 hover:text-blue-200">
|
||||||
next →
|
next →
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
93
web/src/components/TopNav.tsx
Normal file
93
web/src/components/TopNav.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { TimeZoneCarousel } from "@/components/TimeZoneControls";
|
||||||
|
import { usePolling } from "@/lib/usePolling";
|
||||||
|
import type { SystemSummaryData } from "@/lib/view-types";
|
||||||
|
|
||||||
|
const ZERO_SUMMARY: SystemSummaryData = {
|
||||||
|
agents_online: 0,
|
||||||
|
agents_offline: 0,
|
||||||
|
checks_ok: 0,
|
||||||
|
checks_warning: 0,
|
||||||
|
checks_critical: 0,
|
||||||
|
checks_unknown: 0,
|
||||||
|
open_incidents: 0,
|
||||||
|
generated_at: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopNav({ initialSummary }: { initialSummary: SystemSummaryData | null }) {
|
||||||
|
const { data } = usePolling(initialSummary ?? ZERO_SUMMARY, "/api/poll/system-summary");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="border-b border-neutral-800 px-4 py-3 flex flex-wrap items-center gap-4">
|
||||||
|
<Brand />
|
||||||
|
<nav className="flex flex-wrap gap-4 text-sm text-neutral-300">
|
||||||
|
<NavLink href="/agents" label="Agents">
|
||||||
|
<span className="text-emerald-400">{data.agents_online}</span>
|
||||||
|
<span className="text-neutral-600">/</span>
|
||||||
|
<span className="text-red-400">{data.agents_offline}</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/checks" label="Checks">
|
||||||
|
<span className="text-emerald-400">{data.checks_ok}</span>
|
||||||
|
<span className="text-neutral-600">/</span>
|
||||||
|
<span className="text-amber-400">{data.checks_warning}</span>
|
||||||
|
<span className="text-neutral-600">/</span>
|
||||||
|
<span className="text-red-400">{data.checks_critical}</span>
|
||||||
|
<span className="text-neutral-600">/</span>
|
||||||
|
<span className="text-neutral-400">{data.checks_unknown}</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink href="/incidents" label="Incidents">
|
||||||
|
<span className="text-red-400">{data.open_incidents}</span>
|
||||||
|
</NavLink>
|
||||||
|
<Link href="/events" className="hover:text-white">
|
||||||
|
Events
|
||||||
|
</Link>
|
||||||
|
<Link href="/outbox" className="hover:text-white">
|
||||||
|
Outbox
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<TimeZoneCarousel />
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Brand() {
|
||||||
|
return (
|
||||||
|
<Link href="/agents" aria-label="Monlet agents" className="group flex shrink-0 items-center gap-2">
|
||||||
|
<span className="relative h-8 w-8 overflow-hidden rounded-md border border-emerald-400/40 bg-neutral-900 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]">
|
||||||
|
<span className="absolute left-2 top-2 h-1.5 w-1.5 rounded-full bg-emerald-300 shadow-[0_0_12px_rgba(110,231,183,0.75)]" />
|
||||||
|
<span className="absolute bottom-2 left-2 h-2 w-0.5 rounded-full bg-cyan-300/80" />
|
||||||
|
<span className="absolute bottom-2 left-3.5 h-4 w-0.5 rounded-full bg-emerald-300" />
|
||||||
|
<span className="absolute bottom-2 left-5 h-2.5 w-0.5 rounded-full bg-sky-300/80" />
|
||||||
|
<span className="absolute right-1.5 top-1.5 h-1 w-1 rounded-full bg-neutral-500 group-hover:bg-emerald-200" />
|
||||||
|
</span>
|
||||||
|
<span className="text-xl font-semibold tracking-wide text-white">
|
||||||
|
mon<span className="text-emerald-300">let</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NavLink({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="inline-flex items-baseline gap-1.5 hover:text-white">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span className="inline-flex items-baseline gap-1 text-xs">
|
||||||
|
<span className="text-neutral-600">(</span>
|
||||||
|
{children}
|
||||||
|
<span className="text-neutral-600">)</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user