Add web auth, infinite-scroll, agent admission and review fixes across agent/server/web

This commit is contained in:
Stanislav Rossovskii
2026-05-28 23:45:12 +04:00
parent 37b1a1d6d6
commit 1026e9ebbe
75 changed files with 3021 additions and 916 deletions

206
CONFIG_REFERENCE.ru.md Normal file
View File

@@ -0,0 +1,206 @@
# Monlet: краткий справочник по настройкам
Monlet состоит из агента на хостах и центрального сервера. Агент сам генерирует локальный ключ в `state_dir/agent.key`; сервер видит новый ключ как `pending`, пока оператор не примет агента в UI.
## Agent TOML
Файл: обычно `/etc/monlet/agent.toml`.
```toml
# Необязательно. Если не задано, используется hostname ОС.
# agent_id = "host-01"
state_dir = "/var/lib/monlet-agent"
[labels]
env = "prod"
role = "api"
[server]
enabled = true
url = "https://monlet.example.com"
heartbeat_interval = "10s"
batch_interval = "10s"
[metrics]
enabled = true
listen = "127.0.0.1:9465"
[[checks]]
id = "disk_root"
name = "Root disk usage"
command = '''
/usr/local/lib/monlet/check_disk_root.sh --warn 80 --crit 90
'''
interval = "60s"
timeout = "10s"
notifications_enabled = true
resource_limits = { cpu_time = "5s", memory = "256MiB", open_files = 128 }
```
Ключи:
- `agent_id` — стабильный ID агента; необязателен, по умолчанию hostname.
- `state_dir` — локальное состояние, ключ агента и spool.
- `[labels]` — метки инвентаря; секреты сюда не класть.
- `server.enabled` — отправлять heartbeat/events на сервер.
- `server.url` — базовый URL сервера.
- `metrics.enabled` — поднять Prometheus `/metrics`.
- `metrics.listen` — адрес `/metrics`.
- `checks[].id` — стабильный ID проверки.
- `checks[].command` — shell-команда строкой; однострочно в `"..."`, многострочно в `'''...'''`.
- `checks[].interval` — период запуска.
- `checks[].timeout` — таймаут; при таймауте результат `critical`.
- `checks[].notifications_enabled` — разрешить серверные уведомления по этой проверке.
- `checks[].resource_limits` — опциональные лимиты процесса: CPU-время, virtual memory, open files.
Exit code: `0=ok`, `1=warning`, `2=critical`, `3+=unknown`.
Env агента:
- `MONLET_AGENT_MAX_CHECKS` — максимум проверок в конфиге (по умолчанию 256). Лимит ограничивает кардинальность метрики `{check_id}`. Положительное целое; превышение лимита — ошибка загрузки конфига.
Reload:
- `systemctl reload monlet-agent` перечитывает локальный TOML через `SIGHUP`.
- Ошибочный config не применяется, старый продолжает работать.
- Уже запущенный check при reload не убивается, а доживает до своего `timeout`.
- Для изменения `agent_id`, `state_dir`, `server.*`, `metrics.*` нужен restart.
## Server env
Файл: `.env` или переменные контейнера.
```env
MONLET_AUTH_TOKEN=replace-with-random-ui-token
MONLET_DATABASE_URL=postgresql+asyncpg://monlet:monlet@postgres:5432/monlet
MONLET_LOG_LEVEL=INFO
MONLET_HOST=0.0.0.0
MONLET_PORT=8000
MONLET_BODY_LIMIT_BYTES=1048576
MONLET_OUTPUT_MAX_BYTES=8192
MONLET_MAX_BATCH_EVENTS=200
MONLET_ENABLE_DETECTOR=true
MONLET_STALE_AFTER_SEC=20
MONLET_DEAD_AFTER_SEC=30
MONLET_DETECTOR_TICK_SEC=5
MONLET_DB_POOL_SIZE=10
MONLET_DB_MAX_OVERFLOW=10
MONLET_DB_POOL_TIMEOUT_SEC=30
MONLET_DB_POOL_RECYCLE_SEC=1800
MONLET_EVENTS_RETENTION_MONTHS=36
MONLET_EVENTS_FUTURE_PARTITIONS=3
MONLET_PARTITION_MAINTENANCE_INTERVAL_SEC=3600
MONLET_OUTBOX_RETENTION_MAX_ROWS=50000
MONLET_EVENT_DEDUP_RETENTION_DAYS=30
MONLET_EVENT_DEDUP_PRUNE_BATCH_SIZE=5000
MONLET_MAX_PENDING_AGENTS=10000
MONLET_PENDING_AGENT_TTL_DAYS=7
MONLET_ENABLE_NOTIFIER_WORKER=true
MONLET_NOTIFIER_TICK_SEC=5
MONLET_NOTIFIER_BATCH_SIZE=20
MONLET_NOTIFIER_CONCURRENCY=4
MONLET_NOTIFIER_MAX_ATTEMPTS=8
MONLET_NOTIFIER_LEASE_SEC=300
MONLET_NOTIFIER_HTTP_TIMEOUT_SEC=10
MONLET_NOTIFIER_INCLUDE_OUTPUT=true
MONLET_NOTIFIER_MAX_OUTPUT_BYTES=1024
MONLET_NOTIFICATION_TIME_ZONE=UTC
MONLET_NOTIFIER_DEBUG_ENABLED=true
MONLET_NOTIFIER_TELEGRAM_ENABLED=false
MONLET_NOTIFIER_WEBHOOK_ENABLED=false
MONLET_NOTIFIER_ALERTMANAGER_ENABLED=false
```
Основное:
- `MONLET_AUTH_TOKEN` — Bearer-токен(ы) для UI/operator API, не для агентов. Список через запятую/пробел для ротации.
- `MONLET_DATABASE_URL` — PostgreSQL DSN (`postgresql+asyncpg://...`).
- `MONLET_LOG_LEVEL` — уровень логирования.
- `MONLET_HOST` / `MONLET_PORT` — адрес и порт HTTP-сервера (по умолчанию `0.0.0.0:8000`).
Лимиты приёма:
- `MONLET_BODY_LIMIT_BYTES` — максимальный размер тела запроса (по умолчанию 1 MiB = 1048576).
- `MONLET_OUTPUT_MAX_BYTES` — лимит хранимого `output` события (по умолчанию 8192).
- `MONLET_MAX_BATCH_EVENTS` — максимум событий в одном batch (по умолчанию 200).
Liveness-детектор:
- `MONLET_ENABLE_DETECTOR` — включить фоновый пересчёт stale/dead (по умолчанию `true`).
- `MONLET_STALE_AFTER_SEC` — через сколько секунд без heartbeat агент становится `stale`.
- `MONLET_DEAD_AFTER_SEC` — через сколько секунд без heartbeat агент становится `dead`.
- `MONLET_DETECTOR_TICK_SEC` — как часто сервер пересчитывает liveness.
Пул БД (тюнинг под нагрузку):
- `MONLET_DB_POOL_SIZE` — постоянных соединений в пуле (по умолчанию 10).
- `MONLET_DB_MAX_OVERFLOW` — дополнительных соединений сверх пула (по умолчанию 10).
- `MONLET_DB_POOL_TIMEOUT_SEC` — ожидание свободного соединения (по умолчанию 30).
- `MONLET_DB_POOL_RECYCLE_SEC` — пересоздание соединения по возрасту (по умолчанию 1800).
Хранение и обслуживание:
- `MONLET_EVENTS_RETENTION_MONTHS` — хранение event-партиций (по умолчанию 36).
- `MONLET_EVENTS_FUTURE_PARTITIONS` — сколько будущих месячных партиций создавать заранее (по умолчанию 3).
- `MONLET_PARTITION_MAINTENANCE_INTERVAL_SEC` — период обслуживания партиций и prune (по умолчанию 3600).
- `MONLET_OUTBOX_RETENTION_MAX_ROWS` — верхняя граница строк в outbox; лишние обрезаются (по умолчанию 50000).
- `MONLET_EVENT_DEDUP_RETENTION_DAYS` — хранение записей идемпотентности `event_id` (по умолчанию 30).
- `MONLET_EVENT_DEDUP_PRUNE_BATCH_SIZE` — размер пакета при очистке dedup-записей (по умолчанию 5000).
- `MONLET_MAX_PENDING_AGENTS` — лимит новых непринятых агентов (по умолчанию 10000).
- `MONLET_PENDING_AGENT_TTL_DAYS` — очистка старых pending-ключей (по умолчанию 7).
Notifier worker:
- `MONLET_ENABLE_NOTIFIER_WORKER` — включить worker доставки уведомлений (по умолчанию `true`).
- `MONLET_NOTIFIER_TICK_SEC` — период опроса outbox (по умолчанию 5).
- `MONLET_NOTIFIER_BATCH_SIZE` — сколько outbox-записей worker забирает за тик (по умолчанию 20).
- `MONLET_NOTIFIER_CONCURRENCY` — параллелизм доставки внутри тика (по умолчанию 4).
- `MONLET_NOTIFIER_MAX_ATTEMPTS` — максимум попыток доставки до перевода в permanent failure (по умолчанию 8).
- `MONLET_NOTIFIER_LEASE_SEC` — аренда записи на время доставки, чтобы её не взял другой worker (по умолчанию 300).
- `MONLET_NOTIFIER_HTTP_TIMEOUT_SEC` — таймаут HTTP-доставки (по умолчанию 10).
- `MONLET_NOTIFIER_INCLUDE_OUTPUT` — включать ли текст `output` в уведомления (по умолчанию `true`).
- `MONLET_NOTIFIER_MAX_OUTPUT_BYTES` — обрезка `output` в уведомлении после редакции (по умолчанию 1024).
- `MONLET_NOTIFICATION_TIME_ZONE` — таймзона человекочитаемых дат в уведомлениях.
Notifier каналы:
- Telegram: `MONLET_NOTIFIER_TELEGRAM_ENABLED=true`, `MONLET_NOTIFIER_TELEGRAM_TOKEN`, `MONLET_NOTIFIER_TELEGRAM_CHAT_ID`.
- Webhook: `MONLET_NOTIFIER_WEBHOOK_ENABLED=true`, `MONLET_NOTIFIER_WEBHOOK_URL`, опционально `MONLET_NOTIFIER_WEBHOOK_TOKEN`.
- Alertmanager: `MONLET_NOTIFIER_ALERTMANAGER_ENABLED=true`, `MONLET_NOTIFIER_ALERTMANAGER_URL`.
- Debug/log: `MONLET_NOTIFIER_DEBUG_ENABLED=true`.
## Web env
Веб-UI читает auth-настройки из env. Без них read-only страницы доступны локально, но admission-мутации возвращают `403`.
```env
MONLET_WEB_AUTH_USERNAME=admin
MONLET_WEB_AUTH_PASSWORD=replace-with-strong-password
MONLET_WEB_SESSION_SECRET=replace-with-random-32-bytes-min
MONLET_WEB_SESSION_TTL_SEC=604800
# или вместо локального логина — доверие reverse-proxy:
# MONLET_WEB_TRUST_PROXY_AUTH=true
```
- `MONLET_WEB_AUTH_USERNAME` / `MONLET_WEB_AUTH_PASSWORD` — учётные данные локального cookie-логина.
- `MONLET_WEB_SESSION_SECRET` — секрет подписи сессии, минимум 32 байта; короче — UI считает auth неправильно настроенным.
- `MONLET_WEB_SESSION_TTL_SEC` — срок жизни сессии (по умолчанию 604800 = 7 дней).
- `MONLET_WEB_TRUST_PROXY_AUTH``true` включает доверие заголовку `X-Forwarded-User` от reverse-proxy. Несовместимо с локальным логином: одновременная настройка обоих режимов — ошибка (UI отвечает `500`).
## Минимальная схема
1. Сервер стартует с PostgreSQL и `MONLET_AUTH_TOKEN`.
2. Агент стартует с TOML, создаёт `state_dir/agent.key` и шлёт heartbeat.
3. Новый агент появляется в UI как `pending`.
4. Оператор принимает агента.
5. После accept сервер начинает учитывать events, checks, incidents и notifications.

View File

@@ -26,6 +26,7 @@ See `config.example.toml`. Required fields: top-level `state_dir`, at least one
| `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`. 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[].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. |
| `checks[].resource_limits` | `{}` | Optional per-check best-effort `ulimit` values: `cpu_time`, `memory`, `open_files`. |
Examples — one-line and multi-line forms: Examples — one-line and multi-line forms:
@@ -44,6 +45,7 @@ set -eu
''' '''
interval = "60s" interval = "60s"
timeout = "10s" timeout = "10s"
resource_limits = { cpu_time = "5s", memory = "256MiB", open_files = 128 }
``` ```
## Command security contract ## Command security contract
@@ -67,9 +69,25 @@ executes it as `/bin/sh -c <command>` on the host where the agent runs.
validation to keep secrets out of heartbeats. validation to keep secrets out of heartbeats.
- Each run uses `exec.CommandContext` with `Setpgid`. On timeout the whole - Each run uses `exec.CommandContext` with `Setpgid`. On timeout the whole
process group is killed and the check result becomes `critical`. process group is killed and the check result becomes `critical`.
- Optional `resource_limits` are applied inside the child shell before the
configured command runs. `cpu_time` uses `ulimit -t` and maps SIGXCPU to
`critical`; `memory` uses virtual memory/address-space `ulimit -v`; `open_files`
uses `ulimit -n`. These limits are best-effort and are not a cgroup/RSS limit.
- Combined stdout+stderr is truncated by UTF-8 byte length to 8 KiB with marker - Combined stdout+stderr is truncated by UTF-8 byte length to 8 KiB with marker
`...[truncated N bytes]` before being persisted, exposed, or notified. `...[truncated N bytes]` before being persisted, exposed, or notified.
## Config reload
Send `SIGHUP` or run `systemctl reload monlet-agent` to reload the local TOML file.
- Reload first validates the full new config. On error, the old config keeps running.
- Hot-reloadable: labels and checks, including `command`, `interval`, `timeout`,
`notifications_enabled`, `dedupe_key`, and `resource_limits`.
- Restart required: `agent_id`, `state_dir`, `server.*`, and `metrics.*`.
- A check already running during reload is allowed to finish under its old config.
Removed or changed checks do not start new old-config runs; changed checks start
with the new config after the in-flight run finishes.
## 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`).
@@ -79,7 +97,9 @@ executes it as `/bin/sh -c <command>` on the host where the agent runs.
- `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). Drops are counted in `monlet_agent_events_dropped_total{reason}` with bounded reasons; one warning is logged per ~30s drop burst, not per event. - 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.
- Event flushes are split before POST so each JSON request body stays within the 1 MiB server limit.
- 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. - 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.
- On shutdown the agent asks workers to stop cleanly; if they do not drain within 30s, the process exits non-zero so the service manager can restart or mark failure.
- Bearer auth header on both endpoints uses the local `state_dir/agent.key` value (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`.
@@ -97,6 +117,11 @@ Exposed on `metrics.listen` at `/metrics`. Labels are static (`check_id`, `statu
- `monlet_agent_check_last_success_timestamp_seconds{check_id}` - `monlet_agent_check_last_success_timestamp_seconds{check_id}`
- `monlet_agent_check_interval_seconds{check_id}` - `monlet_agent_check_interval_seconds{check_id}`
- `monlet_agent_checks_configured` - `monlet_agent_checks_configured`
- `monlet_agent_config_load_success`
- `monlet_agent_config_reload_total{result}`
- `monlet_agent_config_last_reload_success_timestamp_seconds`
- `monlet_agent_resource_limit_apply_failures_total{check_id,resource}`
- `monlet_agent_events_channel_full_total`
- `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`

View File

@@ -8,6 +8,7 @@ import (
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"syscall" "syscall"
"time"
"github.com/monlet/agent/internal/agentkey" "github.com/monlet/agent/internal/agentkey"
"github.com/monlet/agent/internal/app" "github.com/monlet/agent/internal/app"
@@ -20,6 +21,13 @@ import (
var version = "0.1.0" var version = "0.1.0"
const shutdownTimeout = 30 * time.Second
type reloadTarget interface {
RecordReloadFailure()
Reload(*config.Config, string) error
}
func main() { func main() {
cfgPath := flag.String("config", "config.toml", "path to TOML config") cfgPath := flag.String("config", "config.toml", "path to TOML config")
flag.Parse() flag.Parse()
@@ -65,6 +73,12 @@ func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()
startShutdownWatchdog(ctx, shutdownTimeout, log, os.Exit)
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
defer signal.Stop(hup)
go runReloadLoop(ctx, *cfgPath, hup, a, log)
log.Info("monlet-agent starting", "agent_id", agentID, "version", version, "checks", len(cfg.Checks)) log.Info("monlet-agent starting", "agent_id", agentID, "version", version, "checks", len(cfg.Checks))
if err := a.Run(ctx); err != nil { if err := a.Run(ctx); err != nil {
@@ -73,3 +87,45 @@ func main() {
} }
log.Info("monlet-agent stopped") log.Info("monlet-agent stopped")
} }
func startShutdownWatchdog(ctx context.Context, timeout time.Duration, log *slog.Logger, exit func(int)) {
go func() {
<-ctx.Done()
t := time.NewTimer(timeout)
defer t.Stop()
<-t.C
log.Error("shutdown timeout exceeded", "timeout", timeout.String())
exit(1)
}()
}
func runReloadLoop(ctx context.Context, cfgPath string, hup <-chan os.Signal, target reloadTarget, log *slog.Logger) {
for {
select {
case <-ctx.Done():
return
case <-hup:
reloadOnce(cfgPath, target, log)
}
}
}
func reloadOnce(cfgPath string, target reloadTarget, log *slog.Logger) {
nextCfg, err := config.Load(cfgPath)
if err != nil {
target.RecordReloadFailure()
log.Warn("reload config failed", "err", err)
return
}
nextAgentID, err := ids.ResolveAgentID(nextCfg.AgentID, nextCfg.Hostname)
if err != nil {
target.RecordReloadFailure()
log.Warn("reload agent_id failed", "err", err)
return
}
if err := target.Reload(nextCfg, nextAgentID); err != nil {
log.Warn("reload rejected", "err", err)
return
}
log.Info("reload complete", "checks", len(nextCfg.Checks))
}

View File

@@ -0,0 +1,118 @@
package main
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/monlet/agent/internal/config"
)
type reloadCall struct {
cfg *config.Config
agentID string
}
type fakeReloadTarget struct {
reloads chan reloadCall
failures chan struct{}
}
func (f *fakeReloadTarget) RecordReloadFailure() {
f.failures <- struct{}{}
}
func (f *fakeReloadTarget) Reload(cfg *config.Config, agentID string) error {
f.reloads <- reloadCall{cfg: cfg, agentID: agentID}
return nil
}
func TestRunReloadLoopHandlesSIGHUP(t *testing.T) {
cfgPath := writeTestConfig(t)
target := &fakeReloadTarget{
reloads: make(chan reloadCall, 1),
failures: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hup := make(chan os.Signal, 1)
go runReloadLoop(ctx, cfgPath, hup, target, discardLogger())
hup <- syscall.SIGHUP
select {
case call := <-target.reloads:
if call.agentID != "agent-test" || len(call.cfg.Checks) != 1 {
t.Fatalf("unexpected reload call: agent_id=%q cfg=%+v", call.agentID, call.cfg)
}
case <-time.After(time.Second):
t.Fatal("reload was not called")
}
}
func TestRunReloadLoopRecordsLoadFailure(t *testing.T) {
target := &fakeReloadTarget{
reloads: make(chan reloadCall, 1),
failures: make(chan struct{}, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
hup := make(chan os.Signal, 1)
go runReloadLoop(ctx, filepath.Join(t.TempDir(), "missing.toml"), hup, target, discardLogger())
hup <- syscall.SIGHUP
select {
case <-target.failures:
case <-time.After(time.Second):
t.Fatal("reload failure was not recorded")
}
}
func TestShutdownWatchdogExitsAfterTimeout(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
exits := make(chan int, 1)
startShutdownWatchdog(ctx, time.Millisecond, discardLogger(), func(code int) {
exits <- code
})
cancel()
select {
case code := <-exits:
if code != 1 {
t.Fatalf("exit code: %d", code)
}
case <-time.After(time.Second):
t.Fatal("watchdog did not exit")
}
}
func writeTestConfig(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "agent.toml")
body := fmt.Sprintf(`
agent_id = "agent-test"
state_dir = %q
[server]
enabled = true
url = "http://localhost"
[[checks]]
id = "c1"
command = "true"
interval = "10s"
timeout = "5s"
`, t.TempDir())
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return path
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}

View File

@@ -24,6 +24,7 @@ command = '''
''' '''
interval = "60s" interval = "60s"
timeout = "10s" timeout = "10s"
resource_limits = { cpu_time = "5s", memory = "256MiB", open_files = 128 }
[[checks]] [[checks]]
id = "uptime" id = "uptime"

View File

@@ -3,6 +3,7 @@ package app
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log/slog" "log/slog"
"sync" "sync"
"time" "time"
@@ -24,6 +25,9 @@ type App struct {
Log *slog.Logger Log *slog.Logger
lastDropLog time.Time lastDropLog time.Time
cfgMu sync.RWMutex
schedulerMu sync.RWMutex
scheduler *scheduler.Manager
} }
const ( const (
@@ -36,31 +40,30 @@ const (
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)
cfg := a.config()
// Account drops accumulated during spool Open (e.g. shrunk limits on restart). // Account drops accumulated during spool Open (e.g. shrunk limits on restart).
a.recordSpoolDrops(a.Spool.Drops()) a.recordSpoolDrops(a.Spool.Drops())
a.Metrics.ChecksConfigured.Set(float64(len(a.Cfg.Checks))) a.Metrics.ConfigLoadSuccess.Set(1)
a.Metrics.BuildInfo.WithLabelValues(a.Version, boolLabel(a.Cfg.PushesToServer()), boolLabel(a.Cfg.ExposesMetrics())).Set(1) a.Metrics.ChecksConfigured.Set(float64(len(cfg.Checks)))
for _, c := range a.Cfg.Checks { a.Metrics.BuildInfo.WithLabelValues(a.Version, boolLabel(cfg.PushesToServer()), boolLabel(cfg.ExposesMetrics())).Set(1)
for _, c := range cfg.Checks {
a.Metrics.CheckInterval.WithLabelValues(c.ID).Set(c.Interval.Duration.Seconds()) a.Metrics.CheckInterval.WithLabelValues(c.ID).Set(c.Interval.Duration.Seconds())
} }
var wg sync.WaitGroup var wg sync.WaitGroup
if a.Cfg.ExposesMetrics() { if cfg.ExposesMetrics() {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
if err := a.Metrics.Serve(ctx, a.Cfg.Metrics.Listen); err != nil { if err := a.Metrics.Serve(ctx, cfg.Metrics.Listen); err != nil {
a.Log.Error("metrics server", "err", err) a.Log.Error("metrics server", "err", err)
} }
}() }()
} }
wg.Add(1) mgr := scheduler.NewManager(ctx, a.AgentID, events, scheduler.Hooks{
go func() {
defer wg.Done()
scheduler.Run(ctx, a.AgentID, a.Cfg.Checks, events, scheduler.Hooks{
OnSkipped: func(id string) { a.Metrics.CheckSkipped.WithLabelValues(id).Inc() }, OnSkipped: func(id string) { a.Metrics.CheckSkipped.WithLabelValues(id).Inc() },
OnResult: func(ev scheduler.Event) { OnResult: func(ev scheduler.Event) {
a.Metrics.CheckRuns.WithLabelValues(ev.CheckID, ev.Status).Inc() a.Metrics.CheckRuns.WithLabelValues(ev.CheckID, ev.Status).Inc()
@@ -73,10 +76,25 @@ func (a *App) Run(ctx context.Context) error {
a.Metrics.CheckLastSuccess.WithLabelValues(ev.CheckID).Set(ts) a.Metrics.CheckLastSuccess.WithLabelValues(ev.CheckID).Set(ts)
} }
}, },
OnEventChannelFull: func() { a.Metrics.EventsChannelFull.Inc() },
OnResourceLimitFailure: func(id string, resource string) {
a.Metrics.ResourceLimitApplyFailures.WithLabelValues(id, resource).Inc()
},
OnStopped: func(id string) { a.Metrics.DeleteCheck(id) },
}) })
mgr.Update(cfg.Checks)
a.schedulerMu.Lock()
a.scheduler = mgr
a.schedulerMu.Unlock()
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
mgr.Stop()
mgr.Wait()
}() }()
if a.Cfg.PushesToServer() { if cfg.PushesToServer() {
wg.Add(1) wg.Add(1)
go func() { go func() {
defer wg.Done() defer wg.Done()
@@ -128,18 +146,19 @@ func (a *App) spoolWriter(ctx context.Context, events <-chan scheduler.Event) {
} }
func (a *App) heartbeatLoop(ctx context.Context) { func (a *App) heartbeatLoop(ctx context.Context) {
interval := a.Cfg.Server.HeartbeatInterval.Duration
attempt := 0 attempt := 0
for { for {
cfg := a.config()
interval := cfg.Server.HeartbeatInterval.Duration
hb := client.HeartbeatRequest{ hb := client.HeartbeatRequest{
AgentID: a.AgentID, AgentID: a.AgentID,
ObservedAt: time.Now().UTC(), ObservedAt: time.Now().UTC(),
Hostname: a.Cfg.Hostname, Hostname: cfg.Hostname,
Features: client.AgentFeatures{ Features: client.AgentFeatures{
Push: a.Cfg.PushesToServer(), Push: cfg.PushesToServer(),
Metrics: a.Cfg.ExposesMetrics(), Metrics: cfg.ExposesMetrics(),
}, },
Labels: a.Cfg.HeartbeatLabels(a.Version), Labels: cfg.HeartbeatLabels(a.Version),
} }
a.Metrics.SendAttempts.WithLabelValues("heartbeat").Inc() a.Metrics.SendAttempts.WithLabelValues("heartbeat").Inc()
err := a.Client.SendHeartbeat(ctx, hb) err := a.Client.SendHeartbeat(ctx, hb)
@@ -167,9 +186,14 @@ func (a *App) heartbeatLoop(ctx context.Context) {
} }
func (a *App) sendLoop(ctx context.Context) { func (a *App) sendLoop(ctx context.Context) {
interval := a.Cfg.Server.BatchInterval.Duration
attempt := 0 attempt := 0
for { for {
select {
case <-ctx.Done():
return
default:
}
interval := a.config().Server.BatchInterval.Duration
items, err := a.Spool.Peek(client.MaxBatchEvents) items, err := a.Spool.Peek(client.MaxBatchEvents)
if err != nil { if err != nil {
a.Log.Error("spool peek", "err", err) a.Log.Error("spool peek", "err", err)
@@ -217,6 +241,74 @@ func (a *App) sendLoop(ctx context.Context) {
} }
} }
// Reload applies a validated hot-reload config if immutable fields match.
func (a *App) Reload(newCfg *config.Config, newAgentID string) error {
oldCfg := a.config()
if err := validateReload(oldCfg, newCfg, a.AgentID, newAgentID); err != nil {
a.RecordReloadFailure()
return err
}
a.cfgMu.Lock()
a.Cfg = newCfg
a.cfgMu.Unlock()
a.Metrics.ConfigLoadSuccess.Set(1)
a.Metrics.ConfigReloadTotal.WithLabelValues("success").Inc()
a.Metrics.ConfigLastReloadSuccess.Set(float64(time.Now().Unix()))
a.Metrics.ChecksConfigured.Set(float64(len(newCfg.Checks)))
for _, c := range newCfg.Checks {
a.Metrics.CheckInterval.WithLabelValues(c.ID).Set(c.Interval.Duration.Seconds())
}
a.schedulerMu.RLock()
mgr := a.scheduler
a.schedulerMu.RUnlock()
if mgr != nil {
mgr.Update(newCfg.Checks)
}
return nil
}
// RecordReloadFailure updates config reload metrics after a failed reload attempt.
func (a *App) RecordReloadFailure() {
a.Metrics.ConfigLoadSuccess.Set(0)
a.Metrics.ConfigReloadTotal.WithLabelValues("failure").Inc()
}
func (a *App) config() *config.Config {
a.cfgMu.RLock()
defer a.cfgMu.RUnlock()
return a.Cfg
}
func validateReload(oldCfg, newCfg *config.Config, oldAgentID, newAgentID string) error {
if oldAgentID != newAgentID {
return fmt.Errorf("agent_id change requires restart")
}
if oldCfg.StateDir != newCfg.StateDir {
return fmt.Errorf("state_dir change requires restart")
}
if oldCfg.PushesToServer() != newCfg.PushesToServer() {
return fmt.Errorf("server.enabled change requires restart")
}
if oldCfg.Server.URL != newCfg.Server.URL {
return fmt.Errorf("server.url change requires restart")
}
if oldCfg.Server.HeartbeatInterval.Duration != newCfg.Server.HeartbeatInterval.Duration {
return fmt.Errorf("server.heartbeat_interval change requires restart")
}
if oldCfg.Server.BatchInterval.Duration != newCfg.Server.BatchInterval.Duration {
return fmt.Errorf("server.batch_interval change requires restart")
}
if oldCfg.ExposesMetrics() != newCfg.ExposesMetrics() {
return fmt.Errorf("metrics.enabled change requires restart")
}
if oldCfg.Metrics.Listen != newCfg.Metrics.Listen {
return fmt.Errorf("metrics.listen change requires restart")
}
return nil
}
// sleep returns true if context was cancelled while waiting. // sleep returns true if context was cancelled while waiting.
func sleep(ctx context.Context, d time.Duration) bool { func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d) t := time.NewTimer(d)

View File

@@ -182,6 +182,81 @@ func TestEndToEndPushAndReplay(t *testing.T) {
} }
} }
func TestReloadRejectsImmutableChangesAndKeepsOldConfig(t *testing.T) {
oldCfg := &config.Config{
AgentID: "a1",
Hostname: "h",
StateDir: "/tmp/old",
Server: config.ServerConfig{
Enabled: boolPtr(true),
URL: "http://old",
HeartbeatInterval: config.Duration{Duration: 10 * time.Second},
BatchInterval: config.Duration{Duration: 10 * time.Second},
},
Metrics: config.MetricsConfig{Enabled: false},
Checks: []config.CheckConfig{{
ID: "c1",
Command: "true",
Interval: config.Duration{Duration: time.Second},
Timeout: config.Duration{Duration: time.Second},
}},
}
newCfg := *oldCfg
newCfg.Server.URL = "http://new"
a := &App{
Cfg: oldCfg,
AgentID: "a1",
Metrics: metrics.New(),
}
if err := a.Reload(&newCfg, "a1"); err == nil {
t.Fatal("expected immutable reload rejection")
}
if a.config().Server.URL != "http://old" {
t.Fatalf("old config was replaced: %+v", a.config().Server)
}
}
func TestReloadAcceptsLabelsAndChecks(t *testing.T) {
oldCfg := &config.Config{
AgentID: "a1",
Hostname: "h",
StateDir: "/tmp/old",
Labels: map[string]string{"env": "old"},
Server: config.ServerConfig{
Enabled: boolPtr(true),
URL: "http://server",
HeartbeatInterval: config.Duration{Duration: 10 * time.Second},
BatchInterval: config.Duration{Duration: 10 * time.Second},
},
Metrics: config.MetricsConfig{Enabled: false},
Checks: []config.CheckConfig{{
ID: "c1",
Command: "true",
Interval: config.Duration{Duration: time.Second},
Timeout: config.Duration{Duration: time.Second},
}},
}
newCfg := *oldCfg
newCfg.Labels = map[string]string{"env": "new"}
newCfg.Checks = []config.CheckConfig{{
ID: "c2",
Command: "true",
Interval: config.Duration{Duration: 2 * time.Second},
Timeout: config.Duration{Duration: time.Second},
}}
a := &App{
Cfg: oldCfg,
AgentID: "a1",
Metrics: metrics.New(),
}
if err := a.Reload(&newCfg, "a1"); err != nil {
t.Fatal(err)
}
if a.config().Labels["env"] != "new" || len(a.config().Checks) != 1 || a.config().Checks[0].ID != "c2" {
t.Fatalf("reload did not apply: %+v", a.config())
}
}
func boolPtr(v bool) *bool { func boolPtr(v bool) *bool {
return &v return &v
} }

View File

@@ -4,6 +4,7 @@ import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -15,6 +16,7 @@ import (
const ( const (
MaxBatchEvents = 200 MaxBatchEvents = 200
MaxBatchBytes = 1 << 20
) )
type Client struct { type Client struct {
@@ -68,18 +70,81 @@ func (c *Client) SendEvents(ctx context.Context, events []scheduler.Event) (*Eve
return &EventBatchResponse{}, nil return &EventBatchResponse{}, nil
} }
if len(events) > MaxBatchEvents { if len(events) > MaxBatchEvents {
return nil, fmt.Errorf("batch exceeds %d events", MaxBatchEvents) return nil, nonRetryablef("batch exceeds %d events", MaxBatchEvents)
} }
wireEvents := make([]scheduler.Event, len(events)) wireEvents := make([]scheduler.Event, len(events))
for i, ev := range events { for i, ev := range events {
wireEvents[i] = ev.WireEvent() wireEvents[i] = ev.WireEvent()
} }
req := EventBatchRequest{AgentID: c.agentID, Events: wireEvents} req := EventBatchRequest{AgentID: c.agentID, Events: wireEvents}
var resp EventBatchResponse batches, err := c.splitEventBatches(req.Events)
if err := c.post(ctx, "/api/v1/events", req, &resp); err != nil { if err != nil {
return nil, err return nil, err
} }
return &resp, nil var total EventBatchResponse
for _, batch := range batches {
var resp EventBatchResponse
if err := c.post(ctx, "/api/v1/events", EventBatchRequest{AgentID: c.agentID, Events: batch}, &resp); err != nil {
return nil, err
}
total.Accepted += resp.Accepted
total.Deduplicated += resp.Deduplicated
}
return &total, nil
}
func (c *Client) splitEventBatches(events []scheduler.Event) ([][]scheduler.Event, error) {
overhead, err := c.eventBatchOverhead()
if err != nil {
return nil, err
}
batches := make([][]scheduler.Event, 0, 1)
current := make([]scheduler.Event, 0, len(events))
currentSize := overhead
for _, ev := range events {
eventSize, err := jsonSize(ev)
if err != nil {
return nil, err
}
nextSize := currentSize + eventSize
if len(current) > 0 {
nextSize++
}
if nextSize <= MaxBatchBytes {
current = append(current, ev)
currentSize = nextSize
continue
}
if len(current) == 0 {
return nil, nonRetryablef("single event batch exceeds %d bytes", MaxBatchBytes)
}
batches = append(batches, current)
current = []scheduler.Event{ev}
currentSize = overhead + eventSize
if currentSize > MaxBatchBytes {
return nil, nonRetryablef("single event batch exceeds %d bytes", MaxBatchBytes)
}
}
if len(current) > 0 {
batches = append(batches, current)
}
return batches, nil
}
func (c *Client) eventBatchOverhead() (int, error) {
size, err := jsonSize(EventBatchRequest{AgentID: c.agentID, Events: []scheduler.Event{}})
if err != nil {
return 0, err
}
return size, nil
}
func jsonSize(v any) (int, error) {
b, err := json.Marshal(v)
if err != nil {
return 0, err
}
return len(b), nil
} }
func (c *Client) post(ctx context.Context, path string, body any, out any) error { func (c *Client) post(ctx context.Context, path string, body any, out any) error {
@@ -115,30 +180,28 @@ type HTTPError struct {
func (e *HTTPError) Error() string { return fmt.Sprintf("http %d: %s", e.Status, e.Body) } func (e *HTTPError) Error() string { return fmt.Sprintf("http %d: %s", e.Status, e.Body) }
type NonRetryableError struct{ Err error }
func (e *NonRetryableError) Error() string { return e.Err.Error() }
func (e *NonRetryableError) Unwrap() error { return e.Err }
func nonRetryablef(format string, args ...any) error {
return &NonRetryableError{Err: fmt.Errorf(format, args...)}
}
// Retryable reports whether the error should trigger a retry (network errors // Retryable reports whether the error should trigger a retry (network errors
// and 5xx are retryable; 4xx are dropped to avoid hot-loop on bad payload). // and 5xx are retryable; 4xx are dropped to avoid hot-loop on bad payload).
func Retryable(err error) bool { func Retryable(err error) bool {
if err == nil { if err == nil {
return false return false
} }
var ne *NonRetryableError
if errors.As(err, &ne) {
return false
}
var he *HTTPError var he *HTTPError
if asErr(err, &he) { if errors.As(err, &he) {
return he.Status >= 500 return he.Status >= 500
} }
return true return true
} }
func asErr(err error, target **HTTPError) bool {
for e := err; e != nil; {
if h, ok := e.(*HTTPError); ok {
*target = h
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return false
}
e = u.Unwrap()
}
return false
}

View File

@@ -6,6 +6,7 @@ import (
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time" "time"
@@ -57,6 +58,59 @@ func TestSendEventsBatch(t *testing.T) {
} }
} }
func TestSendEventsSplitsByJSONSize(t *testing.T) {
var (
requests int
total int
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Error(err)
http.Error(w, "read body", http.StatusInternalServerError)
return
}
if len(body) > MaxBatchBytes {
t.Errorf("body size %d exceeds %d", len(body), MaxBatchBytes)
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
return
}
var got EventBatchRequest
if err := json.Unmarshal(body, &got); err != nil {
t.Error(err)
http.Error(w, "bad json", http.StatusBadRequest)
return
}
requests++
total += len(got.Events)
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(EventBatchResponse{Accepted: len(got.Events)})
}))
defer srv.Close()
events := make([]scheduler.Event, MaxBatchEvents)
for i := range events {
events[i] = scheduler.Event{
EventID: "event-" + time.Unix(int64(i), 0).UTC().Format("20060102150405"),
CheckID: "check",
ObservedAt: time.Unix(int64(i), 0).UTC(),
Status: "ok",
Output: strings.Repeat("x", 8192),
}
}
c := New(srv.URL, "tok", "a1")
resp, err := c.SendEvents(context.Background(), events)
if err != nil {
t.Fatal(err)
}
if requests < 2 {
t.Fatalf("expected split batch, got %d request", requests)
}
if total != MaxBatchEvents || resp.Accepted != MaxBatchEvents {
t.Fatalf("total=%d resp=%+v", total, resp)
}
}
func TestSendEventsTooBig(t *testing.T) { func TestSendEventsTooBig(t *testing.T) {
c := New("http://x", "t", "a1") c := New("http://x", "t", "a1")
big := make([]scheduler.Event, MaxBatchEvents+1) big := make([]scheduler.Event, MaxBatchEvents+1)
@@ -64,6 +118,24 @@ func TestSendEventsTooBig(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("expected error") t.Fatal("expected error")
} }
if Retryable(err) {
t.Fatal("oversized batch should not retry")
}
}
func TestSendEventsSingleEventTooLargeIsNotRetryable(t *testing.T) {
c := New("http://x", "t", "a1")
_, err := c.SendEvents(context.Background(), []scheduler.Event{{
EventID: "x",
CheckID: strings.Repeat("c", MaxBatchBytes),
Status: "ok",
}})
if err == nil {
t.Fatal("expected error")
}
if Retryable(err) {
t.Fatal("oversized single event should not retry")
}
} }
func TestRetryableClassification(t *testing.T) { func TestRetryableClassification(t *testing.T) {

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"fmt" "fmt"
"math"
"os" "os"
"regexp" "regexp"
"strconv" "strconv"
@@ -60,10 +61,21 @@ type CheckConfig struct {
Timeout Duration `toml:"timeout"` Timeout Duration `toml:"timeout"`
NotificationsEnabled *bool `toml:"notifications_enabled"` NotificationsEnabled *bool `toml:"notifications_enabled"`
DedupeKey string `toml:"dedupe_key"` DedupeKey string `toml:"dedupe_key"`
ResourceLimits ResourceLimits `toml:"resource_limits"`
} }
type Duration struct{ time.Duration } type Duration struct{ time.Duration }
// ByteSize stores a parsed TOML byte size.
type ByteSize struct{ Bytes int64 }
// ResourceLimits defines optional per-check OS resource limits.
type ResourceLimits struct {
CPUTime Duration `toml:"cpu_time"`
Memory ByteSize `toml:"memory"`
OpenFiles int `toml:"open_files"`
}
func (d *Duration) UnmarshalText(b []byte) error { func (d *Duration) UnmarshalText(b []byte) error {
v, err := time.ParseDuration(string(b)) v, err := time.ParseDuration(string(b))
if err != nil { if err != nil {
@@ -73,6 +85,43 @@ func (d *Duration) UnmarshalText(b []byte) error {
return nil return nil
} }
func (s *ByteSize) UnmarshalText(b []byte) error {
raw := strings.TrimSpace(string(b))
if raw == "" {
return fmt.Errorf("empty byte size")
}
valueEnd := 0
for valueEnd < len(raw) && ((raw[valueEnd] >= '0' && raw[valueEnd] <= '9') || raw[valueEnd] == '.') {
valueEnd++
}
if valueEnd == 0 {
return fmt.Errorf("invalid byte size %q", raw)
}
value, err := strconv.ParseFloat(raw[:valueEnd], 64)
if err != nil || value <= 0 {
return fmt.Errorf("invalid byte size %q", raw)
}
unit := strings.ToLower(strings.TrimSpace(raw[valueEnd:]))
mul, ok := map[string]float64{
"": 1, "b": 1,
"k": 1000, "kb": 1000,
"m": 1000 * 1000, "mb": 1000 * 1000,
"g": 1000 * 1000 * 1000, "gb": 1000 * 1000 * 1000,
"kib": 1024,
"mib": 1024 * 1024,
"gib": 1024 * 1024 * 1024,
}[unit]
if !ok {
return fmt.Errorf("unknown byte size unit %q", unit)
}
bytes := value * mul
if bytes > math.MaxInt64 {
return fmt.Errorf("byte size %q is too large", raw)
}
s.Bytes = int64(bytes)
return nil
}
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
var c Config var c Config
md, err := toml.DecodeFile(path, &c) md, err := toml.DecodeFile(path, &c)
@@ -174,6 +223,9 @@ func (c *Config) Validate() error {
if len(ch.DedupeKey) > 256 { if len(ch.DedupeKey) > 256 {
return fmt.Errorf("check %q: dedupe_key too long", ch.ID) return fmt.Errorf("check %q: dedupe_key too long", ch.ID)
} }
if err := validateResourceLimits(ch.ID, ch.ResourceLimits); err != nil {
return err
}
} }
return nil return nil
} }
@@ -201,8 +253,23 @@ func (c CheckConfig) NotificationsOn() bool {
return c.NotificationsEnabled == nil || *c.NotificationsEnabled return c.NotificationsEnabled == nil || *c.NotificationsEnabled
} }
func (c CheckConfig) Argv() []string { func validateResourceLimits(checkID string, limits ResourceLimits) error {
return []string{"/bin/sh", "-c", c.Command} if limits.CPUTime.Duration < 0 {
return fmt.Errorf("check %q: resource_limits.cpu_time must be > 0", checkID)
}
if limits.Memory.Bytes < 0 {
return fmt.Errorf("check %q: resource_limits.memory must be > 0", checkID)
}
if limits.Memory.Bytes > 0 && limits.Memory.Bytes < 1024*1024 {
return fmt.Errorf("check %q: resource_limits.memory must be >= 1MiB", checkID)
}
if limits.OpenFiles < 0 {
return fmt.Errorf("check %q: resource_limits.open_files must be > 0", checkID)
}
if limits.OpenFiles > 0 && limits.OpenFiles < 16 {
return fmt.Errorf("check %q: resource_limits.open_files must be >= 16", checkID)
}
return nil
} }
func ValidateID(field, v string) error { func ValidateID(field, v string) error {

View File

@@ -48,6 +48,59 @@ func TestLoadMinimal(t *testing.T) {
} }
} }
func TestLoadResourceLimits(t *testing.T) {
c, err := Load(writeTmp(t, `
agent_id = "host-1"
state_dir = "/tmp/x"
[server]
enabled = true
url = "http://localhost"
[[checks]]
id = "c1"
command = "true"
interval = "10s"
timeout = "5s"
resource_limits = { cpu_time = "2s", memory = "256MiB", open_files = 128 }
`))
if err != nil {
t.Fatal(err)
}
limits := c.Checks[0].ResourceLimits
if limits.CPUTime.Duration != 2_000_000_000 || limits.Memory.Bytes != 256*1024*1024 || limits.OpenFiles != 128 {
t.Fatalf("unexpected limits: %+v", limits)
}
}
func TestValidateBadResourceLimits(t *testing.T) {
for name, line := range map[string]string{
"memory_too_small": `resource_limits = { memory = "512KiB" }`,
"open_files_too_small": `resource_limits = { open_files = 8 }`,
} {
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 = "true"
interval = "10s"
timeout = "5s"
`+line+`
`))
if err == nil {
t.Fatal("expected resource limit error")
}
})
}
}
func TestLoadMultilineCommand(t *testing.T) { func TestLoadMultilineCommand(t *testing.T) {
c, err := Load(writeTmp(t, ` c, err := Load(writeTmp(t, `
agent_id = "host-1" agent_id = "host-1"
@@ -72,9 +125,6 @@ timeout = "5s"
if !strings.Contains(c.Checks[0].Command, "echo ok") { if !strings.Contains(c.Checks[0].Command, "echo ok") {
t.Fatalf("command not loaded: %q", c.Checks[0].Command) t.Fatalf("command not loaded: %q", c.Checks[0].Command)
} }
if got := c.Checks[0].Argv(); len(got) != 3 || got[0] != "/bin/sh" || got[1] != "-c" {
t.Fatalf("argv: %#v", got)
}
} }
func TestCommandArrayRejected(t *testing.T) { func TestCommandArrayRejected(t *testing.T) {

View File

@@ -22,6 +22,11 @@ type Metrics struct {
CheckLastSuccess *prometheus.GaugeVec CheckLastSuccess *prometheus.GaugeVec
CheckInterval *prometheus.GaugeVec CheckInterval *prometheus.GaugeVec
ChecksConfigured prometheus.Gauge ChecksConfigured prometheus.Gauge
ConfigLoadSuccess prometheus.Gauge
ConfigReloadTotal *prometheus.CounterVec
ConfigLastReloadSuccess prometheus.Gauge
ResourceLimitApplyFailures *prometheus.CounterVec
EventsChannelFull prometheus.Counter
SpoolDepth prometheus.Gauge SpoolDepth prometheus.Gauge
SpoolBytes prometheus.Gauge SpoolBytes prometheus.Gauge
SendAttempts *prometheus.CounterVec SendAttempts *prometheus.CounterVec
@@ -115,6 +120,26 @@ func New() *Metrics {
Name: "monlet_agent_checks_configured", Name: "monlet_agent_checks_configured",
Help: "Number of checks configured.", Help: "Number of checks configured.",
}) })
m.ConfigLoadSuccess = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "monlet_agent_config_load_success",
Help: "Whether the last agent config load or reload succeeded.",
})
m.ConfigReloadTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "monlet_agent_config_reload_total",
Help: "Number of config reload attempts by result.",
}, []string{"result"})
m.ConfigLastReloadSuccess = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "monlet_agent_config_last_reload_success_timestamp_seconds",
Help: "Unix timestamp of the last successful config reload.",
})
m.ResourceLimitApplyFailures = prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "monlet_agent_resource_limit_apply_failures_total",
Help: "Number of failed resource limit applications by check and resource.",
}, []string{"check_id", "resource"})
m.EventsChannelFull = prometheus.NewCounter(prometheus.CounterOpts{
Name: "monlet_agent_events_channel_full_total",
Help: "Number of times scheduler event delivery would block because the agent event channel was full.",
})
m.LastHeartbeat = prometheus.NewGauge(prometheus.GaugeOpts{ m.LastHeartbeat = prometheus.NewGauge(prometheus.GaugeOpts{
Name: "monlet_agent_last_heartbeat_timestamp_seconds", Name: "monlet_agent_last_heartbeat_timestamp_seconds",
Help: "Unix timestamp of the last successful heartbeat ack.", Help: "Unix timestamp of the last successful heartbeat ack.",
@@ -126,6 +151,9 @@ func New() *Metrics {
reg.MustRegister(m.CheckRuns, m.CheckSkipped, m.CheckDuration, reg.MustRegister(m.CheckRuns, m.CheckSkipped, m.CheckDuration,
m.CheckStatus, m.CheckExitCode, m.CheckLastRun, m.CheckLastSuccess, m.CheckStatus, m.CheckExitCode, m.CheckLastRun, m.CheckLastSuccess,
m.CheckInterval, m.ChecksConfigured, m.CheckInterval, m.ChecksConfigured,
m.ConfigLoadSuccess, m.ConfigReloadTotal, m.ConfigLastReloadSuccess,
m.ResourceLimitApplyFailures,
m.EventsChannelFull,
m.SpoolDepth, m.SpoolBytes, m.SpoolDepth, m.SpoolBytes,
m.SendAttempts, m.SendFailures, m.SendAttempts, m.SendFailures,
m.EventsAccepted, m.EventsDedup, m.EventsDropped, m.EventsAccepted, m.EventsDedup, m.EventsDropped,
@@ -133,6 +161,19 @@ func New() *Metrics {
return m return m
} }
// DeleteCheck removes metric series for a check that no longer exists.
func (m *Metrics) DeleteCheck(checkID string) {
m.CheckRuns.DeletePartialMatch(prometheus.Labels{"check_id": checkID})
m.CheckSkipped.DeleteLabelValues(checkID)
m.CheckDuration.DeleteLabelValues(checkID)
m.CheckStatus.DeleteLabelValues(checkID)
m.CheckExitCode.DeleteLabelValues(checkID)
m.CheckLastRun.DeleteLabelValues(checkID)
m.CheckLastSuccess.DeleteLabelValues(checkID)
m.CheckInterval.DeleteLabelValues(checkID)
m.ResourceLimitApplyFailures.DeletePartialMatch(prometheus.Labels{"check_id": checkID})
}
// Serve blocks until ctx is done; the listener is closed on shutdown. // Serve blocks until ctx is done; the listener is closed on shutdown.
func (m *Metrics) Serve(ctx context.Context, addr string) error { func (m *Metrics) Serve(ctx context.Context, addr string) error {
mux := http.NewServeMux() mux := http.NewServeMux()

View File

@@ -14,6 +14,7 @@ func TestMetricsExposeRegistered(t *testing.T) {
m := New() m := New()
m.CheckRuns.WithLabelValues("c1", "ok").Inc() m.CheckRuns.WithLabelValues("c1", "ok").Inc()
m.SpoolDepth.Set(7) m.SpoolDepth.Set(7)
m.EventsChannelFull.Inc()
srv := httptest.NewServer(promhttp.HandlerFor(m.Registry, promhttp.HandlerOpts{})) srv := httptest.NewServer(promhttp.HandlerFor(m.Registry, promhttp.HandlerOpts{}))
defer srv.Close() defer srv.Close()
resp, err := http.Get(srv.URL) resp, err := http.Get(srv.URL)
@@ -26,6 +27,7 @@ func TestMetricsExposeRegistered(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
`monlet_agent_check_runs_total{check_id="c1",status="ok"} 1`, `monlet_agent_check_runs_total{check_id="c1",status="ok"} 1`,
`monlet_agent_spool_events 7`, `monlet_agent_spool_events 7`,
`monlet_agent_events_channel_full_total 1`,
} { } {
if !strings.Contains(s, want) { if !strings.Contains(s, want) {
t.Errorf("missing %q in output", want) t.Errorf("missing %q in output", want)

View File

@@ -6,6 +6,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"os/exec" "os/exec"
"strconv"
"strings"
"syscall" "syscall"
"time" "time"
"unicode/utf8" "unicode/utf8"
@@ -16,6 +18,7 @@ var replacementRune = []byte("<22>")
const ( const (
MaxOutputBytes = 8192 MaxOutputBytes = 8192
truncMarkerFmt = "...[truncated %d bytes]" truncMarkerFmt = "...[truncated %d bytes]"
limitFailPrefix = "__MONLET_RESOURCE_LIMIT_FAILED__:"
) )
type Status string type Status string
@@ -33,11 +36,36 @@ type Result struct {
DurationMs int64 DurationMs int64
Output string Output string
OutputTruncated bool OutputTruncated bool
ResourceLimitFailure string
}
// ResourceLimits contains best-effort shell ulimit values for a check command.
type ResourceLimits struct {
CPUTime time.Duration
MemoryBytes int64
OpenFiles int
}
func (l ResourceLimits) IsZero() bool {
return l.CPUTime == 0 && l.MemoryBytes == 0 && l.OpenFiles == 0
} }
// Run executes argv with timeout. Stdout+stderr are captured into a single // Run executes argv with timeout. Stdout+stderr are captured into a single
// buffer; both are truncated to 8 KiB UTF-8 bytes if exceeded. // buffer; both are truncated to 8 KiB UTF-8 bytes if exceeded.
func Run(ctx context.Context, argv []string, timeout time.Duration) Result { func Run(ctx context.Context, argv []string, timeout time.Duration) Result {
return runArgv(ctx, argv, timeout)
}
// RunCommand executes a shell command with optional resource limits.
func RunCommand(ctx context.Context, command string, timeout time.Duration, limits ResourceLimits) Result {
argv := []string{"/bin/sh", "-c", command}
if !limits.IsZero() {
argv = limitedShellArgv(command, limits)
}
return runArgv(ctx, argv, timeout)
}
func runArgv(ctx context.Context, argv []string, timeout time.Duration) Result {
start := time.Now() start := time.Now()
cctx, cancel := context.WithTimeout(ctx, timeout) cctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
@@ -67,6 +95,23 @@ func Run(ctx context.Context, argv []string, timeout time.Duration) Result {
r.Output = fmt.Sprintf("critical: check timed out after %s", timeout) r.Output = fmt.Sprintf("critical: check timed out after %s", timeout)
return r return r
} }
if isExitCode(err, 125) {
if resource, ok := detectLimitFailure(out); ok {
r.Status = StatusUnknown
r.ExitCode = -1
r.Output = "unknown: failed to apply resource limit: " + resource
r.OutputTruncated = false
r.ResourceLimitFailure = resource
return r
}
}
if isSignal(err, syscall.SIGXCPU) {
r.Status = StatusCritical
r.ExitCode = 2
r.Output = "critical: CPU time limit exceeded"
r.OutputTruncated = false
return r
}
if err != nil { if err != nil {
var ee *exec.ExitError var ee *exec.ExitError
if errors.As(err, &ee) { if errors.As(err, &ee) {
@@ -74,6 +119,8 @@ func Run(ctx context.Context, argv []string, timeout time.Duration) Result {
} else { } else {
r.Status = StatusUnknown r.Status = StatusUnknown
r.ExitCode = -1 r.ExitCode = -1
// Launch failure (e.g. shell missing); surface exec error, never check output.
r.Output, r.OutputTruncated = TruncateUTF8([]byte("unknown: failed to start check: "+err.Error()), MaxOutputBytes)
return r return r
} }
} }
@@ -81,6 +128,60 @@ func Run(ctx context.Context, argv []string, timeout time.Duration) Result {
return r return r
} }
func isExitCode(err error, code int) bool {
var ee *exec.ExitError
return errors.As(err, &ee) && ee.ExitCode() == code
}
func limitedShellArgv(command string, limits ResourceLimits) []string {
cpu := ""
if limits.CPUTime > 0 {
cpu = strconv.FormatInt(int64((limits.CPUTime+time.Second-1)/time.Second), 10)
}
mem := ""
if limits.MemoryBytes > 0 {
mem = strconv.FormatInt((limits.MemoryBytes+1023)/1024, 10)
}
openFiles := ""
if limits.OpenFiles > 0 {
openFiles = strconv.Itoa(limits.OpenFiles)
}
script := `
cmd=$1
cpu=$2
mem=$3
open_files=$4
if [ -n "$cpu" ]; then ulimit -t "$cpu" 2>/dev/null || { printf '%s\n' '` + limitFailPrefix + `cpu_time'; exit 125; }; fi
if [ -n "$mem" ]; then ulimit -v "$mem" 2>/dev/null || { printf '%s\n' '` + limitFailPrefix + `memory'; exit 125; }; fi
if [ -n "$open_files" ]; then ulimit -n "$open_files" 2>/dev/null || { printf '%s\n' '` + limitFailPrefix + `open_files'; exit 125; }; fi
exec /bin/sh -c "$cmd"
`
return []string{"/bin/sh", "-c", script, "monlet-limit-wrapper", command, cpu, mem, openFiles}
}
func detectLimitFailure(output string) (string, bool) {
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, limitFailPrefix) {
resource := strings.TrimPrefix(line, limitFailPrefix)
if resource != "cpu_time" && resource != "memory" && resource != "open_files" {
resource = "unknown"
}
return resource, true
}
}
return "", false
}
func isSignal(err error, sig syscall.Signal) bool {
var ee *exec.ExitError
if !errors.As(err, &ee) {
return false
}
ws, ok := ee.Sys().(syscall.WaitStatus)
return ok && ws.Signaled() && ws.Signal() == sig
}
func MapExit(code int) Status { func MapExit(code int) Status {
switch code { switch code {
case 0: case 0:

View File

@@ -2,6 +2,7 @@ package runner
import ( import (
"context" "context"
"runtime"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -114,3 +115,41 @@ func TestRunNoExec(t *testing.T) {
t.Fatalf("got %+v", r) t.Fatalf("got %+v", r)
} }
} }
func TestRunCommandWithLimitsOK(t *testing.T) {
r := RunCommand(context.Background(), "echo ok", 2*time.Second, ResourceLimits{
CPUTime: time.Second,
OpenFiles: 64,
})
if r.Status != StatusOK || r.ExitCode != 0 {
t.Fatalf("got %+v", r)
}
if !strings.Contains(r.Output, "ok") {
t.Fatalf("output: %q", r.Output)
}
}
func TestRunCommandCPULimitExceeded(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("ulimit is Unix-only")
}
r := RunCommand(context.Background(), "while :; do :; done", 5*time.Second, ResourceLimits{
CPUTime: time.Second,
})
if r.Status != StatusCritical || r.ExitCode != 2 {
t.Fatalf("got %+v", r)
}
if !strings.Contains(r.Output, "CPU time limit exceeded") {
t.Fatalf("output: %q", r.Output)
}
}
func TestDetectLimitFailureBoundsResourceLabel(t *testing.T) {
resource, ok := detectLimitFailure(limitFailPrefix + "dynamic-user-output")
if !ok {
t.Fatal("expected limit failure")
}
if resource != "unknown" {
t.Fatalf("resource label must be bounded, got %q", resource)
}
}

View File

@@ -2,6 +2,7 @@ package scheduler
import ( import (
"context" "context"
"reflect"
"sync" "sync"
"time" "time"
@@ -35,46 +36,284 @@ func (e Event) WireEvent() Event {
type Hooks struct { type Hooks struct {
OnSkipped func(checkID string) OnSkipped func(checkID string)
OnResult func(ev Event) OnResult func(ev Event)
OnEventChannelFull func()
OnResourceLimitFailure func(checkID string, resource string)
OnStopped func(checkID string)
} }
// Run starts one goroutine per check. Emits events to out. Blocks until ctx is done. // Manager owns per-check workers and applies hot config updates.
func Run(ctx context.Context, agentID string, checks []config.CheckConfig, out chan<- Event, h Hooks) { type Manager struct {
var wg sync.WaitGroup ctx context.Context
for i := range checks { agentID string
c := checks[i] out chan<- Event
wg.Add(1) hooks Hooks
go func() {
defer wg.Done() mu sync.Mutex
runCheck(ctx, agentID, c, out, h) workers map[string]*checkWorker
draining map[string]struct{}
pending map[string]config.CheckConfig
all []*checkWorker
stopped bool
}
// NewManager creates a scheduler manager bound to the parent context.
func NewManager(ctx context.Context, agentID string, out chan<- Event, h Hooks) *Manager {
return &Manager{
ctx: ctx,
agentID: agentID,
out: out,
hooks: h,
workers: make(map[string]*checkWorker),
draining: make(map[string]struct{}),
pending: make(map[string]config.CheckConfig),
}
}
// Update applies the current check set without killing in-flight runs.
func (m *Manager) Update(checks []config.CheckConfig) {
m.mu.Lock()
defer m.mu.Unlock()
if m.stopped {
return
}
next := make(map[string]config.CheckConfig, len(checks))
for _, c := range checks {
next[c.ID] = c
if w, ok := m.workers[c.ID]; ok {
if _, draining := m.draining[c.ID]; draining {
m.pending[c.ID] = c
} else {
w.Update(c)
}
continue
}
id := c.ID
w := newCheckWorker(m.ctx, m.agentID, c, m.out, m.hooks, func(stopped *checkWorker) {
m.workerStopped(id, stopped)
})
m.workers[c.ID] = w
m.all = append(m.all, w)
}
for id, w := range m.workers {
if _, ok := next[id]; ok {
continue
}
m.draining[id] = struct{}{}
delete(m.pending, id)
w.Stop()
}
}
// Stop prevents new runs and lets in-flight checks finish or observe ctx cancel.
func (m *Manager) Stop() {
m.mu.Lock()
defer m.mu.Unlock()
if m.stopped {
return
}
m.stopped = true
for _, w := range m.workers {
w.Stop()
}
}
// Wait blocks until all workers have drained.
func (m *Manager) Wait() {
m.mu.Lock()
workers := append([]*checkWorker(nil), m.all...)
m.mu.Unlock()
for _, w := range workers {
w.Wait()
}
}
func (m *Manager) workerStopped(checkID string, w *checkWorker) {
m.mu.Lock()
defer m.mu.Unlock()
if current, ok := m.workers[checkID]; !ok || current != w {
return
}
delete(m.workers, checkID)
delete(m.draining, checkID)
if m.stopped {
delete(m.pending, checkID)
return
}
if cfg, ok := m.pending[checkID]; ok {
delete(m.pending, checkID)
next := newCheckWorker(m.ctx, m.agentID, cfg, m.out, m.hooks, func(stopped *checkWorker) {
m.workerStopped(checkID, stopped)
})
m.workers[checkID] = next
m.all = append(m.all, next)
}
}
type checkWorker struct {
ctx context.Context
agentID string
out chan<- Event
hooks Hooks
onDone func(*checkWorker)
updateCh chan config.CheckConfig
stopCh chan struct{}
doneCh chan struct{}
stopOnce sync.Once
wg sync.WaitGroup
}
func newCheckWorker(ctx context.Context, agentID string, c config.CheckConfig, out chan<- Event, h Hooks, onDone func(*checkWorker)) *checkWorker {
w := &checkWorker{
ctx: ctx,
agentID: agentID,
out: out,
hooks: h,
onDone: onDone,
updateCh: make(chan config.CheckConfig, 1),
stopCh: make(chan struct{}),
doneCh: make(chan struct{}, 1),
}
w.wg.Add(1)
go w.loop(c)
return w
}
func (w *checkWorker) Update(c config.CheckConfig) {
select {
case w.updateCh <- c:
default:
select {
case <-w.updateCh:
default:
}
w.updateCh <- c
}
}
func (w *checkWorker) Stop() {
w.stopOnce.Do(func() { close(w.stopCh) })
}
func (w *checkWorker) Wait() {
w.wg.Wait()
}
func (w *checkWorker) loop(initial config.CheckConfig) {
defer func() {
if w.onDone != nil {
w.onDone(w)
}
w.wg.Done()
}() }()
} cfg := initial
wg.Wait() t := time.NewTicker(cfg.Interval.Duration)
}
func runCheck(ctx context.Context, agentID string, c config.CheckConfig, out chan<- Event, h Hooks) {
t := time.NewTicker(c.Interval.Duration)
defer t.Stop() defer t.Stop()
var running bool
var mu sync.Mutex
exec := func() { running := false
mu.Lock() stopping := false
runAfterCurrent := false
ctxDone := w.ctx.Done()
stopCh := w.stopCh
start := func(c config.CheckConfig) {
if running { if running {
mu.Unlock() if w.hooks.OnSkipped != nil {
if h.OnSkipped != nil { w.hooks.OnSkipped(c.ID)
h.OnSkipped(c.ID)
} }
return return
} }
running = true running = true
mu.Unlock() go func() {
defer func() { w.run(c)
mu.Lock() w.doneCh <- struct{}{}
running = false
mu.Unlock()
}() }()
}
res := runner.Run(ctx, c.Argv(), c.Timeout.Duration) start(cfg)
for {
select {
case <-ctxDone:
stopping = true
ctxDone = nil
if !running {
if w.hooks.OnStopped != nil {
w.hooks.OnStopped(cfg.ID)
}
return
}
case <-stopCh:
stopping = true
stopCh = nil
if !running {
if w.hooks.OnStopped != nil {
w.hooks.OnStopped(cfg.ID)
}
return
}
case next := <-w.updateCh:
if reflect.DeepEqual(cfg, next) {
continue
}
cfg = next
t.Reset(cfg.Interval.Duration)
if running {
runAfterCurrent = true
} else {
start(cfg)
}
case <-w.doneCh:
running = false
if !stopping {
select {
case <-stopCh:
stopping = true
stopCh = nil
default:
}
}
if stopping {
if w.hooks.OnStopped != nil {
w.hooks.OnStopped(cfg.ID)
}
return
}
if runAfterCurrent {
runAfterCurrent = false
for {
select {
case next := <-w.updateCh:
if !reflect.DeepEqual(cfg, next) {
cfg = next
t.Reset(cfg.Interval.Duration)
}
default:
start(cfg)
goto nextLoop
}
}
nextLoop:
continue
}
case <-t.C:
if stopping {
continue
}
start(cfg)
}
}
}
func (w *checkWorker) run(c config.CheckConfig) {
res := runner.RunCommand(w.ctx, c.Command, c.Timeout.Duration, runner.ResourceLimits{
CPUTime: c.ResourceLimits.CPUTime.Duration,
MemoryBytes: c.ResourceLimits.Memory.Bytes,
OpenFiles: c.ResourceLimits.OpenFiles,
})
if res.ResourceLimitFailure != "" && w.hooks.OnResourceLimitFailure != nil {
w.hooks.OnResourceLimitFailure(c.ID, res.ResourceLimitFailure)
}
id, err := eventid.New() id, err := eventid.New()
if err != nil { if err != nil {
return return
@@ -89,26 +328,22 @@ func runCheck(ctx context.Context, agentID string, c config.CheckConfig, out cha
Output: res.Output, Output: res.Output,
OutputTruncated: res.OutputTruncated, OutputTruncated: res.OutputTruncated,
NotificationsEnabled: boolPtr(c.NotificationsOn()), NotificationsEnabled: boolPtr(c.NotificationsOn()),
IncidentKey: incidentKey(agentID, c), IncidentKey: incidentKey(w.agentID, c),
} }
if h.OnResult != nil { if w.hooks.OnResult != nil {
h.OnResult(ev) w.hooks.OnResult(ev)
} }
select { select {
case out <- ev: case w.out <- ev:
case <-ctx.Done(): case <-w.ctx.Done():
default:
if w.hooks.OnEventChannelFull != nil {
w.hooks.OnEventChannelFull()
} }
}
// Fire first run immediately so smoke tests don't wait a full interval.
go exec()
for {
select { select {
case <-ctx.Done(): case w.out <- ev:
return case <-w.ctx.Done():
case <-t.C: case <-w.stopCh:
go exec()
} }
} }
} }

View File

@@ -2,6 +2,7 @@ package scheduler
import ( import (
"context" "context"
"strings"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
@@ -9,6 +10,14 @@ import (
"github.com/monlet/agent/internal/config" "github.com/monlet/agent/internal/config"
) )
func runScheduler(ctx context.Context, agentID string, checks []config.CheckConfig, out chan<- Event, h Hooks) {
m := NewManager(ctx, agentID, out, h)
m.Update(checks)
<-ctx.Done()
m.Stop()
m.Wait()
}
func mkCheck(id, cmd string, interval, timeout time.Duration) config.CheckConfig { func mkCheck(id, cmd string, interval, timeout time.Duration) config.CheckConfig {
return config.CheckConfig{ return config.CheckConfig{
ID: id, ID: id,
@@ -22,7 +31,7 @@ func TestEmitsEvents(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel() defer cancel()
out := make(chan Event, 10) out := make(chan Event, 10)
go Run(ctx, "a1", []config.CheckConfig{mkCheck("c1", "echo hi", 200*time.Millisecond, 100*time.Millisecond)}, out, Hooks{}) go runScheduler(ctx, "a1", []config.CheckConfig{mkCheck("c1", "echo hi", 200*time.Millisecond, 100*time.Millisecond)}, out, Hooks{})
select { select {
case ev := <-out: case ev := <-out:
if ev.CheckID != "c1" || ev.Status != "ok" { if ev.CheckID != "c1" || ev.Status != "ok" {
@@ -43,9 +52,168 @@ func TestNoOverlapSkips(t *testing.T) {
var skipped int32 var skipped int32
hooks := Hooks{OnSkipped: func(string) { atomic.AddInt32(&skipped, 1) }} hooks := Hooks{OnSkipped: func(string) { atomic.AddInt32(&skipped, 1) }}
c := mkCheck("slow", "sleep 1", 100*time.Millisecond, 900*time.Millisecond) c := mkCheck("slow", "sleep 1", 100*time.Millisecond, 900*time.Millisecond)
go Run(ctx, "a1", []config.CheckConfig{c}, out, hooks) go runScheduler(ctx, "a1", []config.CheckConfig{c}, out, hooks)
<-ctx.Done() <-ctx.Done()
if atomic.LoadInt32(&skipped) == 0 { if atomic.LoadInt32(&skipped) == 0 {
t.Fatal("expected at least one skipped tick") t.Fatal("expected at least one skipped tick")
} }
} }
func TestReportsFullEventChannel(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out := make(chan Event, 1)
out <- Event{EventID: "occupied"}
var full int32
m := NewManager(ctx, "a1", out, Hooks{
OnEventChannelFull: func() { atomic.AddInt32(&full, 1) },
})
m.Update([]config.CheckConfig{mkCheck("c1", "echo hi", time.Hour, time.Second)})
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if atomic.LoadInt32(&full) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
if atomic.LoadInt32(&full) == 0 {
t.Fatal("expected full channel hook")
}
<-out
select {
case ev := <-out:
if ev.CheckID != "c1" {
t.Fatalf("unexpected event: %+v", ev)
}
case <-ctx.Done():
t.Fatal("blocked event was not delivered")
}
m.Stop()
m.Wait()
}
func TestManagerReloadChangedCheckWaitsForRunning(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out := make(chan Event, 10)
m := NewManager(ctx, "a1", out, Hooks{})
m.Update([]config.CheckConfig{mkCheck("c1", "sleep 0.3; echo old", time.Second, time.Second)})
time.Sleep(50 * time.Millisecond)
m.Update([]config.CheckConfig{mkCheck("c1", "echo new", time.Second, time.Second)})
var first, second Event
select {
case first = <-out:
case <-ctx.Done():
t.Fatal("no first event")
}
select {
case second = <-out:
case <-ctx.Done():
t.Fatal("no second event")
}
m.Stop()
m.Wait()
if !strings.Contains(first.Output, "old") {
t.Fatalf("first output: %q", first.Output)
}
if !strings.Contains(second.Output, "new") {
t.Fatalf("second output: %q", second.Output)
}
}
func TestManagerReloadRemovedCheckLetsRunningFinish(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
out := make(chan Event, 10)
stopped := make(chan string, 1)
m := NewManager(ctx, "a1", out, Hooks{OnStopped: func(id string) { stopped <- id }})
m.Update([]config.CheckConfig{mkCheck("c1", "sleep 0.2; echo old", time.Second, time.Second)})
time.Sleep(50 * time.Millisecond)
m.Update(nil)
select {
case ev := <-out:
if !strings.Contains(ev.Output, "old") {
t.Fatalf("output: %q", ev.Output)
}
case <-ctx.Done():
t.Fatal("removed running check did not finish")
}
select {
case id := <-stopped:
if id != "c1" {
t.Fatalf("stopped id: %q", id)
}
case <-ctx.Done():
t.Fatal("removed check did not stop")
}
m.Stop()
m.Wait()
}
func TestManagerRemoveThenReaddSameCheckDoesNotOverlap(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out := make(chan Event, 10)
m := NewManager(ctx, "a1", out, Hooks{})
m.Update([]config.CheckConfig{mkCheck("c1", "sleep 0.25; echo old", time.Second, time.Second)})
time.Sleep(50 * time.Millisecond)
m.Update(nil)
m.Update([]config.CheckConfig{mkCheck("c1", "echo new", time.Second, time.Second)})
var first, second Event
select {
case first = <-out:
case <-ctx.Done():
t.Fatal("no first event")
}
select {
case second = <-out:
case <-ctx.Done():
t.Fatal("no second event")
}
m.Stop()
m.Wait()
if !strings.Contains(first.Output, "old") {
t.Fatalf("first output: %q", first.Output)
}
if !strings.Contains(second.Output, "new") {
t.Fatalf("second output: %q", second.Output)
}
}
func TestManagerUsesLatestPendingReloadAfterRunningFinishes(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out := make(chan Event, 10)
m := NewManager(ctx, "a1", out, Hooks{})
m.Update([]config.CheckConfig{mkCheck("c1", "sleep 0.25; echo old", time.Second, time.Second)})
time.Sleep(50 * time.Millisecond)
m.Update([]config.CheckConfig{mkCheck("c1", "echo intermediate", time.Second, time.Second)})
m.Update([]config.CheckConfig{mkCheck("c1", "echo latest", time.Second, time.Second)})
var first, second Event
select {
case first = <-out:
case <-ctx.Done():
t.Fatal("no first event")
}
select {
case second = <-out:
case <-ctx.Done():
t.Fatal("no second event")
}
m.Stop()
m.Wait()
if !strings.Contains(first.Output, "old") {
t.Fatalf("first output: %q", first.Output)
}
if strings.Contains(second.Output, "intermediate") || !strings.Contains(second.Output, "latest") {
t.Fatalf("second output: %q", second.Output)
}
}

View File

@@ -6,8 +6,10 @@ Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
ExecStart=/usr/local/bin/monlet-agent -config /etc/monlet/agent.toml ExecStart=/usr/local/bin/monlet-agent -config /etc/monlet/agent.toml
ExecReload=/bin/kill -HUP $MAINPID
Restart=always Restart=always
RestartSec=5s RestartSec=5s
TimeoutStopSec=35s
User=monlet User=monlet
Group=monlet Group=monlet
StateDirectory=monlet-agent StateDirectory=monlet-agent

View File

@@ -107,7 +107,9 @@ paths:
"401": "401":
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
"403": "403":
description: Agent key is blocked. description: >-
Agent key is blocked (`agent_blocked`) or not yet accepted
(`agent_not_accepted`).
content: content:
application/json: application/json:
schema: schema:
@@ -627,6 +629,7 @@ components:
- forbidden - forbidden
- conflict - conflict
- agent_blocked - agent_blocked
- agent_not_accepted
- not_found - not_found
- validation - validation
- payload_too_large - payload_too_large

View File

@@ -65,6 +65,8 @@ services:
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_USERNAME: ${MONLET_WEB_AUTH_USERNAME:-}
MONLET_WEB_AUTH_PASSWORD: ${MONLET_WEB_AUTH_PASSWORD:-} MONLET_WEB_AUTH_PASSWORD: ${MONLET_WEB_AUTH_PASSWORD:-}
MONLET_WEB_SESSION_SECRET: ${MONLET_WEB_SESSION_SECRET:-}
MONLET_WEB_SESSION_TTL_SEC: ${MONLET_WEB_SESSION_TTL_SEC:-604800}
MONLET_WEB_TRUST_PROXY_AUTH: ${MONLET_WEB_TRUST_PROXY_AUTH:-false} 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:

View File

@@ -50,7 +50,7 @@ Web is read-only for monitoring state in v1. Agent admission and blacklist contr
Auth plan: Auth plan:
- reverse proxy basic auth or equivalent gateway auth; - local web login or equivalent gateway auth through a trusted reverse proxy;
- no full RBAC in v1. - no full RBAC in v1.
## Observability ## Observability

View File

@@ -5,6 +5,7 @@ A pragmatic list for the first production-ish Monlet stand-up. Adjust per enviro
## 1. Secrets ## 1. Secrets
- [ ] Generate `MONLET_AUTH_TOKEN` (`openssl rand -hex 32`) and store it in your secret manager. - [ ] Generate `MONLET_AUTH_TOKEN` (`openssl rand -hex 32`) and store it in your secret manager.
- [ ] Generate `MONLET_WEB_SESSION_SECRET` (`openssl rand -hex 32`) if local web login is enabled.
- [ ] Generate a PostgreSQL password and store it in your secret manager. - [ ] Generate a PostgreSQL password and store it in your secret manager.
- [ ] Decide which notifier channels you want and gather their credentials (Telegram bot token + chat id, webhook URL, Alertmanager URL). - [ ] Decide which notifier channels you want and gather their credentials (Telegram bot token + chat id, webhook URL, Alertmanager URL).
@@ -41,7 +42,8 @@ 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`. - [ ] Protect the web UI with either local login (`MONLET_WEB_AUTH_USERNAME`, `MONLET_WEB_AUTH_PASSWORD`, `MONLET_WEB_SESSION_SECRET`) or a trusted reverse proxy that sets `X-Forwarded-User` plus `MONLET_WEB_TRUST_PROXY_AUTH=true`.
- [ ] Rate-limit `/login` at the reverse proxy when local web login is enabled.
- [ ] 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).

View File

@@ -7,8 +7,10 @@ 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 .. import metrics
from ..agent_auth import AgentCredential, get_agent_credential from ..agent_auth import AgentCredential, get_agent_credential
from ..db import get_session from ..db import get_session
from ..logging_config import get_logger
from ..models import Event from ..models import Event
from ..schemas import ( from ..schemas import (
ID_PATTERN, ID_PATTERN,
@@ -22,6 +24,8 @@ from ..timeutil import to_utc
router = APIRouter() router = APIRouter()
log = get_logger("monlet.events")
def _encode_cursor(received_at: datetime, event_id) -> str: def _encode_cursor(received_at: datetime, event_id) -> str:
raw = f"{to_utc(received_at).isoformat()}|{event_id}".encode() raw = f"{to_utc(received_at).isoformat()}|{event_id}".encode()
@@ -52,12 +56,24 @@ async def events(
) -> EventBatchAcceptedResponse: ) -> EventBatchAcceptedResponse:
agent = await accepted_agent_for_key(session, credential) agent = await accepted_agent_for_key(session, credential)
if agent is None: if agent is None:
await session.commit() raise HTTPException(status_code=403, detail="agent not accepted")
return EventBatchAcceptedResponse(accepted=0, deduplicated=0)
accepted = 0 accepted = 0
deduplicated = 0 deduplicated = 0
for ev in body.events: for ev in body.events:
try:
async with session.begin_nested():
ok = await ingest_event(session, agent.agent_id, ev) ok = await ingest_event(session, agent.agent_id, ev)
except HTTPException as exc:
if exc.status_code != 400:
raise
log.warning(
"event_rejected",
check_id=ev.check_id,
agent_id=agent.agent_id,
code=exc.detail,
)
metrics.events_total.labels(result="rejected").inc()
continue
if ok: if ok:
accepted += 1 accepted += 1
else: else:

View File

@@ -46,11 +46,12 @@ 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 = ( code: ErrorCode = _STATUS_TO_CODE.get(exc.status_code, "internal")
"agent_blocked" if exc.status_code == 403:
if exc.status_code == 403 and exc.detail == "agent blocked" if exc.detail == "agent blocked":
else _STATUS_TO_CODE.get(exc.status_code, "internal") code = "agent_blocked"
) elif exc.detail == "agent not accepted":
code = "agent_not_accepted"
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)

View File

@@ -1,4 +1,4 @@
from prometheus_client import CollectorRegistry, Counter, Gauge, Histogram, generate_latest from prometheus_client import CollectorRegistry, Counter, Gauge, generate_latest
from prometheus_client.exposition import CONTENT_TYPE_LATEST from prometheus_client.exposition import CONTENT_TYPE_LATEST
REGISTRY = CollectorRegistry() REGISTRY = CollectorRegistry()
@@ -42,12 +42,6 @@ notifications_total = Counter(
["notifier", "result"], ["notifier", "result"],
registry=REGISTRY, registry=REGISTRY,
) )
request_duration = Histogram(
"monlet_server_request_duration_seconds",
"Request duration seconds.",
["path"],
registry=REGISTRY,
)
def render() -> tuple[bytes, str]: def render() -> tuple[bytes, str]:

View File

@@ -1,7 +1,6 @@
import re import re
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -39,6 +38,7 @@ ErrorCode = Literal[
"internal", "internal",
"not_ready", "not_ready",
"agent_blocked", "agent_blocked",
"agent_not_accepted",
] ]
@@ -233,12 +233,6 @@ class OutboxPage(PageEnvelope):
items: list[NotificationOutboxItem] items: list[NotificationOutboxItem]
class UUIDStr(BaseModel):
"""Helper to validate generated UUIDs."""
value: UUID
class SystemSummaryResponse(BaseModel): class SystemSummaryResponse(BaseModel):
agents_online: int agents_online: int
agents_offline: int agents_offline: int

View File

@@ -53,13 +53,7 @@ async def _blacklist_row(session: AsyncSession, cred: AgentCredential) -> AgentB
return res.scalar_one_or_none() return res.scalar_one_or_none()
async def reject_if_blocked( async def reject_if_blocked(session: AsyncSession, cred: AgentCredential) -> None:
session: AsyncSession,
cred: AgentCredential,
agent_id: str,
hostname: str,
observed: datetime | None = None,
) -> None:
row = await _blacklist_row(session, cred) row = await _blacklist_row(session, cred)
if row is None: if row is None:
return return
@@ -108,7 +102,7 @@ async def upsert_agent_heartbeat(
session: AsyncSession, hb: HeartbeatRequest, cred: AgentCredential session: AsyncSession, hb: HeartbeatRequest, cred: AgentCredential
) -> str: ) -> str:
observed = to_utc(hb.observed_at) observed = to_utc(hb.observed_at)
await reject_if_blocked(session, cred, hb.agent_id, hb.hostname, observed) await reject_if_blocked(session, cred)
features = hb.features.model_dump() features = hb.features.model_dump()
res = await session.execute(select(Agent).where(Agent.accepted_key_hash == cred.key_hash)) res = await session.execute(select(Agent).where(Agent.accepted_key_hash == cred.key_hash))
@@ -330,7 +324,6 @@ async def ingest_event(
incident_key=incident_key, incident_key=incident_key,
) )
await session.execute(stmt) await session.execute(stmt)
metrics.events_total.labels(result="accepted").inc()
else: else:
metrics.events_total.labels(result="unchanged").inc() metrics.events_total.labels(result="unchanged").inc()
@@ -366,6 +359,9 @@ async def ingest_event(
return False return False
transition, inc = await _apply_incident(session, agent_id, ev, incident_key) transition, inc = await _apply_incident(session, agent_id, ev, incident_key)
# Count accepted only after _apply_incident: an incident_key collision raises
# 400 and the savepoint rollback discards the Event insert above.
metrics.events_total.labels(result="accepted").inc()
if transition and inc is not None and ev.notifications_enabled: if transition and inc is not None and ev.notifications_enabled:
event_type = "resolved" if ev.status == "ok" else "firing" event_type = "resolved" if ev.status == "ok" else "firing"
await _enqueue_outbox( await _enqueue_outbox(

View File

@@ -208,9 +208,16 @@ async def tick(
await session.commit() await session.commit()
if not rows: if not rows:
return 0 return 0
await asyncio.gather(
*(_process_row(sm, registry, r, settings.notifier_max_attempts) for r in rows) # Bound concurrent DB sessions: each _process_row opens its own session, so an
) # unbounded gather over a large batch can exhaust the connection pool.
sem = asyncio.Semaphore(settings.notifier_concurrency)
async def _guarded(row: dict) -> None:
async with sem:
await _process_row(sm, registry, row, settings.notifier_max_attempts)
await asyncio.gather(*(_guarded(r) for r in rows))
return len(rows) return len(rows)

View File

@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
import httpx
def classify_response(status: int) -> tuple[bool, bool]: def classify_response(status: int) -> tuple[bool, bool]:
"""Return (ok, permanent_failure).""" """Return (ok, permanent_failure)."""
@@ -10,9 +8,3 @@ def classify_response(status: int) -> tuple[bool, bool]:
if 400 <= status < 500 and status not in (408, 425, 429): if 400 <= status < 500 and status not in (408, 425, 429):
return False, True return False, True
return False, False return False, False
def classify_exception(exc: Exception) -> tuple[bool, bool]:
if isinstance(exc, httpx.TimeoutException | httpx.NetworkError):
return False, False
return False, False

View File

@@ -1,4 +1,4 @@
from pydantic import field_validator from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from .timeutil import validate_time_zone from .timeutil import validate_time_zone
@@ -40,6 +40,7 @@ class Settings(BaseSettings):
notifier_batch_size: int = 20 notifier_batch_size: int = 20
notifier_max_attempts: int = 8 notifier_max_attempts: int = 8
notifier_lease_sec: int = 300 notifier_lease_sec: int = 300
notifier_concurrency: int = Field(default=4, ge=1)
notifier_http_timeout_sec: float = 10.0 notifier_http_timeout_sec: float = 10.0
notification_time_zone: str = "UTC" notification_time_zone: str = "UTC"

View File

@@ -22,14 +22,14 @@ async def test_unknown_heartbeat_creates_pending_agent(app_client, auth_headers,
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_pending_events_are_dropped_until_accept(app_client, auth_headers, session): async def test_pending_events_are_rejected_until_accept(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=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", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers "/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
) )
assert r.status_code == 202 assert r.status_code == 403
assert r.json() == {"accepted": 0, "deduplicated": 0} assert r.json()["error"]["code"] == "agent_not_accepted"
n = (await session.execute(select(func.count()).select_from(Check))).scalar_one() n = (await session.execute(select(func.count()).select_from(Check))).scalar_one()
assert n == 0 assert n == 0

View File

@@ -23,13 +23,13 @@ async def test_event_accepted_and_state(app_client, ui_auth_headers, session):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_event_before_heartbeat_is_dropped(app_client, auth_headers, session): async def test_event_before_heartbeat_is_rejected(app_client, auth_headers, session):
ev = make_event(status="warning", exit_code=1) ev = make_event(status="warning", exit_code=1)
r = await app_client.post( r = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-replay", "events": [ev]}, headers=auth_headers "/api/v1/events", json={"agent_id": "agent-replay", "events": [ev]}, headers=auth_headers
) )
assert r.status_code == 202 assert r.status_code == 403
assert r.json() == {"accepted": 0, "deduplicated": 0} assert r.json()["error"]["code"] == "agent_not_accepted"
n = (await session.execute(select(func.count()).select_from(Agent))).scalar_one() n = (await session.execute(select(func.count()).select_from(Agent))).scalar_one()
assert n == 0 assert n == 0

View File

@@ -18,8 +18,8 @@ async def test_incident_key_ownership_rejected(app_client, ui_auth_headers):
r2 = await app_client.post( r2 = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-2", "events": [ev2]}, headers=agent_2_headers "/api/v1/events", json={"agent_id": "agent-2", "events": [ev2]}, headers=agent_2_headers
) )
assert r2.status_code == 400 assert r2.status_code == 202
assert r2.json()["error"]["code"] == "validation" assert r2.json() == {"accepted": 0, "deduplicated": 0}
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -36,11 +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_USERNAME` | _(none)_ | Optional local web login username |
| `MONLET_WEB_AUTH_PASSWORD` | _(none)_ | Optional Basic Auth password for the web UI | | `MONLET_WEB_AUTH_PASSWORD` | _(none)_ | Optional local web login password |
| `MONLET_WEB_SESSION_SECRET` | _(none)_ | Secret used to sign the web session cookie; required with local web login; at least 32 bytes |
| `MONLET_WEB_SESSION_TTL_SEC` | `604800` | Local web session lifetime |
| `MONLET_WEB_TRUST_PROXY_AUTH` | `false` | Trust `X-Forwarded-User` from an upstream auth proxy | | `MONLET_WEB_TRUST_PROXY_AUTH` | `false` | Trust `X-Forwarded-User` from an upstream auth proxy |
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. The API token never reaches the browser — all server calls happen in Server Components / route handlers. Agent admission actions are blocked unless local web login is configured or trusted proxy auth is enabled.
Production deployments should rate-limit `/login` at the reverse proxy. The app applies constant-time credential checks and a small fixed delay on failed login attempts, but it is not a full auth gateway.
The timezone switcher stores the selected mode in browser cookies, so each operator keeps their own default/browser/UTC preference.
## Pages ## Pages

View File

@@ -1,8 +1,10 @@
import { redirect } from "next/navigation";
import { AgentDetailBrowser } from "@/components/AgentDetailBrowser"; 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 { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination"; import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -38,22 +40,19 @@ export default async function AgentDetailPage({
const sp = await searchParams; const sp = await searchParams;
const sort = normalizeSort(sp.sort); const sort = normalizeSort(sp.sort);
const tab = normalizeTab(sp.tab); const tab = normalizeTab(sp.tab);
const eventsLimit = normalizePageSize(sp.events_limit, 50); if (sp.cursor || sp.back || sp.page || sp.events_limit) {
const eventsCursor = sp.cursor; redirect(agentDetailHref({ agentId: agent_id, tab, sort }));
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, prev_cursor: null }; let events: EventsPage = { items: [], next_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") {
events = await api.queryEvents({ events = await api.queryEvents({
agent_id, agent_id,
limit: eventsLimit, limit: INFINITE_BATCH_SIZE,
cursor: eventsCursor,
back: eventsBack,
}); });
} }
} catch (e) { } catch (e) {
@@ -64,15 +63,24 @@ export default async function AgentDetailPage({
} }
return ( return (
<AgentDetailBrowser <AgentDetailBrowser
key={`${agent_id}:${tab}:${sort}:${eventsLimit}:${eventsCursor ?? ""}:${eventsBack ? "b" : "f"}:${eventsPage}`} key={`${agent_id}:${tab}:${sort}`}
initialData={{ agent, checks }} initialData={{ agent, checks }}
initialEvents={events} initialEvents={events}
sort={sort} sort={sort}
tab={tab} tab={tab}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
eventsBack={eventsBack}
eventsPage={eventsPage}
/> />
); );
} }
function agentDetailHref({
agentId,
tab,
sort,
}: {
agentId: string;
tab: TabKey;
sort: SortKey;
}) {
const qs = new URLSearchParams({ tab, sort });
return `/agents/${encodeURIComponent(agentId)}?${qs.toString()}`;
}

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { jsonError } from "@/lib/poll-response"; import { jsonError } from "@/lib/poll-response";
import { assertSameOrigin } from "@/lib/web-auth-server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -16,6 +17,12 @@ type Action =
| "blacklist_clear"; | "blacklist_clear";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
if (!assertSameOrigin(request)) {
return NextResponse.json(
{ error: { code: "forbidden", message: "cross-origin request rejected" } },
{ status: 403 },
);
}
try { try {
const body = (await request.json()) as { const body = (await request.json()) as {
action?: Action; action?: Action;

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import {
cookieSecure,
constantTimeEqualString,
createSessionToken,
delayLoginFailure,
localAuthEnabled,
proxyAuthEnabled,
sanitizeNext,
SESSION_COOKIE,
sessionTtlSec,
} from "@/lib/web-auth";
import { assertSameOrigin } from "@/lib/web-auth-server";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
if (!assertSameOrigin(request)) return new NextResponse("forbidden", { status: 403 });
const form = await request.formData();
const next = sanitizeNext(stringField(form, "next"));
if (proxyAuthEnabled() || !localAuthEnabled()) return redirectLogin(request, next, "config");
const username = stringField(form, "username");
const password = stringField(form, "password");
const [usernameOk, passwordOk] = await Promise.all([
constantTimeEqualString(username, process.env.MONLET_WEB_AUTH_USERNAME ?? ""),
constantTimeEqualString(password, process.env.MONLET_WEB_AUTH_PASSWORD ?? ""),
]);
if (!usernameOk || !passwordOk) {
await delayLoginFailure();
return redirectLogin(request, next, "invalid");
}
const ttl = sessionTtlSec();
const token = await createSessionToken(username, process.env.MONLET_WEB_SESSION_SECRET ?? "", ttl);
const response = NextResponse.redirect(new URL(next, request.url), { status: 303 });
response.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: cookieSecure(request),
path: "/",
maxAge: ttl,
});
return response;
}
function redirectLogin(request: NextRequest, next: string, error: string) {
const url = new URL("/login", request.url);
url.searchParams.set("next", next);
url.searchParams.set("error", error);
return NextResponse.redirect(url, { status: 303 });
}
function stringField(form: FormData, name: string): string {
const value = form.get(name);
return typeof value === "string" ? value : "";
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { cookieSecure, SESSION_COOKIE } from "@/lib/web-auth";
import { assertSameOrigin } from "@/lib/web-auth-server";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
if (!assertSameOrigin(request)) return new NextResponse("forbidden", { status: 403 });
const response = NextResponse.redirect(new URL("/login", request.url), { status: 303 });
response.cookies.set(SESSION_COOKIE, "", {
httpOnly: true,
sameSite: "lax",
secure: cookieSecure(request),
path: "/",
maxAge: 0,
});
return response;
}

View File

@@ -1,21 +1,20 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { loadEvents } from "@/lib/loaders"; import { loadEvents } from "@/lib/loaders";
import { normalizePageSize } from "@/lib/pagination"; import { normalizeFetchLimit } from "@/lib/pagination";
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(request: Request) { export async function GET(request: Request) {
const url = new URL(request.url); const url = new URL(request.url);
const limit = normalizePageSize(url.searchParams.get("limit"), 50); const limit = normalizeFetchLimit(url.searchParams.get("limit"));
try { try {
const data = await loadEvents({ const data = await loadEvents({
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);

View File

@@ -15,7 +15,6 @@ export async function GET(request: Request) {
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 cursor = url.searchParams.get("cursor") ?? undefined;
const back = url.searchParams.get("back") === "1";
const limitRaw = url.searchParams.get("limit"); const limitRaw = url.searchParams.get("limit");
const parsed = limitRaw ? Number(limitRaw) : undefined; const parsed = limitRaw ? Number(limitRaw) : undefined;
const limit = parsed && Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 500) : 100; const limit = parsed && Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 500) : 100;
@@ -28,7 +27,7 @@ export async function GET(request: Request) {
const direction = dirRaw && DIRS.has(dirRaw) ? (dirRaw as "asc" | "desc") : undefined; const direction = dirRaw && DIRS.has(dirRaw) ? (dirRaw as "asc" | "desc") : undefined;
try { try {
const page = await loadIncidentsPage({ state, cursor, back, limit, sort, direction }); const page = await loadIncidentsPage({ state, cursor, limit, sort, direction });
return NextResponse.json(page); return NextResponse.json(page);
} catch (e) { } catch (e) {
return jsonError(e); return jsonError(e);

View File

@@ -12,7 +12,6 @@ const DIRS = new Set(["asc", "desc"]);
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 cursor = url.searchParams.get("cursor") ?? undefined; const cursor = url.searchParams.get("cursor") ?? undefined;
const back = url.searchParams.get("back") === "1";
const limitRaw = url.searchParams.get("limit"); const limitRaw = url.searchParams.get("limit");
const parsed = limitRaw ? Number(limitRaw) : undefined; const parsed = limitRaw ? Number(limitRaw) : undefined;
const limit = parsed && Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 500) : 100; const limit = parsed && Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 500) : 100;
@@ -26,7 +25,6 @@ export async function GET(request: Request) {
try { try {
const page = await loadOutboxPage({ const page = await loadOutboxPage({
cursor, cursor,
back,
limit, limit,
state: state as never, state: state as never,
sort, sort,

View File

@@ -1,8 +1,10 @@
import { redirect } from "next/navigation";
import { CheckDetailBrowser } from "@/components/CheckDetailBrowser"; 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 { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination"; import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -29,21 +31,18 @@ export default async function CheckDetailPage({
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); if (sp.cursor || sp.back || sp.page || sp.events_limit) {
const eventsCursor = sp.cursor; redirect(checkDetailHref({ checkId: check_id, tab }));
const eventsPage = normalizePageNumber(sp.page); }
const eventsBack = normalizeBack(sp.back);
let data; let data;
let events: EventsPage = { items: [], next_cursor: null, prev_cursor: null }; let events: EventsPage = { items: [], next_cursor: null };
try { try {
data = await loadInventory(); data = await loadInventory();
if (tab === "events") { if (tab === "events") {
events = await api.queryEvents({ events = await api.queryEvents({
check_id, check_id,
limit: eventsLimit, limit: INFINITE_BATCH_SIZE,
cursor: eventsCursor,
back: eventsBack,
}); });
} }
} catch (e) { } catch (e) {
@@ -52,15 +51,22 @@ export default async function CheckDetailPage({
return ( return (
<CheckDetailBrowser <CheckDetailBrowser
key={`${check_id}:${tab}:${eventsLimit}:${eventsCursor ?? ""}:${eventsBack ? "b" : "f"}:${eventsPage}`} key={`${check_id}:${tab}`}
checkId={check_id} checkId={check_id}
initialData={data} initialData={data}
initialEvents={events} initialEvents={events}
tab={tab} tab={tab}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
eventsBack={eventsBack}
eventsPage={eventsPage}
/> />
); );
} }
function checkDetailHref({
checkId,
tab,
}: {
checkId: string;
tab: TabKey;
}) {
const qs = new URLSearchParams({ tab });
return `/checks/${encodeURIComponent(checkId)}?${qs.toString()}`;
}

View File

@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
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 { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination"; import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -18,31 +20,39 @@ export default async function EventsPage({
}>; }>;
}) { }) {
const sp = await searchParams; const sp = await searchParams;
const limit = normalizePageSize(sp.limit); if (sp.cursor || sp.back || sp.page || sp.limit) {
const page = normalizePageNumber(sp.page); redirect(eventsHref({ agentId: sp.agent_id, checkId: sp.check_id }));
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, limit: INFINITE_BATCH_SIZE,
back,
limit,
}); });
} catch (e) { } catch (e) {
return <ErrorPanel error={e} />; return <ErrorPanel error={e} />;
} }
return ( return (
<EventsBrowser <EventsBrowser
key={`${sp.cursor ?? ""}:${back ? "b" : "f"}:${page}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}:${limit}`} key={`${sp.agent_id ?? ""}:${sp.check_id ?? ""}`}
initialData={data} initialData={data}
cursor={sp.cursor}
back={back}
agentId={sp.agent_id} agentId={sp.agent_id}
checkId={sp.check_id} checkId={sp.check_id}
limit={limit}
page={page}
/> />
); );
} }
function eventsHref({
agentId,
checkId,
}: {
agentId?: string;
checkId?: string;
}) {
const qs = new URLSearchParams();
if (agentId) qs.set("agent_id", agentId);
if (checkId) qs.set("check_id", checkId);
const query = qs.toString();
return query ? `/events?${query}` : "/events";
}

View File

@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { ErrorPanel } from "@/components/DataPanel"; import { ErrorPanel } from "@/components/DataPanel";
import { IncidentsBrowser } from "@/components/IncidentsBrowser"; import { IncidentsBrowser } from "@/components/IncidentsBrowser";
import { loadIncidentsPage, loadInventory } from "@/lib/loaders"; import { loadIncidentsPage, loadInventory } from "@/lib/loaders";
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination"; import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -25,14 +27,14 @@ export default async function IncidentsPage({
? sp.sort ? sp.sort
: "status"; : "status";
const direction = sp.direction === "asc" || sp.direction === "desc" ? sp.direction : "desc"; const direction = sp.direction === "asc" || sp.direction === "desc" ? sp.direction : "desc";
const limit = normalizePageSize(sp.limit); if (sp.cursor || sp.back || sp.page || sp.limit) {
const pageNumber = normalizePageNumber(sp.page); redirect(incidentsHref({ state, sort, direction }));
const back = normalizeBack(sp.back); }
let page; let page;
let inventory; let inventory;
try { try {
[page, inventory] = await Promise.all([ [page, inventory] = await Promise.all([
loadIncidentsPage({ state, cursor: sp.cursor, back, limit, sort, direction }), loadIncidentsPage({ state, limit: INFINITE_BATCH_SIZE, sort, direction }),
loadInventory(), loadInventory(),
]); ]);
} catch (e) { } catch (e) {
@@ -40,16 +42,28 @@ export default async function IncidentsPage({
} }
return ( return (
<IncidentsBrowser <IncidentsBrowser
key={`${sp.cursor ?? ""}:${back ? "b" : "f"}:${pageNumber}:${state ?? ""}:${sort}:${direction}:${limit}`} key={`${state ?? ""}:${sort}:${direction}`}
initialData={page} initialData={page}
initialInventory={inventory} initialInventory={inventory}
state={state} state={state}
cursor={sp.cursor}
back={back}
sort={sort} sort={sort}
direction={direction} direction={direction}
limit={limit}
page={pageNumber}
/> />
); );
} }
function incidentsHref({
state,
sort,
direction,
}: {
state?: "open" | "resolved";
sort: "status" | "opened_at" | "resolved_at";
direction: "asc" | "desc";
}) {
const qs = new URLSearchParams();
if (state) qs.set("state", state);
qs.set("sort", sort);
qs.set("direction", direction);
return `/incidents?${qs.toString()}`;
}

View File

@@ -3,8 +3,9 @@ import type { ReactNode } from "react";
import { TimeZoneProvider } from "@/components/TimeZoneControls"; import { TimeZoneProvider } from "@/components/TimeZoneControls";
import { TopNav } from "@/components/TopNav"; import { TopNav } from "@/components/TopNav";
import { loadSystemSummary } from "@/lib/loaders"; import { loadSystemSummary } from "@/lib/loaders";
import { getUiTimeZone } from "@/lib/timezone"; import { getInitialTimeZonePreference, getUiTimeZone } from "@/lib/timezone";
import type { SystemSummaryData } from "@/lib/view-types"; import type { SystemSummaryData } from "@/lib/view-types";
import { requestHasAppAccess, showLocalLogout } from "@/lib/web-auth-server";
import "./globals.css"; import "./globals.css";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -24,13 +25,19 @@ async function safeLoadSystemSummary(): Promise<SystemSummaryData | null> {
export default async function RootLayout({ children }: { children: ReactNode }) { export default async function RootLayout({ children }: { children: ReactNode }) {
const configuredTimeZone = getUiTimeZone(); const configuredTimeZone = getUiTimeZone();
const summary = await safeLoadSystemSummary(); const timezonePreference = await getInitialTimeZonePreference();
const hasAppAccess = await requestHasAppAccess();
const summary = hasAppAccess ? await safeLoadSystemSummary() : null;
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
<TopNav initialSummary={summary} /> configuredTimeZone={configuredTimeZone}
initialMode={timezonePreference.mode}
initialBrowserTimeZone={timezonePreference.browserTimeZone}
>
{hasAppAccess ? <TopNav initialSummary={summary} showLogout={showLocalLogout()} /> : null}
<main className="flex-1 px-4 py-6">{children}</main> <main className="flex-1 px-4 py-6">{children}</main>
</TimeZoneProvider> </TimeZoneProvider>
</body> </body>

View File

@@ -0,0 +1,96 @@
import { redirect } from "next/navigation";
import {
localAuthEnabled,
localAuthMisconfigured,
proxyAuthEnabled,
sanitizeNext,
} from "@/lib/web-auth";
import { requestHasAppAccess } from "@/lib/web-auth-server";
export const dynamic = "force-dynamic";
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ next?: string; error?: string }>;
}) {
const sp = await searchParams;
const next = sanitizeNext(sp.next);
if (await requestHasAppAccess()) redirect(next);
const proxyMode = proxyAuthEnabled();
const enabled = localAuthEnabled();
const misconfigured = localAuthMisconfigured();
const error =
sp.error === "invalid"
? "Invalid username or password."
: sp.error === "config" || misconfigured
? "Web authentication is not configured correctly."
: null;
return (
<div className="mx-auto mt-24 w-full max-w-sm">
<div className="mb-6 flex items-center gap-3">
<span className="relative h-9 w-9 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" />
<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>
<h1 className="text-2xl font-semibold">
mon<span className="text-emerald-300">let</span>
</h1>
</div>
<h2 className="mb-4 text-lg font-semibold">Sign in</h2>
{error ? (
<div className="mb-4 border border-red-900 bg-red-950/30 px-3 py-2 text-sm text-red-200">
{error}
</div>
) : null}
{misconfigured ? (
<div className="border border-red-900 bg-red-950/30 px-3 py-2 text-sm text-red-200">
Web authentication is misconfigured.
</div>
) : proxyMode ? (
<div className="border border-amber-900 bg-amber-950/20 px-3 py-2 text-sm text-amber-200">
Proxy authentication is enabled. This page is only shown when the proxy did not pass
an authenticated user.
</div>
) : enabled ? (
<form action="/api/auth/login" method="post" className="space-y-3">
<input type="hidden" name="next" value={next} />
<label className="block text-sm">
<span className="mb-1 block text-neutral-400">username</span>
<input
name="username"
autoComplete="username"
className="w-full border border-neutral-800 bg-neutral-950 px-3 py-2 text-neutral-100 outline-none focus:border-neutral-500"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-neutral-400">password</span>
<input
name="password"
type="password"
autoComplete="current-password"
className="w-full border border-neutral-800 bg-neutral-950 px-3 py-2 text-neutral-100 outline-none focus:border-neutral-500"
/>
</label>
<button
type="submit"
className="w-full border border-emerald-900 bg-emerald-950/40 px-3 py-2 text-sm text-emerald-200 hover:border-emerald-600"
>
Sign in
</button>
</form>
) : (
<div className="border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm text-neutral-300">
Local web authentication is disabled.
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { ErrorPanel } from "@/components/DataPanel"; import { ErrorPanel } from "@/components/DataPanel";
import { OutboxBrowser } from "@/components/OutboxBrowser"; import { OutboxBrowser } from "@/components/OutboxBrowser";
import { loadOutboxPage } from "@/lib/loaders"; import { loadOutboxPage } from "@/lib/loaders";
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination"; import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -18,27 +20,34 @@ export default async function OutboxPage({
}>; }>;
}) { }) {
const sp = await searchParams; const sp = await searchParams;
const limit = normalizePageSize(sp.limit);
const sort = sp.sort === "created_at" || sp.sort === "updated_at" ? sp.sort : "updated_at"; 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 direction = sp.direction === "asc" || sp.direction === "desc" ? sp.direction : "desc";
const pageNumber = normalizePageNumber(sp.page); if (sp.cursor || sp.back || sp.page || sp.limit) redirect(outboxHref({ sort, direction }));
const back = normalizeBack(sp.back);
let page; let page;
try { try {
page = await loadOutboxPage({ cursor: sp.cursor, back, limit, sort, direction }); page = await loadOutboxPage({ limit: INFINITE_BATCH_SIZE, sort, direction });
} catch (e) { } catch (e) {
return <ErrorPanel error={e} />; return <ErrorPanel error={e} />;
} }
return ( return (
<OutboxBrowser <OutboxBrowser
key={`${sp.cursor ?? ""}:${back ? "b" : "f"}:${pageNumber}:${sort}:${direction}:${limit}`} key={`${sort}:${direction}`}
initialData={page} initialData={page}
cursor={sp.cursor}
back={back}
sort={sort} sort={sort}
direction={direction} direction={direction}
limit={limit}
page={pageNumber}
/> />
); );
} }
function outboxHref({
sort,
direction,
}: {
sort: "created_at" | "updated_at";
direction: "asc" | "desc";
}) {
const qs = new URLSearchParams();
qs.set("sort", sort);
qs.set("direction", direction);
return `/outbox?${qs.toString()}`;
}

View File

@@ -1,33 +1,25 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useCallback, useMemo } from "react";
import { useMemo, useState } from "react";
import { AgentFeatures } from "@/components/AgentFeatures"; import { AgentFeatures } from "@/components/AgentFeatures";
import { CheckEventsLink } from "@/components/CheckEventsLink"; import { CheckEventsLink } from "@/components/CheckEventsLink";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, SummaryCell, Td, Th } from "@/components/DataPanel"; import { EmptyPanel, SummaryCell, Td, Th } from "@/components/DataPanel";
import { DetailTabs } from "@/components/DetailTabs"; import { DetailTabs } from "@/components/DetailTabs";
import { InfiniteListFooter } from "@/components/InfiniteListFooter";
import { LabelChips } from "@/components/Labels"; import { LabelChips } from "@/components/Labels";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { Pager } from "@/components/Pager";
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 { INFINITE_BATCH_SIZE } from "@/lib/pagination";
DEFAULT_PAGE_SIZE, import { useInfiniteClientList, useInfiniteCursorList } from "@/lib/useInfiniteList";
clampPage,
pageCountOf,
paginate,
type PageSize,
} from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling"; 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; prev_cursor?: string | null }; type EventsPageData = { items: StoredEvent[]; next_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[] };
@@ -45,19 +37,11 @@ export function AgentDetailBrowser({
initialEvents, initialEvents,
sort, sort,
tab, tab,
eventsLimit,
eventsCursor,
eventsBack,
eventsPage,
}: { }: {
initialData: AgentDetailData; initialData: AgentDetailData;
initialEvents: EventsPageData; initialEvents: EventsPageData;
sort: SortKey; sort: SortKey;
tab: TabKey; tab: TabKey;
eventsLimit: PageSize;
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)}`;
@@ -102,22 +86,14 @@ export function AgentDetailBrowser({
{ {
key: "events", key: "events",
label: "Events", label: "Events",
href: `/agents/${encodeURIComponent(agentId)}?tab=events&sort=${sort}&events_limit=${eventsLimit}`, href: `/agents/${encodeURIComponent(agentId)}?tab=events&sort=${sort}`,
}, },
]} ]}
/> />
{tab === "checks" ? ( {tab === "checks" ? (
<ChecksTable agentId={agentId} checks={checks} sort={sort} /> <ChecksTable agentId={agentId} checks={checks} sort={sort} />
) : ( ) : (
<EventsTable <EventsTable agentId={agentId} initialEvents={initialEvents} />
agentId={agentId}
sort={sort}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
eventsBack={eventsBack}
eventsPage={eventsPage}
initialEvents={initialEvents}
/>
)} )}
</div> </div>
); );
@@ -132,22 +108,19 @@ function ChecksTable({
checks: CheckState[]; checks: CheckState[];
sort: SortKey; sort: SortKey;
}) { }) {
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE); const {
const [page, setPage] = useState(1); visibleItems: visible,
const onPageSizeChange = (next: PageSize) => { hasMore,
setPageSize(next); sentinelRef,
setPage(1); } = useInfiniteClientList({
}; items: checks,
const pageCount = pageCountOf(checks.length, pageSize); batchSize: INFINITE_BATCH_SIZE,
const currentPage = clampPage(page, pageCount); resetKey: `${agentId}:${sort}`,
const visible = paginate(checks, currentPage, pageSize); });
if (checks.length === 0) return <EmptyPanel label="No checks" />; if (checks.length === 0) return <EmptyPanel label="No checks" />;
return ( return (
<section> <section>
<div className="mb-3 flex justify-end">
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
</div>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr> <tr>
@@ -184,48 +157,45 @@ function ChecksTable({
))} ))}
</tbody> </tbody>
</table> </table>
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} /> <InfiniteListFooter sentinelRef={sentinelRef} hasMore={hasMore} />
</section> </section>
); );
} }
function EventsTable({ function EventsTable({
agentId, agentId,
sort,
eventsLimit,
eventsCursor,
eventsBack,
eventsPage,
initialEvents, initialEvents,
}: { }: {
agentId: string; agentId: string;
sort: SortKey;
eventsLimit: PageSize;
eventsCursor?: string;
eventsBack: boolean;
eventsPage: number;
initialEvents: EventsPageData; initialEvents: EventsPageData;
}) { }) {
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(INFINITE_BATCH_SIZE) });
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, eventsBack, eventsLimit]); }, [agentId]);
const { data } = usePolling(initialEvents, pollUrl); const getPageUrl = useCallback(
(cursor: string) => `${pollUrl}&cursor=${encodeURIComponent(cursor)}`,
const onLimitChange = (next: PageSize) => { [pollUrl],
const qs = new URLSearchParams({ tab: "events", sort, events_limit: String(next) }); );
router.push(`/agents/${encodeURIComponent(agentId)}?${qs.toString()}`); const {
}; items,
hasMore,
loadingMore,
error,
sentinelRef,
hasNewItems,
showLatest,
} = useInfiniteCursorList<StoredEvent>({
initialData: initialEvents,
firstPageUrl: pollUrl,
getPageUrl,
getKey: eventKey,
resetKey: agentId,
});
return ( return (
<section> <section>
<div className="mb-3 flex justify-end"> {items.length === 0 ? (
<PageSizeSelect value={eventsLimit} onChange={onLimitChange} />
</div>
{data.items.length === 0 ? (
<EmptyPanel label="No events" /> <EmptyPanel label="No events" />
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -239,7 +209,7 @@ function EventsTable({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.items.map((e) => ( {items.map((e) => (
<tr key={e.event_id}> <tr key={e.event_id}>
<Td> <Td>
<DateTime iso={e.observed_at} /> <DateTime iso={e.observed_at} />
@@ -255,12 +225,13 @@ function EventsTable({
</tbody> </tbody>
</table> </table>
)} )}
<Pager <InfiniteListFooter
basePath={`/agents/${encodeURIComponent(agentId)}`} sentinelRef={sentinelRef}
prevCursor={data.prev_cursor} hasMore={hasMore}
nextCursor={data.next_cursor} loading={loadingMore}
page={eventsPage} error={error}
extra={{ tab: "events", sort, events_limit: String(eventsLimit) }} hasNewItems={hasNewItems}
onShowLatest={showLatest}
/> />
</section> </section>
); );
@@ -294,3 +265,7 @@ function sortChecks(checks: CheckState[], sort: SortKey): CheckState[] {
return severityRank[a.status] - severityRank[b.status] || a.check_id.localeCompare(b.check_id); return severityRank[a.status] - severityRank[b.status] || a.check_id.localeCompare(b.check_id);
}); });
} }
function eventKey(event: StoredEvent): string {
return event.event_id;
}

View File

@@ -5,21 +5,16 @@ import { useMemo, useState } from "react";
import { AgentFeatures } from "@/components/AgentFeatures"; import { AgentFeatures } from "@/components/AgentFeatures";
import { AgentLink } from "@/components/AgentLink"; import { AgentLink } from "@/components/AgentLink";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, Td, Th } from "@/components/DataPanel"; import { EmptyPanel, Td, Th } from "@/components/DataPanel";
import { InfiniteListFooter } from "@/components/InfiniteListFooter";
import { LabelChips } from "@/components/Labels"; import { LabelChips } from "@/components/Labels";
import { ListToolbar } from "@/components/ListToolbar"; import { ListToolbar } from "@/components/ListToolbar";
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 { INFINITE_BATCH_SIZE } from "@/lib/pagination";
DEFAULT_PAGE_SIZE, import { useInfiniteClientList } from "@/lib/useInfiniteList";
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";
@@ -56,19 +51,18 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
({ agent }) => agent.rotation_pending, ({ agent }) => agent.rotation_pending,
).length; ).length;
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE); const {
const [page, setPage] = useState(1); visibleItems: visible,
const pageCount = pageCountOf(rows.length, pageSize); hasMore,
const currentPage = clampPage(page, pageCount); sentinelRef,
const visible = paginate(rows, currentPage, pageSize); } = useInfiniteClientList({
items: rows,
batchSize: INFINITE_BATCH_SIZE,
resetKey: `${query}:${sort}:${dir}`,
});
const onQueryChange = (value: string) => { const onQueryChange = (value: string) => {
setQuery(value); setQuery(value);
setPage(1);
};
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
}; };
const chooseSort = (field: SortField) => { const chooseSort = (field: SortField) => {
if (field === sort) { if (field === sort) {
@@ -77,7 +71,6 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
setSort(field); setSort(field);
setDir(fieldDefaultDir(field)); setDir(fieldDefaultDir(field));
} }
setPage(1);
}; };
const runAction = async ( const runAction = async (
action: string, action: string,
@@ -123,8 +116,6 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
query={query} query={query}
onQueryChange={onQueryChange} onQueryChange={onQueryChange}
placeholder="search agent" placeholder="search agent"
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
/> />
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyPanel /> <EmptyPanel />
@@ -140,7 +131,7 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
rotationPendingCount={allRotationPendingCount} rotationPendingCount={allRotationPendingCount}
/> />
)} )}
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} /> <InfiniteListFooter sentinelRef={sentinelRef} hasMore={hasMore} />
</> </>
); );
} }
@@ -173,6 +164,8 @@ function AgentsTable({
rotationPendingCount > 0 rotationPendingCount > 0
? `ROTATION: existing accepted keys will be replaced for ${rotationPendingCount} agents. Accept all pending agents including rotations?` ? `ROTATION: existing accepted keys will be replaced for ${rotationPendingCount} agents. Accept all pending agents including rotations?`
: undefined; : undefined;
const visiblePendingRows = rows.filter(({ agent }) => agent.admission_state === "pending");
const visibleAcceptedRows = rows.filter(({ agent }) => agent.admission_state === "accepted");
return ( return (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
@@ -198,37 +191,76 @@ function AgentsTable({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{pendingCount > 0 ? ( {visiblePendingRows.length > 0 ? (
<tr> <tr>
<td colSpan={6} className="border-b border-neutral-900 bg-amber-950/20 px-2 py-2 text-amber-300"> <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"> <div className="flex flex-wrap items-center gap-3">
<span>{pendingCount} pending</span> <span>{pendingCount} pending</span>
<ActionButton {rotationPendingCount > 0 ? (
label="✅" <span className="text-amber-500">{rotationPendingCount} replacements</span>
title="Accept all" ) : null}
</div>
</td>
</tr>
) : null}
{visiblePendingRows.map((row) => (
<AgentTableRow key={row.agent.agent_id} row={row} busy={busy} runAction={runAction} />
))}
{visiblePendingRows.length > 0 ? (
<tr>
<td colSpan={6} className="border-b border-neutral-900 bg-amber-950/10 px-2 py-3">
<div className="flex flex-wrap items-center gap-2">
<BulkActionButton
label="✅ Accept all"
title="Accept all pending agents"
disabled={busy !== null} disabled={busy !== null}
onClick={() => onClick={() =>
runAction("accept_all", undefined, acceptAllText, rotationPendingCount > 0) runAction("accept_all", undefined, acceptAllText, rotationPendingCount > 0)
} }
/> />
<ActionButton <BulkActionButton
label="⛔" label="⛔ Block all"
title="Block all" title="Block all pending agents"
disabled={busy !== null} disabled={busy !== null}
onClick={() => runAction("block_all", undefined, "Block all pending agents?")} onClick={() => runAction("block_all", undefined, "Block all pending agents?")}
/> />
<ActionButton <BulkActionButton
label="🗑" label="🗑 Remove all"
title="Remove all" title="Remove all pending agents"
disabled={busy !== null} disabled={busy !== null}
onClick={() => runAction("remove_all", undefined, "Remove all pending agents and their data?")} onClick={() =>
runAction("remove_all", undefined, "Remove all pending agents and their data?")
}
/> />
</div> </div>
</td> </td>
</tr> </tr>
) : null} ) : null}
{rows.map(({ agent: a, checks }) => ( {visibleAcceptedRows.map((row) => (
<tr key={a.agent_id} className={a.admission_state === "pending" ? "bg-amber-950/10" : ""}> <AgentTableRow key={row.agent.agent_id} row={row} busy={busy} runAction={runAction} />
))}
</tbody>
</table>
);
}
function AgentTableRow({
row,
busy,
runAction,
}: {
row: AgentRow;
busy: string | null;
runAction: (
action: string,
agentId?: string,
confirmText?: string,
includeRotation?: boolean,
) => void;
}) {
const { agent: a, checks } = row;
return (
<tr className={a.admission_state === "pending" ? "bg-amber-950/10" : ""}>
<Td> <Td>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AgentLink agent={a} agentId={a.agent_id} /> <AgentLink agent={a} agentId={a.agent_id} />
@@ -255,9 +287,6 @@ function AgentsTable({
<AgentActions agent={a} busy={busy} runAction={runAction} /> <AgentActions agent={a} busy={busy} runAction={runAction} />
</Td> </Td>
</tr> </tr>
))}
</tbody>
</table>
); );
} }
@@ -313,6 +342,31 @@ function AgentActions({
); );
} }
function BulkActionButton({
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-700 bg-neutral-950 px-3 py-1.5 text-sm text-neutral-100 hover:border-neutral-400 disabled:opacity-40"
>
{label}
</button>
);
}
function ActionButton({ function ActionButton({
label, label,
title, title,

View File

@@ -1,32 +1,24 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useCallback, useMemo } from "react";
import { useMemo, useState } from "react";
import { AgentLink } from "@/components/AgentLink"; import { AgentLink } from "@/components/AgentLink";
import { CheckStatusSummary } from "@/components/CheckStatusSummary"; import { CheckStatusSummary } from "@/components/CheckStatusSummary";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, SummaryCell, Td, Th } from "@/components/DataPanel"; import { EmptyPanel, SummaryCell, Td, Th } from "@/components/DataPanel";
import { DetailTabs } from "@/components/DetailTabs"; import { DetailTabs } from "@/components/DetailTabs";
import { PageSizeSelect } from "@/components/PageSizeSelect"; import { InfiniteListFooter } from "@/components/InfiniteListFooter";
import { Pager } from "@/components/Pager";
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 { groupChecks, statusSeverity, type Agent, type CheckGroup } from "@/lib/check-groups"; import { groupChecks, statusSeverity, type Agent, type CheckGroup } from "@/lib/check-groups";
import { statusBadgeClass } from "@/lib/format"; import { statusBadgeClass } from "@/lib/format";
import { import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
DEFAULT_PAGE_SIZE, import { useInfiniteClientList, useInfiniteCursorList } from "@/lib/useInfiniteList";
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";
type StoredEvent = components["schemas"]["StoredCheckResultEvent"]; type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null }; type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
type TabKey = "agents" | "events"; type TabKey = "agents" | "events";
export function CheckDetailBrowser({ export function CheckDetailBrowser({
@@ -34,19 +26,11 @@ export function CheckDetailBrowser({
initialData, initialData,
initialEvents, initialEvents,
tab, tab,
eventsLimit,
eventsCursor,
eventsBack,
eventsPage,
}: { }: {
checkId: string; checkId: string;
initialData: InventoryData; initialData: InventoryData;
initialEvents: EventsPageData; initialEvents: EventsPageData;
tab: TabKey; tab: TabKey;
eventsLimit: PageSize;
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(
@@ -101,7 +85,7 @@ export function CheckDetailBrowser({
{ {
key: "events", key: "events",
label: "Events", label: "Events",
href: `/checks/${encodeURIComponent(checkId)}?tab=events&events_limit=${eventsLimit}`, href: `/checks/${encodeURIComponent(checkId)}?tab=events`,
}, },
]} ]}
/> />
@@ -112,10 +96,6 @@ export function CheckDetailBrowser({
checkId={checkId} checkId={checkId}
initialData={initialEvents} initialData={initialEvents}
agentsById={agentsById} agentsById={agentsById}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
eventsBack={eventsBack}
eventsPage={eventsPage}
/> />
)} )}
</div> </div>
@@ -133,21 +113,18 @@ function AgentsForCheck({ group }: { group: CheckGroup }) {
[group.checks], [group.checks],
); );
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE); const {
const [page, setPage] = useState(1); visibleItems: visible,
const onPageSizeChange = (next: PageSize) => { hasMore,
setPageSize(next); sentinelRef,
setPage(1); } = useInfiniteClientList({
}; items: rows,
const pageCount = pageCountOf(rows.length, pageSize); batchSize: INFINITE_BATCH_SIZE,
const currentPage = clampPage(page, pageCount); resetKey: group.checkId,
const visible = paginate(rows, currentPage, pageSize); });
return ( return (
<section> <section>
<div className="mb-3 flex justify-end">
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
</div>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr> <tr>
@@ -172,7 +149,7 @@ function AgentsForCheck({ group }: { group: CheckGroup }) {
))} ))}
</tbody> </tbody>
</table> </table>
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} /> <InfiniteListFooter sentinelRef={sentinelRef} hasMore={hasMore} />
</section> </section>
); );
} }
@@ -181,43 +158,41 @@ function EventsForCheck({
checkId, checkId,
initialData, initialData,
agentsById, agentsById,
eventsLimit,
eventsCursor,
eventsBack,
eventsPage,
}: { }: {
checkId: string; checkId: string;
initialData: EventsPageData; initialData: EventsPageData;
agentsById: Map<string, Agent>; agentsById: Map<string, Agent>;
eventsLimit: PageSize;
eventsCursor?: string;
eventsBack: boolean;
eventsPage: number;
}) { }) {
const router = useRouter();
const pollUrl = useMemo(() => { const pollUrl = useMemo(() => {
const qs = new URLSearchParams({ const qs = new URLSearchParams({
check_id: checkId, check_id: checkId,
limit: String(eventsLimit), limit: String(INFINITE_BATCH_SIZE),
}); });
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, eventsBack, eventsLimit]); }, [checkId]);
const { data } = usePolling(initialData, pollUrl); const getPageUrl = useCallback(
(cursor: string) => `${pollUrl}&cursor=${encodeURIComponent(cursor)}`,
const onLimitChange = (next: PageSize) => { [pollUrl],
router.push(
`/checks/${encodeURIComponent(checkId)}?tab=events&events_limit=${next}`,
); );
}; const {
items,
hasMore,
loadingMore,
error,
sentinelRef,
hasNewItems,
showLatest,
} = useInfiniteCursorList<StoredEvent>({
initialData,
firstPageUrl: pollUrl,
getPageUrl,
getKey: eventKey,
resetKey: checkId,
});
return ( return (
<section> <section>
<div className="mb-3 flex justify-end"> {items.length === 0 ? (
<PageSizeSelect value={eventsLimit} onChange={onLimitChange} />
</div>
{data.items.length === 0 ? (
<EmptyPanel label="No events" /> <EmptyPanel label="No events" />
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -232,7 +207,7 @@ function EventsForCheck({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.items.map((event) => { {items.map((event) => {
const agent = agentsById.get(event.agent_id); const agent = agentsById.get(event.agent_id);
return ( return (
<tr key={event.event_id}> <tr key={event.event_id}>
@@ -254,12 +229,13 @@ function EventsForCheck({
</tbody> </tbody>
</table> </table>
)} )}
<Pager <InfiniteListFooter
basePath={`/checks/${encodeURIComponent(checkId)}`} sentinelRef={sentinelRef}
prevCursor={data.prev_cursor} hasMore={hasMore}
nextCursor={data.next_cursor} loading={loadingMore}
page={eventsPage} error={error}
extra={{ tab: "events", events_limit: String(eventsLimit) }} hasNewItems={hasNewItems}
onShowLatest={showLatest}
/> />
</section> </section>
); );
@@ -268,3 +244,7 @@ function EventsForCheck({
function hostName(row: { agent?: Agent; check: { agent_id: string } }): string { function hostName(row: { agent?: Agent; check: { agent_id: string } }): string {
return row.agent?.hostname || row.check.agent_id; return row.agent?.hostname || row.check.agent_id;
} }
function eventKey(event: StoredEvent): string {
return event.event_id;
}

View File

@@ -4,8 +4,8 @@ import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { CheckStatusSummary } from "@/components/CheckStatusSummary"; import { CheckStatusSummary } from "@/components/CheckStatusSummary";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, Td, Th } from "@/components/DataPanel"; import { EmptyPanel, Td, Th } from "@/components/DataPanel";
import { InfiniteListFooter } from "@/components/InfiniteListFooter";
import { ListToolbar } from "@/components/ListToolbar"; import { ListToolbar } from "@/components/ListToolbar";
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton"; import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
import { DateTime } from "@/components/TimeZoneControls"; import { DateTime } from "@/components/TimeZoneControls";
@@ -14,13 +14,8 @@ import {
groupChecks, groupChecks,
type CheckGroup, type CheckGroup,
} from "@/lib/check-groups"; } from "@/lib/check-groups";
import { import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
DEFAULT_PAGE_SIZE, import { useInfiniteClientList } from "@/lib/useInfiniteList";
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";
@@ -39,19 +34,18 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
[dir, groups, query, sort], [dir, groups, query, sort],
); );
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE); const {
const [page, setPage] = useState(1); visibleItems: visible,
const pageCount = pageCountOf(rows.length, pageSize); hasMore,
const currentPage = clampPage(page, pageCount); sentinelRef,
const visible = paginate(rows, currentPage, pageSize); } = useInfiniteClientList({
items: rows,
batchSize: INFINITE_BATCH_SIZE,
resetKey: `${query}:${sort}:${dir}`,
});
const onQueryChange = (value: string) => { const onQueryChange = (value: string) => {
setQuery(value); setQuery(value);
setPage(1);
};
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
}; };
const chooseSort = (field: SortField) => { const chooseSort = (field: SortField) => {
if (field === sort) { if (field === sort) {
@@ -60,7 +54,6 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
setSort(field); setSort(field);
setDir(fieldDefaultDir(field)); setDir(fieldDefaultDir(field));
} }
setPage(1);
}; };
return ( return (
@@ -75,8 +68,6 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
query={query} query={query}
onQueryChange={onQueryChange} onQueryChange={onQueryChange}
placeholder="search check" placeholder="search check"
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
/> />
{rows.length === 0 ? ( {rows.length === 0 ? (
<EmptyPanel /> <EmptyPanel />
@@ -125,7 +116,7 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
</tbody> </tbody>
</table> </table>
)} )}
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} /> <InfiniteListFooter sentinelRef={sentinelRef} hasMore={hasMore} />
</> </>
); );
} }

View File

@@ -1,39 +0,0 @@
"use client";
export function ClientPager({
page,
pageCount,
onPageChange,
}: {
page: number;
pageCount: number;
onPageChange: (page: number) => void;
}) {
const prev = () => onPageChange(Math.max(1, page - 1));
const next = () => onPageChange(Math.min(pageCount, page + 1));
const disabledPrev = page <= 1;
const disabledNext = page >= pageCount;
return (
<div className="mt-4 flex items-center justify-between text-sm">
<button
type="button"
onClick={prev}
disabled={disabledPrev}
className="text-blue-300 hover:text-blue-200 disabled:cursor-default disabled:text-neutral-600"
>
prev
</button>
<span className="text-neutral-500">
page {page} of {pageCount}
</span>
<button
type="button"
onClick={next}
disabled={disabledNext}
className="text-blue-300 hover:text-blue-200 disabled:cursor-default disabled:text-neutral-600"
>
next
</button>
</div>
);
}

View File

@@ -1,58 +1,60 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import { CheckEventsLink } from "@/components/CheckEventsLink"; import { CheckEventsLink } from "@/components/CheckEventsLink";
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel"; import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
import { PageSizeSelect } from "@/components/PageSizeSelect"; import { InfiniteListFooter } from "@/components/InfiniteListFooter";
import { Pager } from "@/components/Pager";
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 { type PageSize } from "@/lib/pagination"; import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling"; import { useInfiniteCursorList } from "@/lib/useInfiniteList";
type StoredEvent = components["schemas"]["StoredCheckResultEvent"]; type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null }; type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
export function EventsBrowser({ export function EventsBrowser({
initialData, initialData,
cursor,
back,
agentId, agentId,
checkId, checkId,
limit,
page,
}: { }: {
initialData: EventsPageData; initialData: EventsPageData;
cursor?: string;
back: boolean;
agentId?: string; agentId?: string;
checkId?: string; checkId?: string;
limit: PageSize;
page: number;
}) { }) {
const router = useRouter(); const firstPageUrl = useMemo(
const url = useMemo( () => eventsPollUrl({ agentId, checkId }),
() => eventsPollUrl({ cursor, back, agentId, checkId, limit }), [agentId, checkId],
[agentId, back, checkId, cursor, limit],
); );
const { data } = usePolling(initialData, url); const getPageUrl = useCallback(
(cursor: string) => `${firstPageUrl}&cursor=${encodeURIComponent(cursor)}`,
const onLimitChange = (next: PageSize) => { [firstPageUrl],
router.push(eventsHref({ agent_id: agentId, check_id: checkId, limit: next })); );
}; const {
items,
hasMore,
loadingMore,
error,
sentinelRef,
hasNewItems,
showLatest,
} = useInfiniteCursorList<StoredEvent>({
initialData,
firstPageUrl,
getPageUrl,
getKey: eventKey,
resetKey: `${agentId ?? ""}:${checkId ?? ""}`,
});
return ( return (
<> <>
<PageHeader title="Events" count={data.items.length} /> <PageHeader title="Events" count={items.length} />
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4">
<ActiveFilters agentId={agentId} checkId={checkId} limit={limit} /> <ActiveFilters agentId={agentId} checkId={checkId} />
<PageSizeSelect value={limit} onChange={onLimitChange} />
</div> </div>
{data.items.length === 0 ? ( {items.length === 0 ? (
<EmptyPanel /> <EmptyPanel />
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -68,7 +70,7 @@ export function EventsBrowser({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.items.map((e) => ( {items.map((e) => (
<tr key={e.event_id}> <tr key={e.event_id}>
<Td> <Td>
<DateTime iso={e.observed_at} /> <DateTime iso={e.observed_at} />
@@ -88,12 +90,13 @@ export function EventsBrowser({
</tbody> </tbody>
</table> </table>
)} )}
<Pager <InfiniteListFooter
basePath="/events" sentinelRef={sentinelRef}
prevCursor={data.prev_cursor} hasMore={hasMore}
nextCursor={data.next_cursor} loading={loadingMore}
page={page} error={error}
extra={{ agent_id: agentId, check_id: checkId, limit: String(limit) }} hasNewItems={hasNewItems}
onShowLatest={showLatest}
/> />
</> </>
); );
@@ -102,11 +105,9 @@ export function EventsBrowser({
function ActiveFilters({ function ActiveFilters({
agentId, agentId,
checkId, checkId,
limit,
}: { }: {
agentId?: string; agentId?: string;
checkId?: string; checkId?: string;
limit: PageSize;
}) { }) {
if (!agentId && !checkId) return <div />; if (!agentId && !checkId) return <div />;
@@ -116,18 +117,18 @@ function ActiveFilters({
<FilterChip <FilterChip
label="agent_id" label="agent_id"
value={agentId} value={agentId}
href={eventsHref({ check_id: checkId, limit })} href={eventsHref({ check_id: checkId })}
/> />
) : null} ) : null}
{checkId ? ( {checkId ? (
<FilterChip <FilterChip
label="check_id" label="check_id"
value={checkId} value={checkId}
href={eventsHref({ agent_id: agentId, limit })} href={eventsHref({ agent_id: agentId })}
/> />
) : null} ) : null}
<Link <Link
href={eventsHref({ limit })} href={eventsHref({})}
className="text-neutral-500 hover:text-neutral-300" className="text-neutral-500 hover:text-neutral-300"
> >
clear clear
@@ -153,34 +154,28 @@ function FilterChip({ label, value, href }: { label: string; value: string; href
function eventsHref(params: { function eventsHref(params: {
agent_id?: string; agent_id?: string;
check_id?: string; check_id?: string;
limit?: PageSize;
}): string { }): string {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.agent_id) qs.set("agent_id", params.agent_id); if (params.agent_id) qs.set("agent_id", params.agent_id);
if (params.check_id) qs.set("check_id", params.check_id); if (params.check_id) qs.set("check_id", params.check_id);
if (params.limit) qs.set("limit", String(params.limit));
const query = qs.toString(); const query = qs.toString();
return query ? `/events?${query}` : "/events"; return query ? `/events?${query}` : "/events";
} }
function eventsPollUrl({ function eventsPollUrl({
cursor,
back,
agentId, agentId,
checkId, checkId,
limit,
}: { }: {
cursor?: string;
back: boolean;
agentId?: string; agentId?: string;
checkId?: string; checkId?: string;
limit: PageSize;
}) { }) {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
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(INFINITE_BATCH_SIZE));
return `/api/poll/events?${qs.toString()}`; return `/api/poll/events?${qs.toString()}`;
} }
function eventKey(event: StoredEvent): string {
return event.event_id;
}

View File

@@ -2,18 +2,18 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo } from "react"; import { useCallback, useMemo } from "react";
import { AgentLink } from "@/components/AgentLink"; import { AgentLink } from "@/components/AgentLink";
import { CheckEventsLink } from "@/components/CheckEventsLink"; import { CheckEventsLink } from "@/components/CheckEventsLink";
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel"; import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
import { PageSizeSelect } from "@/components/PageSizeSelect"; import { InfiniteListFooter } from "@/components/InfiniteListFooter";
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 { type PageSize } from "@/lib/pagination"; import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
import { useInfiniteCursorList } from "@/lib/useInfiniteList";
import { usePolling } from "@/lib/usePolling"; import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types"; import type { InventoryData } from "@/lib/view-types";
@@ -21,51 +21,56 @@ 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[]; next_cursor?: string | null; prev_cursor?: string | null }; type IncidentsData = { items: Incident[]; next_cursor?: string | null };
type SortField = "status" | "opened_at" | "resolved_at"; type SortField = "status" | "opened_at" | "resolved_at";
export function IncidentsBrowser({ export function IncidentsBrowser({
initialData, initialData,
initialInventory, initialInventory,
state, state,
cursor,
back,
sort, sort,
direction, direction,
limit,
page,
}: { }: {
initialData: IncidentsData; initialData: IncidentsData;
initialInventory: InventoryData; initialInventory: InventoryData;
state?: "open" | "resolved"; state?: "open" | "resolved";
cursor?: string;
back: boolean;
sort: SortField; sort: SortField;
direction: SortDir; direction: SortDir;
limit: PageSize;
page: number;
}) { }) {
const router = useRouter(); const router = useRouter();
const url = useMemo(() => { const firstPageUrl = useMemo(() => {
const q = new URLSearchParams(); const q = new URLSearchParams();
if (state) q.set("state", state); if (state) q.set("state", state);
q.set("sort", sort); q.set("sort", sort);
q.set("direction", direction); q.set("direction", direction);
q.set("limit", String(limit)); q.set("limit", String(INFINITE_BATCH_SIZE));
if (cursor) q.set("cursor", cursor);
if (back) q.set("back", "1");
return `/api/poll/incidents?${q.toString()}`; return `/api/poll/incidents?${q.toString()}`;
}, [cursor, back, direction, limit, state, sort]); }, [direction, state, sort]);
const { data } = usePolling<IncidentsData>(initialData, url); const getPageUrl = useCallback(
(cursor: string) => `${firstPageUrl}&cursor=${encodeURIComponent(cursor)}`,
[firstPageUrl],
);
const {
items,
hasMore,
loadingMore,
error,
sentinelRef,
hasNewItems,
showLatest,
} = useInfiniteCursorList<Incident>({
initialData,
firstPageUrl,
getPageUrl,
getKey: incidentKey,
resetKey: `${state ?? "all"}:${sort}:${direction}`,
});
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 onPageSizeChange = (next: PageSize) => {
router.push(incidentsHref({ state, sort, direction, limit: next }));
};
const chooseSort = (field: SortField) => { const chooseSort = (field: SortField) => {
let nextSort = sort; let nextSort = sort;
let nextDirection = direction; let nextDirection = direction;
@@ -75,12 +80,12 @@ export function IncidentsBrowser({
nextSort = field; nextSort = field;
nextDirection = fieldDefaultDir(); nextDirection = fieldDefaultDir();
} }
router.push(incidentsHref({ state, sort: nextSort, direction: nextDirection, limit })); router.push(incidentsHref({ state, sort: nextSort, direction: nextDirection }));
}; };
return ( return (
<> <>
<PageHeader title="Incidents" count={data.items.length} /> <PageHeader title="Incidents" count={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) => {
@@ -88,7 +93,6 @@ export function IncidentsBrowser({
state: s === "all" ? undefined : s, state: s === "all" ? undefined : s,
sort: sort === "resolved_at" && s !== "resolved" ? "status" : sort, sort: sort === "resolved_at" && s !== "resolved" ? "status" : sort,
direction, direction,
limit,
}); });
const active = (s === "all" && !state) || s === state; const active = (s === "all" && !state) || s === state;
return ( return (
@@ -102,9 +106,8 @@ export function IncidentsBrowser({
); );
})} })}
</div> </div>
<PageSizeSelect value={limit} onChange={onPageSizeChange} />
</div> </div>
{data.items.length === 0 ? ( {items.length === 0 ? (
<EmptyPanel /> <EmptyPanel />
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -150,7 +153,7 @@ export function IncidentsBrowser({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.items.map((incident) => ( {items.map((incident) => (
<IncidentRow <IncidentRow
key={incident.id} key={incident.id}
incident={incident} incident={incident}
@@ -160,12 +163,13 @@ export function IncidentsBrowser({
</tbody> </tbody>
</table> </table>
)} )}
<Pager <InfiniteListFooter
basePath="/incidents" sentinelRef={sentinelRef}
prevCursor={data.prev_cursor} hasMore={hasMore}
nextCursor={data.next_cursor} loading={loadingMore}
page={page} error={error}
extra={{ state, sort, direction, limit: String(limit) }} hasNewItems={hasNewItems}
onShowLatest={showLatest}
/> />
</> </>
); );
@@ -220,6 +224,10 @@ function incidentCheckId(incident: Incident): string {
return incident.check_id ?? ""; return incident.check_id ?? "";
} }
function incidentKey(incident: Incident): string {
return String(incident.id);
}
function fieldDefaultDir(): SortDir { function fieldDefaultDir(): SortDir {
return "desc"; return "desc";
} }
@@ -228,18 +236,15 @@ function incidentsHref({
state, state,
sort, sort,
direction, direction,
limit,
}: { }: {
state?: "open" | "resolved"; state?: "open" | "resolved";
sort: SortField; sort: SortField;
direction: SortDir; direction: SortDir;
limit: PageSize;
}) { }) {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (state) qs.set("state", state); if (state) qs.set("state", state);
qs.set("sort", sort); qs.set("sort", sort);
qs.set("direction", direction); qs.set("direction", direction);
qs.set("limit", String(limit));
const query = qs.toString(); const query = qs.toString();
return query ? `/incidents?${query}` : "/incidents"; return query ? `/incidents?${query}` : "/incidents";
} }

View File

@@ -0,0 +1,40 @@
"use client";
import type { RefObject } from "react";
export function InfiniteListFooter({
sentinelRef,
hasMore,
loading,
error,
hasNewItems,
onShowLatest,
}: {
sentinelRef: RefObject<HTMLDivElement | null>;
hasMore: boolean;
loading?: boolean;
error?: string | null;
hasNewItems?: boolean;
onShowLatest?: () => void;
}) {
if (!hasMore && !loading && !error && !hasNewItems) return null;
return (
<>
{hasNewItems && onShowLatest ? (
<button
type="button"
onClick={onShowLatest}
className="fixed bottom-4 right-4 z-40 border border-emerald-800 bg-neutral-950 px-3 py-2 text-xs text-emerald-200 shadow-lg hover:border-emerald-500"
>
show latest
</button>
) : null}
<div ref={sentinelRef} className="mt-4 min-h-10 py-2 text-center text-xs text-neutral-500">
{error ? <span className="text-red-300">{error}</span> : null}
{!error && loading ? "loading..." : null}
{!error && !loading && hasMore ? "scroll for more" : null}
</div>
</>
);
}

View File

@@ -1,20 +1,13 @@
"use client"; "use client";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { type PageSize } from "@/lib/pagination";
export function ListToolbar({ export function ListToolbar({
query, query,
onQueryChange, onQueryChange,
placeholder, placeholder,
pageSize,
onPageSizeChange,
}: { }: {
query: string; query: string;
onQueryChange: (value: string) => void; onQueryChange: (value: string) => void;
placeholder: string; placeholder: string;
pageSize: PageSize;
onPageSizeChange: (next: PageSize) => void;
}) { }) {
return ( return (
<div className="mb-4 flex items-center justify-between gap-3"> <div className="mb-4 flex items-center justify-between gap-3">
@@ -36,7 +29,6 @@ export function ListToolbar({
</button> </button>
) : null} ) : null}
</div> </div>
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
</div> </div>
); );
} }

View File

@@ -1,66 +1,67 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useMemo } from "react"; import { useCallback, useMemo } from "react";
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel"; import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { PageSizeSelect } from "@/components/PageSizeSelect"; import { InfiniteListFooter } from "@/components/InfiniteListFooter";
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 { type PageSize } from "@/lib/pagination"; import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling"; import { useInfiniteCursorList } from "@/lib/useInfiniteList";
type OutboxItem = components["schemas"]["NotificationOutboxItem"]; type OutboxItem = components["schemas"]["NotificationOutboxItem"];
type OutboxData = { items: OutboxItem[]; next_cursor?: string | null; prev_cursor?: string | null }; type OutboxData = { items: OutboxItem[]; next_cursor?: string | null };
type SortField = "created_at" | "updated_at"; type SortField = "created_at" | "updated_at";
export function OutboxBrowser({ export function OutboxBrowser({
initialData, initialData,
cursor,
back,
sort, sort,
direction, direction,
limit,
page,
}: { }: {
initialData: OutboxData; initialData: OutboxData;
cursor?: string;
back: boolean;
sort: SortField; sort: SortField;
direction: SortDir; direction: SortDir;
limit: PageSize;
page: number;
}) { }) {
const router = useRouter(); const router = useRouter();
const url = useMemo(() => { const firstPageUrl = useMemo(() => {
const q = new URLSearchParams(); const q = new URLSearchParams();
q.set("sort", sort); q.set("sort", sort);
q.set("direction", direction); q.set("direction", direction);
q.set("limit", String(limit)); q.set("limit", String(INFINITE_BATCH_SIZE));
if (cursor) q.set("cursor", cursor);
if (back) q.set("back", "1");
return `/api/poll/outbox?${q.toString()}`; return `/api/poll/outbox?${q.toString()}`;
}, [cursor, back, direction, limit, sort]); }, [direction, sort]);
const { data } = usePolling<OutboxData>(initialData, url); const getPageUrl = useCallback(
(cursor: string) => `${firstPageUrl}&cursor=${encodeURIComponent(cursor)}`,
[firstPageUrl],
);
const {
items,
hasMore,
loadingMore,
error,
sentinelRef,
hasNewItems,
showLatest,
} = useInfiniteCursorList<OutboxItem>({
initialData,
firstPageUrl,
getPageUrl,
getKey: outboxKey,
resetKey: `${sort}:${direction}`,
});
const onPageSizeChange = (next: PageSize) => {
router.push(outboxHref({ sort, direction, limit: next }));
};
const chooseSort = (field: SortField) => { const chooseSort = (field: SortField) => {
const nextDirection = field === sort && direction === "desc" ? "asc" : "desc"; const nextDirection = field === sort && direction === "desc" ? "asc" : "desc";
router.push(outboxHref({ sort: field, direction: nextDirection, limit })); router.push(outboxHref({ sort: field, direction: nextDirection }));
}; };
return ( return (
<> <>
<PageHeader title="Notification outbox" count={data.items.length} /> <PageHeader title="Notification outbox" count={items.length} />
<div className="mb-3 flex justify-end"> {items.length === 0 ? (
<PageSizeSelect value={limit} onChange={onPageSizeChange} />
</div>
{data.items.length === 0 ? (
<EmptyPanel /> <EmptyPanel />
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
@@ -80,7 +81,7 @@ export function OutboxBrowser({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.items.map((o) => ( {items.map((o) => (
<tr key={o.id}> <tr key={o.id}>
<Td> <Td>
<DateTime iso={o.updated_at} /> <DateTime iso={o.updated_at} />
@@ -98,12 +99,13 @@ export function OutboxBrowser({
</tbody> </tbody>
</table> </table>
)} )}
<Pager <InfiniteListFooter
basePath="/outbox" sentinelRef={sentinelRef}
prevCursor={data.prev_cursor} hasMore={hasMore}
nextCursor={data.next_cursor} loading={loadingMore}
page={page} error={error}
extra={{ sort, direction, limit: String(limit) }} hasNewItems={hasNewItems}
onShowLatest={showLatest}
/> />
</> </>
); );
@@ -112,15 +114,16 @@ export function OutboxBrowser({
function outboxHref({ function outboxHref({
sort, sort,
direction, direction,
limit,
}: { }: {
sort: SortField; sort: SortField;
direction: SortDir; direction: SortDir;
limit: PageSize;
}) { }) {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
qs.set("sort", sort); qs.set("sort", sort);
qs.set("direction", direction); qs.set("direction", direction);
qs.set("limit", String(limit));
return `/outbox?${qs.toString()}`; return `/outbox?${qs.toString()}`;
} }
function outboxKey(item: OutboxItem): string {
return String(item.id);
}

View File

@@ -1,30 +0,0 @@
"use client";
import { PAGE_SIZE_OPTIONS, type PageSize } from "@/lib/pagination";
export function PageSizeSelect({
value,
onChange,
options = PAGE_SIZE_OPTIONS,
}: {
value: PageSize;
onChange: (size: PageSize) => void;
options?: readonly number[];
}) {
return (
<label className="inline-flex items-center gap-2 text-xs text-neutral-400">
<span>per page</span>
<select
value={value}
onChange={(e) => onChange(Number(e.target.value) as PageSize)}
className="border border-neutral-800 bg-neutral-950 px-2 py-1 text-sm text-neutral-100 outline-none focus:border-neutral-500"
>
{options.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
);
}

View File

@@ -1,61 +0,0 @@
import Link from "next/link";
export function Pager({
basePath,
prevCursor,
nextCursor,
page = 1,
extra = {},
}: {
basePath: string;
prevCursor?: string | null;
nextCursor?: string | null;
page?: number;
extra?: Record<string, string | undefined>;
}) {
const make = ({
nextPage,
nextPageCursor,
back,
}: {
nextPage: number;
nextPageCursor?: string;
back?: boolean;
}) => {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(extra)) if (v) sp.set(k, v);
if (nextPageCursor) sp.set("cursor", nextPageCursor);
if (back) sp.set("back", "1");
if (nextPage > 1) sp.set("page", String(nextPage));
const qs = sp.toString();
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 (
<div className="mt-4 flex justify-between text-sm">
{previousHref ? (
<Link href={previousHref} 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
</Link>
) : (
<span className="text-neutral-600">end</span>
)}
</div>
);
}

View File

@@ -1,10 +1,15 @@
"use client"; "use client";
import { createContext, useContext, useMemo, useState } from "react"; import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { DEFAULT_TIME_ZONE, fmtDate, normalizeTimeZone } from "@/lib/format"; import { DEFAULT_TIME_ZONE, fmtDate, normalizeTimeZone } from "@/lib/format";
import {
type TimeZoneMode = "configured" | "browser" | "utc"; normalizeTimeZoneMode,
TZ_BROWSER_COOKIE,
TZ_COOKIE_MAX_AGE_SEC,
TZ_MODE_COOKIE,
type TimeZoneMode,
} from "@/lib/timezone-preference";
const modes: TimeZoneMode[] = ["configured", "browser", "utc"]; const modes: TimeZoneMode[] = ["configured", "browser", "utc"];
@@ -20,14 +25,29 @@ const TimeZoneContext = createContext<TimeZoneContextValue | null>(null);
export function TimeZoneProvider({ export function TimeZoneProvider({
configuredTimeZone, configuredTimeZone,
initialMode,
initialBrowserTimeZone,
children, children,
}: { }: {
configuredTimeZone: string; configuredTimeZone: string;
initialMode: TimeZoneMode;
initialBrowserTimeZone: string | null;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const configured = normalizeTimeZone(configuredTimeZone); const configured = normalizeTimeZone(configuredTimeZone);
const [mode, setMode] = useState<TimeZoneMode>("configured"); const [mode, setMode] = useState<TimeZoneMode>(normalizeTimeZoneMode(initialMode));
const [browserTimeZone, setBrowserTimeZone] = useState<string | null>(null); const [browserTimeZone, setBrowserTimeZone] = useState<string | null>(
initialBrowserTimeZone ? normalizeTimeZone(initialBrowserTimeZone) : null,
);
useEffect(() => {
if (mode !== "browser") return;
const detected = normalizeTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
writeCookie(TZ_BROWSER_COOKIE, detected);
if (detected === browserTimeZone) return;
const id = window.setTimeout(() => setBrowserTimeZone(detected), 0);
return () => window.clearTimeout(id);
}, [browserTimeZone, mode]);
const value = useMemo<TimeZoneContextValue>(() => { const value = useMemo<TimeZoneContextValue>(() => {
const timeZone = const timeZone =
@@ -43,8 +63,13 @@ export function TimeZoneProvider({
timeZone, timeZone,
cycleMode: () => { cycleMode: () => {
const next = modes[(modes.indexOf(mode) + 1) % modes.length]; const next = modes[(modes.indexOf(mode) + 1) % modes.length];
writeCookie(TZ_MODE_COOKIE, next);
if (next === "browser" && browserTimeZone === null) { if (next === "browser" && browserTimeZone === null) {
setBrowserTimeZone(normalizeTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone)); const detected = normalizeTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
setBrowserTimeZone(detected);
writeCookie(TZ_BROWSER_COOKIE, detected);
} else if (next !== "browser") {
deleteCookie(TZ_BROWSER_COOKIE);
} }
setMode(next); setMode(next);
}, },
@@ -54,6 +79,15 @@ export function TimeZoneProvider({
return <TimeZoneContext.Provider value={value}>{children}</TimeZoneContext.Provider>; return <TimeZoneContext.Provider value={value}>{children}</TimeZoneContext.Provider>;
} }
function writeCookie(name: string, value: string) {
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=${TZ_COOKIE_MAX_AGE_SEC}${secure}`;
}
function deleteCookie(name: string) {
document.cookie = `${name}=; Path=/; SameSite=Lax; Max-Age=0`;
}
export function DateTime({ iso }: { iso?: string | null }) { export function DateTime({ iso }: { iso?: string | null }) {
const { timeZone } = useDisplayTimeZone(); const { timeZone } = useDisplayTimeZone();
return <time dateTime={iso ?? undefined}>{fmtDate(iso, timeZone)}</time>; return <time dateTime={iso ?? undefined}>{fmtDate(iso, timeZone)}</time>;

View File

@@ -18,7 +18,13 @@ const ZERO_SUMMARY: SystemSummaryData = {
generated_at: "", generated_at: "",
}; };
export function TopNav({ initialSummary }: { initialSummary: SystemSummaryData | null }) { export function TopNav({
initialSummary,
showLogout,
}: {
initialSummary: SystemSummaryData | null;
showLogout: boolean;
}) {
const { data } = usePolling(initialSummary ?? ZERO_SUMMARY, "/api/poll/system-summary"); const { data } = usePolling(initialSummary ?? ZERO_SUMMARY, "/api/poll/system-summary");
return ( return (
@@ -50,6 +56,16 @@ export function TopNav({ initialSummary }: { initialSummary: SystemSummaryData |
</Link> </Link>
</nav> </nav>
<TimeZoneCarousel /> <TimeZoneCarousel />
{showLogout ? (
<form action="/api/auth/logout" method="post">
<button
type="submit"
className="rounded border border-neutral-800 bg-neutral-950 px-2.5 py-1.5 text-xs text-neutral-300 hover:border-neutral-600 hover:text-neutral-100"
>
logout
</button>
</form>
) : null}
</header> </header>
); );
} }

View File

@@ -327,7 +327,7 @@ export interface components {
ErrorResponse: { ErrorResponse: {
error: { error: {
/** @enum {string} */ /** @enum {string} */
code: "unauthorized" | "forbidden" | "conflict" | "agent_blocked" | "not_found" | "validation" | "payload_too_large" | "rate_limited" | "internal" | "not_ready"; code: "unauthorized" | "forbidden" | "conflict" | "agent_blocked" | "agent_not_accepted" | "not_found" | "validation" | "payload_too_large" | "rate_limited" | "internal" | "not_ready";
message: string; message: string;
/** @description Server-generated request id, mirrored from X-Request-Id. */ /** @description Server-generated request id, mirrored from X-Request-Id. */
request_id: string; request_id: string;
@@ -704,7 +704,7 @@ export interface operations {
}; };
400: components["responses"]["ValidationError"]; 400: components["responses"]["ValidationError"];
401: components["responses"]["Unauthorized"]; 401: components["responses"]["Unauthorized"];
/** @description Agent key is blocked. */ /** @description Agent key is blocked (`agent_blocked`) or not yet accepted (`agent_not_accepted`). */
403: { 403: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;

View File

@@ -69,48 +69,30 @@ export async function loadEvents({
agentId, agentId,
checkId, checkId,
cursor, cursor,
back,
limit, limit,
}: { }: {
agentId?: string; agentId?: string;
checkId?: string; checkId?: string;
cursor?: string; cursor?: string;
back?: boolean;
limit?: number; limit?: number;
} = {}): Promise<{ items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null }> { } = {}): Promise<{ items: StoredEvent[]; next_cursor?: string | null }> {
return api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, back, limit }); return api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, limit });
} }
// PH-001: incidents are unbounded history. Always page through the server. // PH-001: incidents are unbounded history. Always page through the server.
export async function loadIncidentsPage( export async function loadIncidentsPage(
q: NonNullable<operations["listIncidents"]["parameters"]["query"]> = {}, q: NonNullable<operations["listIncidents"]["parameters"]["query"]> = {},
): Promise<{ items: Incident[]; next_cursor?: string | null; prev_cursor?: string | null }> { ): Promise<{ items: Incident[]; next_cursor?: string | null }> {
return api.listIncidents(q); return api.listIncidents(q);
} }
// PH-002: outbox is unbounded. Always page through the server. // PH-002: outbox is unbounded. Always page through the server.
export async function loadOutboxPage( export async function loadOutboxPage(
q: NonNullable<operations["listOutbox"]["parameters"]["query"]> = {}, q: NonNullable<operations["listOutbox"]["parameters"]["query"]> = {},
): Promise<{ items: OutboxItem[]; next_cursor?: string | null; prev_cursor?: string | null }> { ): Promise<{ items: OutboxItem[]; next_cursor?: string | null }> {
return api.listOutbox(q); return api.listOutbox(q);
} }
// First-page convenience loaders for SSR. Replace the previous drain-all paths
// (PH-001, PH-002): they returned the entire table on every initial render and
// did not scale past a few thousand rows. Pages now show one page and the UI
// owns subsequent fetches via /api/poll/* with cursors.
export async function loadIncidents(
state?: "open" | "resolved",
): Promise<Incident[]> {
const page = await api.listIncidents({ state, limit: 100 });
return page.items;
}
export async function loadOutboxAll(): Promise<OutboxItem[]> {
const page = await api.listOutbox({ limit: 100 });
return page.items;
}
export async function loadSystemSummary(): Promise<SystemSummaryData> { export async function loadSystemSummary(): Promise<SystemSummaryData> {
return api.getSystemSummary(); return api.getSystemSummary();
} }

View File

@@ -1,32 +1,6 @@
export const PAGE_SIZE_OPTIONS = [10, 50, 100] as const; export const INFINITE_BATCH_SIZE = 50;
export type PageSize = (typeof PAGE_SIZE_OPTIONS)[number];
export const DEFAULT_PAGE_SIZE: PageSize = 10;
export function pageCountOf(total: number, pageSize: number): number { export function normalizeFetchLimit(value: unknown, fallback = INFINITE_BATCH_SIZE): number {
return Math.max(1, Math.ceil(total / pageSize));
}
export function clampPage(page: number, pageCount: number): number {
if (page < 1) return 1;
if (page > pageCount) return pageCount;
return page;
}
export function paginate<T>(items: T[], page: number, pageSize: number): T[] {
const start = (page - 1) * pageSize;
return items.slice(start, start + pageSize);
}
export function normalizePageSize(value: unknown, fallback: PageSize = DEFAULT_PAGE_SIZE): PageSize {
const n = Number(value); const n = Number(value);
return (PAGE_SIZE_OPTIONS as readonly number[]).includes(n) ? (n as PageSize) : fallback; return Number.isFinite(n) ? Math.min(Math.max(Math.floor(n), 1), 500) : fallback;
}
export function normalizePageNumber(value: unknown): number {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : 1;
}
export function normalizeBack(value: unknown): boolean {
return value === "1" || value === "true";
} }

View File

@@ -0,0 +1,5 @@
export function redirectToLogin() {
const next = `${window.location.pathname}${window.location.search}`;
const params = new URLSearchParams({ next });
window.location.assign(`/login?${params.toString()}`);
}

View File

@@ -0,0 +1,9 @@
export type TimeZoneMode = "configured" | "browser" | "utc";
export const TZ_MODE_COOKIE = "monlet_tz_mode";
export const TZ_BROWSER_COOKIE = "monlet_tz_browser";
export const TZ_COOKIE_MAX_AGE_SEC = 31_536_000;
export function normalizeTimeZoneMode(value?: string | null): TimeZoneMode {
return value === "browser" || value === "utc" ? value : "configured";
}

View File

@@ -1,7 +1,32 @@
import "server-only"; import "server-only";
import { cookies } from "next/headers";
import { DEFAULT_TIME_ZONE, normalizeTimeZone } from "@/lib/format"; import { DEFAULT_TIME_ZONE, normalizeTimeZone } from "@/lib/format";
import {
normalizeTimeZoneMode,
TZ_BROWSER_COOKIE,
TZ_MODE_COOKIE,
type TimeZoneMode,
} from "@/lib/timezone-preference";
export function getUiTimeZone(): string { export function getUiTimeZone(): string {
return normalizeTimeZone(process.env.MONLET_WEB_TIME_ZONE ?? process.env.TZ ?? DEFAULT_TIME_ZONE); return normalizeTimeZone(process.env.MONLET_WEB_TIME_ZONE ?? process.env.TZ ?? DEFAULT_TIME_ZONE);
} }
export async function getInitialTimeZonePreference(): Promise<{
mode: TimeZoneMode;
browserTimeZone: string | null;
}> {
const store = await cookies();
const mode = normalizeTimeZoneMode(store.get(TZ_MODE_COOKIE)?.value);
const browserRaw = store.get(TZ_BROWSER_COOKIE)?.value;
const browserTimeZone = browserRaw ? normalizeTimeZone(browserRaw) : null;
return {
mode,
browserTimeZone:
browserTimeZone === DEFAULT_TIME_ZONE && browserRaw !== DEFAULT_TIME_ZONE
? null
: browserTimeZone,
};
}

View File

@@ -0,0 +1,235 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { redirectToLogin } from "@/lib/redirect-to-login";
import { usePolling } from "@/lib/usePolling";
export type CursorPage<T> = {
items: T[];
next_cursor?: string | null;
};
export function useInfiniteClientList<T>({
items,
batchSize,
resetKey,
}: {
items: T[];
batchSize: number;
resetKey: string;
}) {
const stateKey = `${resetKey}:${batchSize}`;
const [state, setState] = useState({ key: stateKey, visibleCount: batchSize });
const visibleCount = state.key === stateKey ? state.visibleCount : batchSize;
const hasMore = visibleCount < items.length;
const loadMore = useCallback(() => {
setState((current) => {
const currentCount = current.key === stateKey ? current.visibleCount : batchSize;
return {
key: stateKey,
visibleCount: Math.min(currentCount + batchSize, items.length),
};
});
}, [batchSize, items.length, stateKey]);
const sentinelRef = useInfiniteSentinel(hasMore, loadMore);
return {
visibleItems: items.slice(0, visibleCount),
hasMore,
sentinelRef,
shown: Math.min(visibleCount, items.length),
total: items.length,
};
}
export function useInfiniteCursorList<T>({
initialData,
firstPageUrl,
getPageUrl,
getKey,
resetKey,
}: {
initialData: CursorPage<T>;
firstPageUrl: string;
getPageUrl: (cursor: string) => string;
getKey: (item: T) => string;
resetKey: string;
}) {
const { data: firstPage, error, isStale, lastUpdatedAt } = usePolling(initialData, firstPageUrl);
const [windowState, setWindowState] = useState(() =>
initialWindowState(resetKey, initialData, getKey),
);
const [loadingMore, setLoadingMore] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const loadingMoreRef = useRef(false);
const abortRef = useRef<AbortController | null>(null);
const generationRef = useRef(0);
const nearTop = useNearTop();
const firstPageSignature = pageSignature(firstPage, getKey);
const activeWindow =
windowState.key === resetKey ? windowState : initialWindowState(resetKey, initialData, getKey);
// Persist the head rebase into state (single source of truth) so an in-flight
// loadMore cannot glue a stale tail onto an outdated base and revert a fresh
// head. Skip while a tail load is in flight: that request extends the current
// base and must resolve against it.
useEffect(() => {
if (loadingMoreRef.current) return;
setWindowState((current) => {
const w =
current.key === resetKey ? current : initialWindowState(resetKey, initialData, getKey);
if (w.tailItems.length === 0 && w.baseSignature !== firstPageSignature && nearTop) {
return initialWindowState(resetKey, firstPage, getKey);
}
return w;
});
}, [firstPageSignature, nearTop, resetKey, firstPage, initialData, getKey]);
const hasNewHead = activeWindow.baseSignature !== firstPageSignature;
const baseItems = activeWindow.baseItems;
const tailItems = activeWindow.tailItems;
const items = appendUnique(baseItems, tailItems, getKey);
const nextCursor = activeWindow.nextCursor;
const showLatest = useCallback(() => {
generationRef.current += 1;
abortRef.current?.abort();
abortRef.current = null;
loadingMoreRef.current = false;
setLoadingMore(false);
setLoadError(null);
setWindowState(initialWindowState(resetKey, firstPage, getKey));
if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "smooth" });
}, [firstPage, getKey, resetKey]);
const loadMore = useCallback(() => {
if (!nextCursor || loadingMoreRef.current) return;
loadingMoreRef.current = true;
setLoadingMore(true);
setLoadError(null);
const controller = new AbortController();
abortRef.current = controller;
const generation = generationRef.current;
const requestBaseSignature = activeWindow.baseSignature;
fetch(getPageUrl(nextCursor), { cache: "no-store", signal: controller.signal })
.then(async (response) => {
if (response.status === 401) {
redirectToLogin();
throw new Error("authentication required");
}
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.error?.message ?? response.statusText);
}
return response.json() as Promise<CursorPage<T>>;
})
.then((page) => {
if (generation !== generationRef.current) return;
setWindowState((current) => {
if (current.key !== resetKey) return current;
// The cursor was computed against requestBaseSignature. If a poll
// rebased the base in state meanwhile, gluing this tail onto a
// different base would skip the rows between the new head and the old
// cursor. Drop the response instead; the user reloads via showLatest.
if (current.baseSignature !== requestBaseSignature) return current;
return {
key: resetKey,
baseItems: current.baseItems,
baseSignature: current.baseSignature,
tailItems: appendUnique(current.tailItems, page.items, getKey),
nextCursor: page.next_cursor ?? null,
};
});
})
.catch((e: unknown) => {
if (generation === generationRef.current && !controller.signal.aborted) {
setLoadError(e instanceof Error ? e.message : String(e));
}
})
.finally(() => {
if (generation !== generationRef.current) return;
if (abortRef.current === controller) abortRef.current = null;
loadingMoreRef.current = false;
setLoadingMore(false);
});
}, [activeWindow, getKey, getPageUrl, nextCursor, resetKey]);
useEffect(() => () => abortRef.current?.abort(), []);
const sentinelRef = useInfiniteSentinel(Boolean(nextCursor), loadMore);
return {
items,
hasMore: Boolean(nextCursor),
loadingMore,
error: loadError ?? error,
isStale,
lastUpdatedAt,
sentinelRef,
hasNewItems: hasNewHead,
showLatest,
};
}
function useInfiniteSentinel(enabled: boolean, onIntersect: () => void) {
const ref = useRef<HTMLDivElement | null>(null);
const callbackRef = useRef(onIntersect);
useEffect(() => {
callbackRef.current = onIntersect;
}, [onIntersect]);
useEffect(() => {
const node = ref.current;
if (!enabled || !node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) callbackRef.current();
},
{ rootMargin: "360px 0px" },
);
observer.observe(node);
return () => observer.disconnect();
}, [enabled]);
return ref;
}
function appendUnique<T>(current: T[], next: T[], getKey: (item: T) => string): T[] {
const keys = new Set(current.map(getKey));
return [...current, ...next.filter((item) => !keys.has(getKey(item)))];
}
function initialWindowState<T>(key: string, page: CursorPage<T>, getKey: (item: T) => string) {
return {
key,
baseItems: page.items,
baseSignature: pageSignature(page, getKey),
tailItems: [] as T[],
nextCursor: page.next_cursor ?? null,
};
}
function pageSignature<T>(page: CursorPage<T>, getKey: (item: T) => string): string {
return `${page.next_cursor ?? ""}:${page.items.map(getKey).join("\u0000")}`;
}
function useNearTop() {
const [nearTop, setNearTop] = useState(true);
useEffect(() => {
const update = () => setNearTop(window.scrollY < 120);
const frame = window.requestAnimationFrame(update);
window.addEventListener("scroll", update, { passive: true });
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("scroll", update);
};
}, []);
return nearTop;
}

View File

@@ -2,6 +2,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { redirectToLogin } from "@/lib/redirect-to-login";
const DEFAULT_POLL_MS = 10_000; const DEFAULT_POLL_MS = 10_000;
const BACKOFF_AFTER_ERRORS = 2; const BACKOFF_AFTER_ERRORS = 2;
const BACKOFF_CAP_MS = 60_000; const BACKOFF_CAP_MS = 60_000;
@@ -53,6 +55,10 @@ export function usePolling<T>(initialData: T, url: string, intervalMs = pollInte
fetch(url, { cache: "no-store", signal: controller.signal }) fetch(url, { cache: "no-store", signal: controller.signal })
.then(async (response) => { .then(async (response) => {
if (response.status === 401) {
redirectToLogin();
throw new Error("authentication required");
}
if (!response.ok) { if (!response.ok) {
const body = await response.json().catch(() => null); const body = await response.json().catch(() => null);
throw new Error(body?.error?.message ?? response.statusText); throw new Error(body?.error?.message ?? response.statusText);

View File

@@ -8,13 +8,4 @@ export type InventoryData = {
checks: CheckState[]; checks: CheckState[];
}; };
export type SystemSummaryData = { export type SystemSummaryData = components["schemas"]["SystemSummaryResponse"];
agents_online: number;
agents_offline: number;
checks_ok: number;
checks_warning: number;
checks_critical: number;
checks_unknown: number;
open_incidents: number;
generated_at: string;
};

View File

@@ -0,0 +1,52 @@
import "server-only";
import { cookies, headers } from "next/headers";
import {
localAuthEnabled,
localAuthMisconfigured,
proxyAuthEnabled,
SESSION_COOKIE,
verifySessionToken,
} from "@/lib/web-auth";
export async function requestHasAppAccess(): Promise<boolean> {
if (localAuthMisconfigured()) return false;
if (proxyAuthEnabled()) return Boolean((await headers()).get("x-forwarded-user"));
if (!localAuthEnabled()) return true;
return verifySessionToken(
(await cookies()).get(SESSION_COOKIE)?.value,
process.env.MONLET_WEB_SESSION_SECRET ?? "",
process.env.MONLET_WEB_AUTH_USERNAME,
);
}
export function showLocalLogout(): boolean {
return localAuthEnabled() && !proxyAuthEnabled() && !localAuthMisconfigured();
}
// CSRF defense for state-changing routes. Prefer the Fetch Metadata signal;
// fall back to Origin/Host comparison for clients that omit Sec-Fetch-Site.
export function assertSameOrigin(req: Request): boolean {
const fetchSite = req.headers.get("sec-fetch-site");
// Strict same-origin: same-site would let a sibling subdomain pass the guard.
if (fetchSite) return fetchSite === "same-origin";
const origin = req.headers.get("origin");
if (!origin) return false;
let originHost: string;
try {
originHost = new URL(origin).host;
} catch {
return false;
}
return originHost === requestHost(req);
}
// Trust X-Forwarded-Host like cookieSecure() trusts X-Forwarded-Proto: the app
// is deployed behind a reverse proxy that terminates the public origin.
function requestHost(req: Request): string {
const forwarded = req.headers.get("x-forwarded-host");
if (forwarded) return forwarded.split(",")[0]!.trim();
return req.headers.get("host") ?? new URL(req.url).host;
}

170
web/src/lib/web-auth.ts Normal file
View File

@@ -0,0 +1,170 @@
export const SESSION_COOKIE = "monlet_session";
export const DEFAULT_SESSION_TTL_SEC = 604_800;
export const MIN_SESSION_SECRET_BYTES = 32;
const LOGIN_FAILURE_DELAY_MS = 350;
type SessionPayload = {
u: string;
exp: number;
};
export function localAuthEnabled(): boolean {
return Boolean(
process.env.MONLET_WEB_AUTH_USERNAME &&
process.env.MONLET_WEB_AUTH_PASSWORD &&
sessionSecretStrong(process.env.MONLET_WEB_SESSION_SECRET),
);
}
export function localAuthMisconfigured(): boolean {
const hasAny = Boolean(
process.env.MONLET_WEB_AUTH_USERNAME ||
process.env.MONLET_WEB_AUTH_PASSWORD ||
process.env.MONLET_WEB_SESSION_SECRET,
);
const enabled = localAuthEnabled();
return (hasAny && !enabled) || (proxyAuthEnabled() && enabled);
}
export function proxyAuthEnabled(): boolean {
return process.env.MONLET_WEB_TRUST_PROXY_AUTH === "true";
}
export function sessionSecretStrong(secret?: string): boolean {
return new TextEncoder().encode(secret ?? "").byteLength >= MIN_SESSION_SECRET_BYTES;
}
export function sessionTtlSec(): number {
const raw = Number(process.env.MONLET_WEB_SESSION_TTL_SEC);
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_SESSION_TTL_SEC;
}
export function sanitizeNext(value?: string | null): string {
const fallback = "/agents";
const raw = value?.trim();
if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\")) return fallback;
let decoded: string;
try {
decoded = decodeURIComponent(raw);
} catch {
return fallback;
}
if (
!decoded.startsWith("/") ||
decoded.startsWith("//") ||
decoded.includes("\\") ||
hasControlChar(decoded)
) {
return fallback;
}
let url: URL;
try {
url = new URL(raw, "https://monlet.local");
} catch {
return fallback;
}
if (url.origin !== "https://monlet.local") return fallback;
if (url.pathname === "/login" || url.pathname.startsWith("/api/auth/")) return fallback;
return `${url.pathname}${url.search}${url.hash}`;
}
export function cookieSecure(request: Request): boolean {
const forwarded = request.headers.get("x-forwarded-proto");
return forwarded === "https" || new URL(request.url).protocol === "https:";
}
export async function createSessionToken(username: string, secret: string, ttlSec: number) {
const payload: SessionPayload = {
u: username,
exp: Math.floor(Date.now() / 1000) + ttlSec,
};
const payloadBytes = new TextEncoder().encode(JSON.stringify(payload));
const signature = await sign(payloadBytes, secret);
return `${base64UrlEncode(payloadBytes)}.${base64UrlEncode(signature)}`;
}
export async function constantTimeEqualString(actual: string, expected: string): Promise<boolean> {
const [actualHash, expectedHash] = await Promise.all([sha256(actual), sha256(expected)]);
return equalBytes(actualHash, expectedHash);
}
export async function delayLoginFailure(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, LOGIN_FAILURE_DELAY_MS));
}
export async function verifySessionToken(
token: string | undefined,
secret: string,
expectedUsername?: string,
): Promise<boolean> {
if (!token) return false;
const [payloadPart, signaturePart] = token.split(".");
if (!payloadPart || !signaturePart) return false;
const payloadBytes = base64UrlDecode(payloadPart);
const signature = base64UrlDecode(signaturePart);
if (!payloadBytes || !signature) return false;
const expected = await sign(payloadBytes, secret);
if (!equalBytes(signature, expected)) return false;
try {
const payload = JSON.parse(new TextDecoder().decode(payloadBytes)) as SessionPayload;
return (
typeof payload.u === "string" &&
(!expectedUsername || payload.u === expectedUsername) &&
payload.exp > Math.floor(Date.now() / 1000)
);
} catch {
return false;
}
}
async function sign(payload: Uint8Array, secret: string): Promise<Uint8Array> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
return new Uint8Array(await crypto.subtle.sign("HMAC", key, arrayBufferOf(payload)));
}
async function sha256(value: string): Promise<Uint8Array> {
const bytes = new TextEncoder().encode(value);
return new Uint8Array(await crypto.subtle.digest("SHA-256", arrayBufferOf(bytes)));
}
function arrayBufferOf(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}
function equalBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
function hasControlChar(value: string): boolean {
return /[\u0000-\u001F\u007F]/.test(value);
}
function base64UrlEncode(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
function base64UrlDecode(value: string): Uint8Array | null {
try {
const padded = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(
Math.ceil(value.length / 4) * 4,
"=",
);
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
} catch {
return null;
}
}

View File

@@ -1,29 +1,44 @@
import type { NextRequest } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import {
localAuthEnabled,
localAuthMisconfigured,
proxyAuthEnabled,
sanitizeNext,
SESSION_COOKIE,
verifySessionToken,
} from "@/lib/web-auth";
const ACTION_PATH = "/api/agents/action"; const ACTION_PATH = "/api/agents/action";
const HEALTH_PATH = "/api/health"; const HEALTH_PATH = "/api/health";
const LOGIN_PATH = "/login";
const AUTH_PREFIX = "/api/auth/";
export const config = { export const config = {
matcher: "/((?!_next/static|_next/image|favicon.ico).*)", matcher: "/((?!_next/static|_next/image|favicon.ico).*)",
}; };
export function proxy(request: NextRequest) { export async function proxy(request: NextRequest) {
const path = request.nextUrl.pathname; const path = request.nextUrl.pathname;
if (path === HEALTH_PATH) return; if (path === HEALTH_PATH || path === LOGIN_PATH || path.startsWith(AUTH_PREFIX)) return;
const user = process.env.MONLET_WEB_AUTH_USERNAME; if (localAuthMisconfigured()) {
const password = process.env.MONLET_WEB_AUTH_PASSWORD; return new Response("web auth is misconfigured", { status: 500 });
if (user && password) {
if (validBasicAuth(request, user, password)) return;
return new Response("authentication required", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="monlet"' },
});
} }
if (process.env.MONLET_WEB_TRUST_PROXY_AUTH === "true") { if (proxyAuthEnabled()) {
if (request.headers.get("x-forwarded-user")) return; if (request.headers.get("x-forwarded-user")) return;
return new Response("proxy authentication required", { status: 401 }); return unauthorized(request, "proxy authentication required");
}
if (localAuthEnabled()) {
const ok = await verifySessionToken(
request.cookies.get(SESSION_COOKIE)?.value,
process.env.MONLET_WEB_SESSION_SECRET ?? "",
process.env.MONLET_WEB_AUTH_USERNAME,
);
if (ok) return;
return unauthorized(request, "authentication required");
} }
if (path === ACTION_PATH) { if (path === ACTION_PATH) {
@@ -34,15 +49,11 @@ export function proxy(request: NextRequest) {
} }
} }
function validBasicAuth(request: NextRequest, user: string, password: string): boolean { function unauthorized(request: NextRequest, message: string) {
const header = request.headers.get("authorization"); if (request.nextUrl.pathname.startsWith("/api/")) {
if (!header?.startsWith("Basic ")) return false; return Response.json({ error: { code: "unauthorized", message } }, { status: 401 });
try {
const raw = atob(header.slice("Basic ".length));
const split = raw.indexOf(":");
if (split < 0) return false;
return raw.slice(0, split) === user && raw.slice(split + 1) === password;
} catch {
return false;
} }
const login = new URL(LOGIN_PATH, request.url);
login.searchParams.set("next", sanitizeNext(`${request.nextUrl.pathname}${request.nextUrl.search}`));
return NextResponse.redirect(login, { status: 303 });
} }

View File

@@ -3,6 +3,60 @@ import { createServer } from "node:http";
const PORT = Number(process.env.MOCK_PORT ?? 8765); const PORT = Number(process.env.MOCK_PORT ?? 8765);
const now = new Date().toISOString(); const now = new Date().toISOString();
const eventItems = [
{
event_id: "00000000-0000-7000-8000-000000000001",
agent_id: "agent-1",
check_id: "disk",
observed_at: now,
received_at: now,
status: "ok",
exit_code: 0,
duration_ms: 12,
output: "disk ok",
output_truncated: false,
notifications_enabled: true,
},
{
event_id: "00000000-0000-7000-8000-000000000002",
agent_id: "agent-1",
check_id: "load",
observed_at: now,
received_at: now,
status: "warning",
exit_code: 1,
duration_ms: 34,
output: "load high",
output_truncated: false,
notifications_enabled: true,
},
{
event_id: "00000000-0000-7000-8000-000000000003",
agent_id: "agent-2",
check_id: "agent_liveness",
observed_at: now,
received_at: now,
status: "critical",
exit_code: 2,
duration_ms: 0,
output: "agent is dead",
output_truncated: false,
notifications_enabled: true,
},
...Array.from({ length: 12 }, (_, i) => ({
event_id: `00000000-0000-7000-8000-0000000001${String(i).padStart(2, "0")}`,
agent_id: "agent-1",
check_id: "disk",
observed_at: now,
received_at: now,
status: "ok",
exit_code: 0,
duration_ms: 10 + i,
output: `disk extra ${i + 1}`,
output_truncated: false,
notifications_enabled: true,
})),
];
const fixtures = { const fixtures = {
"/api/v1/health": { status: "ok" }, "/api/v1/health": { status: "ok" },
@@ -12,6 +66,7 @@ const fixtures = {
checks_ok: 2, checks_ok: 2,
checks_warning: 1, checks_warning: 1,
checks_critical: 1, checks_critical: 1,
checks_unknown: 0,
open_incidents: 2, open_incidents: 2,
generated_at: now, generated_at: now,
}, },
@@ -128,48 +183,8 @@ const fixtures = {
next_cursor: null, next_cursor: null,
}, },
"/api/v1/events/query": { "/api/v1/events/query": {
items: [ items: eventItems,
{ next_cursor: "10",
event_id: "00000000-0000-7000-8000-000000000001",
agent_id: "agent-1",
check_id: "disk",
observed_at: now,
received_at: now,
status: "ok",
exit_code: 0,
duration_ms: 12,
output: "disk ok",
output_truncated: false,
notifications_enabled: true,
},
{
event_id: "00000000-0000-7000-8000-000000000002",
agent_id: "agent-1",
check_id: "load",
observed_at: now,
received_at: now,
status: "warning",
exit_code: 1,
duration_ms: 34,
output: "load high",
output_truncated: false,
notifications_enabled: true,
},
{
event_id: "00000000-0000-7000-8000-000000000003",
agent_id: "agent-2",
check_id: "agent_liveness",
observed_at: now,
received_at: now,
status: "critical",
exit_code: 2,
duration_ms: 0,
output: "agent is dead",
output_truncated: false,
notifications_enabled: true,
},
],
next_cursor: null,
}, },
"/api/v1/notifiers/outbox": { "/api/v1/notifiers/outbox": {
items: [ items: [
@@ -205,15 +220,12 @@ const server = createServer((req, res) => {
if (url.pathname === "/api/v1/events/query" && body) { if (url.pathname === "/api/v1/events/query" && body) {
const agentId = url.searchParams.get("agent_id"); const agentId = url.searchParams.get("agent_id");
const checkId = url.searchParams.get("check_id"); const checkId = url.searchParams.get("check_id");
body = { const items = body.items.filter((item) => {
...body,
items: body.items.filter((item) => {
if (agentId && item.agent_id !== agentId) return false; if (agentId && item.agent_id !== agentId) return false;
if (checkId && item.check_id !== checkId) return false; if (checkId && item.check_id !== checkId) return false;
return true; return true;
}), });
next_cursor: null, body = paginate(body, items, url);
};
} }
if (url.pathname === "/api/v1/incidents" && body) { if (url.pathname === "/api/v1/incidents" && body) {
const state = url.searchParams.get("state"); const state = url.searchParams.get("state");
@@ -239,3 +251,16 @@ const server = createServer((req, res) => {
server.listen(PORT, "127.0.0.1", () => { server.listen(PORT, "127.0.0.1", () => {
console.log(`mock server on ${PORT}`); console.log(`mock server on ${PORT}`);
}); });
function paginate(body, items, url) {
const limit = Number(url.searchParams.get("limit") ?? 100);
const start = Number(url.searchParams.get("cursor") ?? 0);
const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 100;
const safeStart = Number.isFinite(start) && start > 0 ? start : 0;
const next = safeStart + safeLimit;
return {
...body,
items: items.slice(safeStart, next),
next_cursor: next < items.length ? String(next) : null,
};
}

View File

@@ -107,6 +107,14 @@ test("events page", async ({ page }) => {
await expect(page.getByRole("link", { name: /check_id=.*disk/ })).toBeVisible(); await expect(page.getByRole("link", { name: /check_id=.*disk/ })).toBeVisible();
}); });
test("events page loads the next cursor page on scroll", async ({ page }) => {
await page.goto("/events");
await expect(page.locator("tbody")).toContainText("disk extra 7");
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await expect(page.locator("tbody")).toContainText("disk extra 11");
await expect(page).toHaveURL(/\/events$/);
});
test("outbox page", async ({ page }) => { test("outbox page", async ({ page }) => {
await page.goto("/outbox"); await page.goto("/outbox");
await expect(page.locator("body")).toContainText("debug"); await expect(page.locator("body")).toContainText("debug");