Improve timestamp handling and event navigation

This commit is contained in:
Stanislav Rossovskii
2026-05-27 11:01:27 +04:00
parent edc51e9c59
commit 71f0035b0b
37 changed files with 485 additions and 109 deletions

View File

@@ -385,6 +385,7 @@ components:
observed_at:
type: string
format: date-time
description: UTC RFC3339 timestamp. Timezone is required; use `Z` or `+00:00`. Non-UTC offsets are rejected.
hostname:
type: string
maxLength: 253
@@ -456,6 +457,7 @@ components:
observed_at:
type: string
format: date-time
description: UTC RFC3339 timestamp. Timezone is required; use `Z` or `+00:00`. Non-UTC offsets are rejected.
status:
type: string
enum: [ok, warning, critical, unknown]

View File

@@ -10,6 +10,8 @@ MONLET_AUTH_TOKEN=$(openssl rand -hex 16) docker compose up --build
```
Brings up: PostgreSQL 16, Monlet server (with Alembic upgrade on start), Web UI.
Web timestamps use `MONLET_WEB_TIME_ZONE` (`UTC` by default); notification text/annotations use `MONLET_NOTIFICATION_TIME_ZONE` (`UTC` by default).
PostgreSQL and server containers run with `TZ=UTC`; local presentation timezones are configured separately.
- Server: http://127.0.0.1:8000 (`/api/v1/health`, `/api/v1/ready`, `/metrics`)
- Web UI: http://127.0.0.1:3000

View File

@@ -5,6 +5,7 @@ services:
POSTGRES_USER: monlet
POSTGRES_PASSWORD: monlet
POSTGRES_DB: monlet
TZ: UTC
healthcheck:
test: ["CMD-SHELL", "pg_isready -U monlet -d monlet"]
interval: 5s
@@ -27,7 +28,9 @@ services:
MONLET_LOG_LEVEL: ${MONLET_LOG_LEVEL:-INFO}
MONLET_ENABLE_DETECTOR: "true"
MONLET_ENABLE_NOTIFIER_WORKER: "true"
MONLET_NOTIFICATION_TIME_ZONE: ${MONLET_NOTIFICATION_TIME_ZONE:-UTC}
MONLET_NOTIFIER_DEBUG_ENABLED: "true"
TZ: UTC
command:
- sh
- -c
@@ -49,6 +52,8 @@ services:
environment:
MONLET_API_BASE_URL: http://server:8000
MONLET_API_TOKEN: ${MONLET_AUTH_TOKEN:?MONLET_AUTH_TOKEN is required}
MONLET_WEB_TIME_ZONE: ${MONLET_WEB_TIME_ZONE:-UTC}
TZ: ${MONLET_WEB_TIME_ZONE:-UTC}
ports:
- "3000:3000"

View File

@@ -13,6 +13,7 @@ A pragmatic list for the first production-ish Monlet stand-up. Adjust per enviro
- [ ] Provision PostgreSQL 16+.
- [ ] Create `monlet` database and role with `CREATE`/`USAGE` on the schema.
- [ ] Set `MONLET_DATABASE_URL=postgresql+asyncpg://...`.
- [ ] Keep database/server runtime timezone at UTC; only UI/notifier presentation should use local timezones.
- [ ] Run `alembic upgrade head` from a one-shot container or the server image.
- [ ] Verify `\dt` shows `agents, checks, events, incidents, notification_outbox`.
@@ -39,7 +40,8 @@ A pragmatic list for the first production-ish Monlet stand-up. Adjust per enviro
## 6. Web UI
- [ ] Set `MONLET_API_BASE_URL` and `MONLET_API_TOKEN` for the web container.
- [ ] Set `MONLET_API_BASE_URL`, `MONLET_API_TOKEN`, and optional `MONLET_WEB_TIME_ZONE` for the web container.
- [ ] Set optional `MONLET_NOTIFICATION_TIME_ZONE` for human-readable notifier timestamps.
- [ ] Browse `/`, `/agents`, `/checks`, `/incidents`, `/events`, `/outbox`.
- [ ] Verify the web image does not log the token (`docker logs` greps clean).

View File

@@ -16,6 +16,7 @@ MONLET_NOTIFIER_BATCH_SIZE=20
MONLET_NOTIFIER_MAX_ATTEMPTS=8
MONLET_NOTIFIER_LEASE_SEC=300
MONLET_NOTIFIER_HTTP_TIMEOUT_SEC=10
MONLET_NOTIFICATION_TIME_ZONE=UTC
MONLET_NOTIFIER_DEBUG_ENABLED=true
MONLET_NOTIFIER_TELEGRAM_ENABLED=false
MONLET_NOTIFIER_TELEGRAM_TOKEN=

View File

@@ -47,6 +47,7 @@ UV_PROJECT_ENVIRONMENT=.venv UV_CACHE_DIR=../.cache/uv uv run fastapi dev monlet
| `MONLET_NOTIFIER_MAX_ATTEMPTS` | `8` | Terminal-failure threshold (ADR-0005) |
| `MONLET_NOTIFIER_LEASE_SEC` | `300` | Recovery lease for stuck `sending` rows |
| `MONLET_NOTIFIER_HTTP_TIMEOUT_SEC` | `10` | Outbound HTTP timeout |
| `MONLET_NOTIFICATION_TIME_ZONE` | `UTC` | IANA timezone for human-readable notification timestamps |
| `MONLET_NOTIFIER_DEBUG_ENABLED` | `true` | Log-only notifier |
| `MONLET_NOTIFIER_TELEGRAM_ENABLED` | `false` | Telegram notifier on/off |
| `MONLET_NOTIFIER_TELEGRAM_TOKEN` | `` | Telegram bot token |

View File

@@ -6,6 +6,7 @@ from ..cursors import decode, encode
from ..db import get_session
from ..models import Agent as AgentModel
from ..schemas import Agent, AgentsPage
from ..timeutil import to_utc
router = APIRouter()
@@ -15,7 +16,7 @@ def _to_schema(a: AgentModel) -> Agent:
agent_id=a.agent_id,
hostname=a.hostname,
status=a.status,
last_seen_at=a.last_seen_at,
last_seen_at=to_utc(a.last_seen_at),
features=a.features or {"push": False, "metrics": False},
labels=a.labels or {},
)

View File

@@ -6,6 +6,7 @@ from ..cursors import decode, encode
from ..db import get_session
from ..models import Check
from ..schemas import ID_PATTERN, ChecksPage, CheckState
from ..timeutil import to_utc
router = APIRouter()
@@ -38,7 +39,7 @@ async def list_checks(
agent_id=r.agent_id,
check_id=r.check_id,
status=r.status,
last_observed_at=r.last_observed_at,
last_observed_at=to_utc(r.last_observed_at),
exit_code=r.exit_code,
incident_key=r.incident_key,
)

View File

@@ -16,12 +16,13 @@ from ..schemas import (
StoredCheckResultEvent,
)
from ..services.ingestion import ingest_event
from ..timeutil import to_utc
router = APIRouter()
def _encode_cursor(received_at: datetime, event_id) -> str:
raw = f"{received_at.isoformat()}|{event_id}".encode()
raw = f"{to_utc(received_at).isoformat()}|{event_id}".encode()
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
@@ -30,7 +31,7 @@ def _decode_cursor(cursor: str) -> tuple[datetime, str]:
try:
raw = base64.urlsafe_b64decode(cursor + pad).decode()
ts_str, eid = raw.split("|", 1)
return datetime.fromisoformat(ts_str), eid
return to_utc(datetime.fromisoformat(ts_str)), eid
except (binascii.Error, ValueError) as exc:
raise HTTPException(status_code=400, detail="invalid cursor") from exc
@@ -87,8 +88,8 @@ async def query_events(
event_id=str(r.event_id),
agent_id=r.agent_id,
check_id=r.check_id,
observed_at=r.observed_at,
received_at=r.received_at,
observed_at=to_utc(r.observed_at),
received_at=to_utc(r.received_at),
status=r.status,
exit_code=r.exit_code,
duration_ms=r.duration_ms,

View File

@@ -6,6 +6,7 @@ from ..cursors import decode, decode_datetime, encode
from ..db import get_session
from ..models import Incident as IncidentModel
from ..schemas import Incident, IncidentsPage
from ..timeutil import to_utc
router = APIRouter()
@@ -18,8 +19,8 @@ def _to_schema(i: IncidentModel) -> Incident:
severity=i.severity,
agent_id=i.agent_id,
check_id=i.check_id,
opened_at=i.opened_at,
resolved_at=i.resolved_at,
opened_at=to_utc(i.opened_at),
resolved_at=to_utc(i.resolved_at) if i.resolved_at else None,
summary=i.summary,
)
@@ -49,5 +50,5 @@ async def list_incidents(
next_cursor = None
if len(rows) > limit:
rows = rows[:limit]
next_cursor = encode(rows[-1].opened_at.isoformat(), rows[-1].id)
next_cursor = encode(to_utc(rows[-1].opened_at).isoformat(), rows[-1].id)
return IncidentsPage(items=[_to_schema(r) for r in rows], next_cursor=next_cursor)

View File

@@ -6,6 +6,7 @@ from ..cursors import decode, decode_datetime, encode
from ..db import get_session
from ..models import NotificationOutbox
from ..schemas import NotificationOutboxItem, OutboxPage
from ..timeutil import to_utc
router = APIRouter()
@@ -38,7 +39,7 @@ async def list_outbox(
next_cursor = None
if len(rows) > limit:
rows = rows[:limit]
next_cursor = encode(rows[-1].created_at.isoformat(), rows[-1].id)
next_cursor = encode(to_utc(rows[-1].created_at).isoformat(), rows[-1].id)
return OutboxPage(
items=[
NotificationOutboxItem(
@@ -48,9 +49,9 @@ async def list_outbox(
incident_id=str(r.incident_id),
event_type=r.event_type,
attempts=r.attempts,
next_attempt_at=r.next_attempt_at,
next_attempt_at=to_utc(r.next_attempt_at) if r.next_attempt_at else None,
last_error=r.last_error,
updated_at=r.updated_at,
updated_at=to_utc(r.updated_at),
)
for r in rows
],

View File

@@ -5,6 +5,8 @@ from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, field_validator
from .timeutil import require_utc_datetime
ID_PATTERN = r"^[A-Za-z0-9._:-]+$"
UUIDV7_PATTERN = r"^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$"
ID_RE = re.compile(ID_PATTERN)
@@ -80,6 +82,11 @@ class HeartbeatRequest(BaseModel):
features: AgentFeatures
labels: dict[str, str] = Field(default_factory=dict)
@field_validator("observed_at")
@classmethod
def _observed_at_must_be_utc(cls, v: datetime) -> datetime:
return require_utc_datetime(v)
@field_validator("labels")
@classmethod
def _check_labels(cls, v: dict[str, str]) -> dict[str, str]:
@@ -109,6 +116,11 @@ class CheckResultEvent(BaseModel):
notifications_enabled: bool = True
incident_key: str | None = Field(default=None, max_length=256)
@field_validator("observed_at")
@classmethod
def _observed_at_must_be_utc(cls, v: datetime) -> datetime:
return require_utc_datetime(v)
@field_validator("output")
@classmethod
def _check_output_bytes(cls, v: str | None) -> str | None:

View File

@@ -12,6 +12,7 @@ from ..models import Agent, Check, Event, Incident, NotificationOutbox
from ..redaction import redact
from ..schemas import CheckResultEvent, HeartbeatRequest
from ..settings import get_settings
from ..timeutil import to_utc
INCIDENT_KEY_MAX = 256
@@ -37,11 +38,7 @@ def _incident_key(agent_id: str, ev: CheckResultEvent) -> str:
async def upsert_agent_heartbeat(session: AsyncSession, hb: HeartbeatRequest) -> None:
observed = (
hb.observed_at.astimezone(UTC)
if hb.observed_at.tzinfo
else hb.observed_at.replace(tzinfo=UTC)
)
observed = to_utc(hb.observed_at)
stmt = pg_insert(Agent).values(
agent_id=hb.agent_id,
hostname=hb.hostname,
@@ -71,7 +68,7 @@ async def _apply_incident(
incident_key: str,
) -> tuple[bool, Incident | None]:
"""Return (transition, incident). transition True if open->resolved or none->open."""
observed = ev.observed_at
observed = to_utc(ev.observed_at)
if ev.status == "ok":
res = await session.execute(
select(Incident).where(Incident.incident_key == incident_key, Incident.state == "open")
@@ -159,7 +156,7 @@ async def ingest_event(
ev: CheckResultEvent,
) -> bool:
"""Return True if accepted, False if deduplicated."""
observed = ev.observed_at
observed = to_utc(ev.observed_at)
output = redact(ev.output)
incident_key = _incident_key(agent_id, ev)

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import httpx
from ...redaction import redact
from ...timeutil import payload_with_display_times
from .base import DeliveryResult
from .http import classify_response
@@ -10,11 +11,13 @@ from .http import classify_response
class AlertmanagerNotifier:
name = "alertmanager"
def __init__(self, client: httpx.AsyncClient, url: str) -> None:
def __init__(self, client: httpx.AsyncClient, url: str, time_zone: str) -> None:
self._client = client
self._url = url.rstrip("/")
self._time_zone = time_zone
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult:
display_payload = payload_with_display_times(payload, self._time_zone)
labels = {
"alertname": "monlet_incident",
"agent_id": str(payload.get("agent_id", "")),
@@ -25,7 +28,11 @@ class AlertmanagerNotifier:
"severity": str(payload.get("severity", "unknown")),
"status": str(payload.get("status", "")),
"summary": redact(payload.get("summary") or "") or "",
"time_zone": display_payload["time_zone"],
}
for key in ("observed_at_display", "opened_at_display", "resolved_at_display"):
if display_payload.get(key):
annotations[key.removesuffix("_display")] = display_payload[key]
if payload.get("output"):
annotations["output"] = (redact(payload["output"]) or "")[:1024]
alert = {

View File

@@ -1,14 +1,20 @@
from ...logging_config import get_logger
from ...redaction import redact_dict
from ...timeutil import payload_with_display_times
from .base import DeliveryResult
class DebugNotifier:
name = "debug"
def __init__(self) -> None:
def __init__(self, time_zone: str = "UTC") -> None:
self._log = get_logger("monlet.notifier.debug")
self._time_zone = time_zone
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult:
self._log.info("notify", event_type=event_type, payload=redact_dict(payload))
self._log.info(
"notify",
event_type=event_type,
payload=redact_dict(payload_with_display_times(payload, self._time_zone)),
)
return DeliveryResult(ok=True)

View File

@@ -13,19 +13,27 @@ from .webhook import WebhookNotifier
def build_registry(settings: Settings, client: httpx.AsyncClient) -> dict[str, Notifier]:
reg: dict[str, Notifier] = {}
if settings.notifier_debug_enabled:
reg["debug"] = DebugNotifier()
reg["debug"] = DebugNotifier(settings.notification_time_zone)
if (
settings.notifier_telegram_enabled
and settings.notifier_telegram_token
and settings.notifier_telegram_chat_id
):
reg["telegram"] = TelegramNotifier(
client, settings.notifier_telegram_token, settings.notifier_telegram_chat_id
client,
settings.notifier_telegram_token,
settings.notifier_telegram_chat_id,
settings.notification_time_zone,
)
if settings.notifier_webhook_enabled and settings.notifier_webhook_url:
reg["webhook"] = WebhookNotifier(
client, settings.notifier_webhook_url, settings.notifier_webhook_token
client,
settings.notifier_webhook_url,
settings.notifier_webhook_token,
settings.notification_time_zone,
)
if settings.notifier_alertmanager_enabled and settings.notifier_alertmanager_url:
reg["alertmanager"] = AlertmanagerNotifier(client, settings.notifier_alertmanager_url)
reg["alertmanager"] = AlertmanagerNotifier(
client, settings.notifier_alertmanager_url, settings.notification_time_zone
)
return reg

View File

@@ -3,11 +3,13 @@ from __future__ import annotations
import httpx
from ...redaction import redact
from ...timeutil import payload_with_display_times
from .base import DeliveryResult
from .http import classify_response
def _format_message(event_type: str, payload: dict) -> str:
def _format_message(event_type: str, payload: dict, time_zone: str) -> str:
payload = payload_with_display_times(payload, time_zone)
icon = "[FIRING]" if event_type == "firing" else "[RESOLVED]"
sev = payload.get("severity", "unknown")
agent = payload.get("agent_id", "?")
@@ -19,7 +21,7 @@ def _format_message(event_type: str, payload: dict) -> str:
f"{icon} {sev} {agent}/{check}",
f"status: {status}",
f"incident_key: {incident_key}",
f"observed_at: {payload.get('observed_at', '?')}",
f"observed_at: {payload.get('observed_at_display') or payload.get('observed_at', '?')}",
]
if output:
lines.append(f"output: {output[:300]}")
@@ -29,14 +31,18 @@ def _format_message(event_type: str, payload: dict) -> str:
class TelegramNotifier:
name = "telegram"
def __init__(self, client: httpx.AsyncClient, token: str, chat_id: str) -> None:
def __init__(self, client: httpx.AsyncClient, token: str, chat_id: str, time_zone: str) -> None:
self._client = client
self._token = token
self._chat_id = chat_id
self._time_zone = time_zone
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult:
url = f"https://api.telegram.org/bot{self._token}/sendMessage"
body = {"chat_id": self._chat_id, "text": _format_message(event_type, payload)}
body = {
"chat_id": self._chat_id,
"text": _format_message(event_type, payload, self._time_zone),
}
try:
resp = await self._client.post(url, json=body)
except httpx.HTTPError as exc:

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
import httpx
from ...redaction import redact_dict
from ...timeutil import payload_with_display_times
from .base import DeliveryResult
from .http import classify_response
@@ -10,16 +11,22 @@ from .http import classify_response
class WebhookNotifier:
name = "webhook"
def __init__(self, client: httpx.AsyncClient, url: str, token: str = "") -> None:
def __init__(
self, client: httpx.AsyncClient, url: str, token: str = "", time_zone: str = "UTC"
) -> None:
self._client = client
self._url = url
self._token = token
self._time_zone = time_zone
async def deliver(self, event_type: str, payload: dict) -> DeliveryResult:
headers = {"Content-Type": "application/json"}
if self._token:
headers["Authorization"] = f"Bearer {self._token}"
body = {"event_type": event_type, "incident": redact_dict(payload)}
body = {
"event_type": event_type,
"incident": redact_dict(payload_with_display_times(payload, self._time_zone)),
}
try:
resp = await self._client.post(self._url, json=body, headers=headers)
except httpx.HTTPError as exc:

View File

@@ -1,6 +1,8 @@
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from .timeutil import validate_time_zone
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="MONLET_", env_file=".env", extra="ignore")
@@ -25,6 +27,7 @@ class Settings(BaseSettings):
notifier_max_attempts: int = 8
notifier_lease_sec: int = 300
notifier_http_timeout_sec: float = 10.0
notification_time_zone: str = "UTC"
notifier_debug_enabled: bool = True
notifier_telegram_enabled: bool = False
@@ -43,6 +46,11 @@ class Settings(BaseSettings):
raise ValueError("MONLET_AUTH_TOKEN must be set to a non-default value")
return value
@field_validator("notification_time_zone")
@classmethod
def _validate_notification_time_zone(cls, value: str) -> str:
return validate_time_zone(value)
@property
def enabled_notifiers(self) -> list[str]:
out: list[str] = []

View File

@@ -0,0 +1,53 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
DEFAULT_TIME_ZONE = "UTC"
_TIMESTAMP_KEYS = ("observed_at", "opened_at", "resolved_at")
def validate_time_zone(value: str) -> str:
name = value.strip() or DEFAULT_TIME_ZONE
try:
ZoneInfo(name)
except ZoneInfoNotFoundError as exc:
raise ValueError(f"invalid timezone: {name}") from exc
return name
def require_utc_datetime(value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
raise ValueError("timestamp must include UTC timezone")
if value.utcoffset() != timedelta(0):
raise ValueError("timestamp must be UTC")
return value.astimezone(UTC)
def to_utc(value: datetime) -> datetime:
if value.tzinfo is None or value.utcoffset() is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC)
def format_display_datetime(value: str | datetime | None, time_zone: str) -> str:
if not value:
return ""
if isinstance(value, datetime):
dt = value
else:
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
return (
to_utc(dt)
.astimezone(ZoneInfo(validate_time_zone(time_zone)))
.strftime("%Y-%m-%d %H:%M:%S %Z")
)
def payload_with_display_times(payload: dict, time_zone: str) -> dict:
out = dict(payload)
out["time_zone"] = validate_time_zone(time_zone)
for key in _TIMESTAMP_KEYS:
if payload.get(key):
out[f"{key}_display"] = format_display_datetime(payload[key], out["time_zone"])
return out

View File

@@ -43,6 +43,18 @@ async def test_empty_batch_400(app_client, auth_headers):
assert r.status_code == 400
@pytest.mark.asyncio
@pytest.mark.parametrize("observed_at", ["2026-05-27T09:00:00", "2026-05-27T09:00:00+04:00"])
async def test_event_requires_utc_timestamp(app_client, auth_headers, observed_at):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
ev = make_event(observed_at=observed_at)
r = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
)
assert r.status_code == 400
assert r.json()["error"]["code"] == "validation"
@pytest.mark.asyncio
async def test_oversize_batch_400(app_client, auth_headers):
evs = [make_event() for _ in range(201)]

View File

@@ -43,6 +43,16 @@ async def test_heartbeat_validation_400(app_client, auth_headers):
assert r.json()["error"]["code"] == "validation"
@pytest.mark.asyncio
@pytest.mark.parametrize("observed_at", ["2026-05-27T09:00:00", "2026-05-27T09:00:00+04:00"])
async def test_heartbeat_requires_utc_timestamp(app_client, auth_headers, observed_at):
hb = make_heartbeat()
hb["observed_at"] = observed_at
r = await app_client.post("/api/v1/heartbeat", json=hb, headers=auth_headers)
assert r.status_code == 400
assert r.json()["error"]["code"] == "validation"
@pytest.mark.asyncio
async def test_heartbeat_rejects_extra_fields(app_client, auth_headers):
hb = make_heartbeat()

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import json
import os
from datetime import UTC, datetime, timedelta
from uuid import uuid4
@@ -27,8 +28,8 @@ def _payload() -> dict:
"status": "critical",
"severity": "critical",
"incident_key": "agent-1:chk-1",
"observed_at": datetime.now(UTC).isoformat(),
"opened_at": datetime.now(UTC).isoformat(),
"observed_at": "2026-05-27T05:00:00+00:00",
"opened_at": "2026-05-27T05:00:00+00:00",
"summary": "boom",
"output": "stderr: boom token=abc123",
}
@@ -60,12 +61,13 @@ async def test_telegram_success_and_redaction():
def handler(req):
body = req.content.decode()
assert "token=abc123" not in body
assert "2026-05-27 09:00:00 +04" in body
assert "***" in body
return httpx.Response(200, json={"ok": True})
client, t = _client(handler)
try:
n = TelegramNotifier(client, "TOK", "123")
n = TelegramNotifier(client, "TOK", "123", "Asia/Tbilisi")
res = await n.deliver("firing", _payload())
assert res.ok
assert "/botTOK/sendMessage" in str(t.requests[0].url)
@@ -77,7 +79,7 @@ async def test_telegram_success_and_redaction():
async def test_telegram_4xx_permanent():
client, _ = _client(lambda r: httpx.Response(400, json={}))
try:
n = TelegramNotifier(client, "TOK", "123")
n = TelegramNotifier(client, "TOK", "123", "UTC")
res = await n.deliver("firing", _payload())
assert not res.ok and res.permanent
finally:
@@ -88,7 +90,7 @@ async def test_telegram_4xx_permanent():
async def test_telegram_5xx_transient():
client, _ = _client(lambda r: httpx.Response(503, json={}))
try:
n = TelegramNotifier(client, "TOK", "123")
n = TelegramNotifier(client, "TOK", "123", "UTC")
res = await n.deliver("firing", _payload())
assert not res.ok and not res.permanent
finally:
@@ -102,7 +104,7 @@ async def test_telegram_network_error_transient():
client, _ = _client(handler)
try:
n = TelegramNotifier(client, "TOK", "123")
n = TelegramNotifier(client, "TOK", "123", "UTC")
res = await n.deliver("firing", _payload())
assert not res.ok and not res.permanent
finally:
@@ -140,11 +142,15 @@ async def test_alertmanager_payload_shape():
client, _ = _client(handler)
try:
n = AlertmanagerNotifier(client, "http://am/")
n = AlertmanagerNotifier(client, "http://am/", "Asia/Tbilisi")
res = await n.deliver("firing", _payload())
assert res.ok
assert captured["url"].endswith("/api/v2/alerts")
assert '"alertname":"monlet_incident"' in captured["body"]
body = json.loads(captured["body"])
alert = body[0]
assert alert["labels"]["alertname"] == "monlet_incident"
assert alert["startsAt"] == "2026-05-27T05:00:00+00:00"
assert alert["annotations"]["observed_at"] == "2026-05-27 09:00:00 +04"
finally:
await client.aclose()
@@ -348,7 +354,7 @@ async def test_alertmanager_severity_not_in_labels():
client, _ = _client(handler)
try:
n = AlertmanagerNotifier(client, "http://am")
n = AlertmanagerNotifier(client, "http://am", "UTC")
await n.deliver("firing", _payload())
import json as _json
@@ -371,7 +377,7 @@ async def test_telegram_redacts_secret_in_incident_key():
try:
p = _payload()
p["incident_key"] = "agent-1:chk-1:token=leakedsecret"
n = TelegramNotifier(client, "TOK", "1")
n = TelegramNotifier(client, "TOK", "1", "UTC")
await n.deliver("firing", p)
assert "leakedsecret" not in captured["body"]
finally:

View File

@@ -13,7 +13,9 @@ FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0
HOSTNAME=0.0.0.0 \
MONLET_WEB_TIME_ZONE=UTC \
TZ=UTC
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules

View File

@@ -35,6 +35,7 @@ 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_TOKEN` | _(none)_ | Bearer token sent to server |
| `MONLET_WEB_TIME_ZONE` | `UTC` | IANA timezone used to render timestamps |
The token never reaches the browser — all server calls happen in Server Components / route handlers.

View File

@@ -1,10 +1,12 @@
import Link from "next/link";
import { AgentFeatures } from "@/components/AgentFeatures";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { ErrorPanel, Td, Th } from "@/components/DataPanel";
import { LabelChips } from "@/components/Labels";
import { DateTime } from "@/components/TimeZoneControls";
import { ApiError, type Agent, type CheckState, type StoredEvent, api } from "@/lib/api";
import { fmtDate, statusBadgeClass } from "@/lib/format";
import { statusBadgeClass } from "@/lib/format";
export const dynamic = "force-dynamic";
@@ -93,7 +95,9 @@ export default async function AgentDetailPage({
<dt className="text-neutral-400">status</dt>
<dd className={statusBadgeClass(agent.status)}>{agent.status}</dd>
<dt className="text-neutral-400">last_seen_at</dt>
<dd>{fmtDate(agent.last_seen_at)}</dd>
<dd>
<DateTime iso={agent.last_seen_at} />
</dd>
<dt className="text-neutral-400">features</dt>
<dd>
<AgentFeatures features={agent.features} />
@@ -104,11 +108,7 @@ export default async function AgentDetailPage({
</dd>
</dl>
<TabNav agentId={agent.agent_id} current={tab} sort={sort} />
{tab === "checks" ? (
<ChecksTable agentId={agent.agent_id} checks={checks} sort={sort} />
) : (
<EventsTable events={events} />
)}
{tab === "checks" ? <ChecksTable agentId={agent.agent_id} checks={checks} sort={sort} /> : <EventsTable events={events} />}
</div>
);
}
@@ -169,10 +169,14 @@ function ChecksTable({
<tbody>
{checks.map((c) => (
<tr key={c.check_id}>
<Td>{c.check_id}</Td>
<Td>
<CheckEventsLink agentId={agentId} checkId={c.check_id} />
</Td>
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
<Td>{c.exit_code ?? "—"}</Td>
<Td>{fmtDate(c.last_observed_at)}</Td>
<Td>
<DateTime iso={c.last_observed_at} />
</Td>
</tr>
))}
</tbody>
@@ -197,8 +201,12 @@ function EventsTable({ events }: { events: StoredEvent[] }) {
<tbody>
{events.map((e) => (
<tr key={e.event_id}>
<Td>{fmtDate(e.observed_at)}</Td>
<Td>{e.check_id}</Td>
<Td>
<DateTime iso={e.observed_at} />
</Td>
<Td>
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
</Td>
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
<Td>{e.duration_ms}</Td>
</tr>

View File

@@ -1,9 +1,11 @@
import Link from "next/link";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { Pager } from "@/components/Pager";
import { DateTime } from "@/components/TimeZoneControls";
import { api } from "@/lib/api";
import { fmtDate, statusBadgeClass } from "@/lib/format";
import { statusBadgeClass } from "@/lib/format";
export const dynamic = "force-dynamic";
@@ -47,10 +49,14 @@ export default async function ChecksPage({
{c.agent_id}
</Link>
</Td>
<Td>{c.check_id}</Td>
<Td>
<CheckEventsLink agentId={c.agent_id} checkId={c.check_id} />
</Td>
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
<Td>{c.exit_code ?? "—"}</Td>
<Td>{fmtDate(c.last_observed_at)}</Td>
<Td>
<DateTime iso={c.last_observed_at} />
</Td>
<Td className="text-xs text-neutral-400">{c.incident_key ?? "—"}</Td>
</tr>
))}

View File

@@ -1,7 +1,11 @@
import Link from "next/link";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { Pager } from "@/components/Pager";
import { DateTime } from "@/components/TimeZoneControls";
import { api } from "@/lib/api";
import { fmtDate, statusBadgeClass } from "@/lib/format";
import { statusBadgeClass } from "@/lib/format";
export const dynamic = "force-dynamic";
@@ -24,20 +28,7 @@ export default async function EventsPage({
return (
<>
<PageHeader title="Events" count={data.items.length} />
<form
action="/events"
method="get"
className="mb-4 flex flex-wrap gap-3 text-sm items-end"
>
<Field label="agent_id" name="agent_id" defaultValue={sp.agent_id} />
<Field label="check_id" name="check_id" defaultValue={sp.check_id} />
<button
type="submit"
className="border border-neutral-700 px-3 py-1 hover:border-neutral-500"
>
query
</button>
</form>
<ActiveFilters agentId={sp.agent_id} checkId={sp.check_id} />
{data.items.length === 0 ? (
<EmptyPanel />
) : (
@@ -57,10 +48,16 @@ export default async function EventsPage({
<tbody>
{data.items.map((e) => (
<tr key={e.event_id}>
<Td>{fmtDate(e.observed_at)}</Td>
<Td>{fmtDate(e.received_at)}</Td>
<Td>
<DateTime iso={e.observed_at} />
</Td>
<Td>
<DateTime iso={e.received_at} />
</Td>
<Td>{e.agent_id}</Td>
<Td>{e.check_id}</Td>
<Td>
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
</Td>
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
<Td>{e.exit_code}</Td>
<Td>{e.duration_ms}</Td>
@@ -80,15 +77,42 @@ export default async function EventsPage({
);
}
function Field({ label, name, defaultValue }: { label: string; name: string; defaultValue?: string }) {
function ActiveFilters({ agentId, checkId }: { agentId?: string; checkId?: string }) {
if (!agentId && !checkId) return null;
return (
<label className="flex flex-col gap-1 text-xs uppercase tracking-wide text-neutral-400">
{label}
<input
name={name}
defaultValue={defaultValue ?? ""}
className="bg-neutral-900 border border-neutral-800 px-2 py-1 text-sm text-neutral-100 font-mono"
/>
</label>
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
{agentId ? (
<FilterChip label="agent_id" value={agentId} href={eventsHref({ check_id: checkId })} />
) : null}
{checkId ? (
<FilterChip label="check_id" value={checkId} href={eventsHref({ agent_id: agentId })} />
) : null}
<Link href="/events" className="text-neutral-500 hover:text-neutral-300">
clear
</Link>
</div>
);
}
function FilterChip({ label, value, href }: { label: string; value: string; href: string }) {
return (
<Link
href={href}
className="inline-flex max-w-xs items-center gap-1 rounded border border-neutral-800 bg-neutral-900 px-2 py-1 text-neutral-300 hover:border-neutral-600 hover:text-neutral-100"
title={`${label}=${value}`}
>
<span className="text-neutral-500">{label}=</span>
<span className="truncate">{value}</span>
<span className="text-neutral-600">x</span>
</Link>
);
}
function eventsHref(params: { agent_id?: string; check_id?: string }): string {
const qs = new URLSearchParams();
if (params.agent_id) qs.set("agent_id", params.agent_id);
if (params.check_id) qs.set("check_id", params.check_id);
const query = qs.toString();
return query ? `/events?${query}` : "/events";
}

View File

@@ -1,9 +1,11 @@
import Link from "next/link";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { Pager } from "@/components/Pager";
import { DateTime } from "@/components/TimeZoneControls";
import { api } from "@/lib/api";
import { fmtDate, statusBadgeClass } from "@/lib/format";
import { statusBadgeClass } from "@/lib/format";
export const dynamic = "force-dynamic";
@@ -58,12 +60,18 @@ export default async function IncidentsPage({
<tbody>
{data.items.map((i) => (
<tr key={i.id}>
<Td>{fmtDate(i.opened_at)}</Td>
<Td>
<DateTime iso={i.opened_at} />
</Td>
<Td className={statusBadgeClass(i.state)}>{i.state}</Td>
<Td className={statusBadgeClass(i.severity)}>{i.severity}</Td>
<Td>{i.agent_id ?? "—"}</Td>
<Td>{i.check_id ?? "—"}</Td>
<Td>{fmtDate(i.resolved_at)}</Td>
<Td>
<CheckEventsLink agentId={i.agent_id} checkId={i.check_id} />
</Td>
<Td>
<DateTime iso={i.resolved_at} />
</Td>
<Td className="text-neutral-400">{i.summary ?? "—"}</Td>
</tr>
))}

View File

@@ -1,6 +1,8 @@
import type { Metadata } from "next";
import Link from "next/link";
import { AutoRefresh } from "@/components/AutoRefresh";
import { TimeZoneCarousel, TimeZoneProvider } from "@/components/TimeZoneControls";
import { getUiTimeZone } from "@/lib/timezone";
import "./globals.css";
export const metadata: Metadata = {
@@ -46,22 +48,26 @@ function Brand() {
export default function RootLayout({ children }: { children: React.ReactNode }) {
const refreshIntervalMs = getRefreshIntervalMs();
const configuredTimeZone = getUiTimeZone();
return (
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col bg-neutral-950 text-neutral-100 font-mono">
<header className="border-b border-neutral-800 px-4 py-3 flex items-center gap-6">
<Brand />
<nav className="flex gap-4 text-sm text-neutral-300">
{NAV.map((n) => (
<Link key={n.href} href={n.href} className="hover:text-white">
{n.label}
</Link>
))}
</nav>
</header>
<main className="flex-1 px-4 py-6">{children}</main>
{refreshIntervalMs > 0 && <AutoRefresh intervalMs={refreshIntervalMs} />}
<TimeZoneProvider configuredTimeZone={configuredTimeZone}>
<header className="border-b border-neutral-800 px-4 py-3 flex flex-wrap items-center gap-4">
<Brand />
<nav className="flex flex-wrap gap-4 text-sm text-neutral-300">
{NAV.map((n) => (
<Link key={n.href} href={n.href} className="hover:text-white">
{n.label}
</Link>
))}
</nav>
<TimeZoneCarousel />
</header>
<main className="flex-1 px-4 py-6">{children}</main>
{refreshIntervalMs > 0 && <AutoRefresh intervalMs={refreshIntervalMs} />}
</TimeZoneProvider>
</body>
</html>
);

View File

@@ -1,7 +1,8 @@
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { Pager } from "@/components/Pager";
import { DateTime } from "@/components/TimeZoneControls";
import { api } from "@/lib/api";
import { fmtDate, statusBadgeClass } from "@/lib/format";
import { statusBadgeClass } from "@/lib/format";
export const dynamic = "force-dynamic";
@@ -38,12 +39,16 @@ export default async function OutboxPage({
<tbody>
{data.items.map((o) => (
<tr key={o.id}>
<Td>{fmtDate(o.updated_at)}</Td>
<Td>
<DateTime iso={o.updated_at} />
</Td>
<Td>{o.notifier}</Td>
<Td>{o.event_type}</Td>
<Td className={statusBadgeClass(o.state)}>{o.state}</Td>
<Td>{o.attempts}</Td>
<Td>{fmtDate(o.next_attempt_at)}</Td>
<Td>
<DateTime iso={o.next_attempt_at} />
</Td>
<Td className="text-xs text-red-300">{o.last_error ?? "—"}</Td>
</tr>
))}

View File

@@ -5,8 +5,9 @@ import { useMemo, useState } from "react";
import { AgentFeatures } from "@/components/AgentFeatures";
import { LabelChips } from "@/components/Labels";
import { DateTime } from "@/components/TimeZoneControls";
import type { Agent, CheckState } from "@/lib/api";
import { fmtDate, statusBadgeClass } from "@/lib/format";
import { statusBadgeClass } from "@/lib/format";
type CheckStatus = CheckState["status"];
type CheckCounts = Record<CheckStatus, number>;
@@ -104,7 +105,9 @@ export function AgentsBrowser({ rows: initialRows }: { rows: AgentRow[] }) {
<Td>
<AgentStatus status={a.status} checks={checks} />
</Td>
<Td>{fmtDate(a.last_seen_at)}</Td>
<Td>
<DateTime iso={a.last_seen_at} />
</Td>
<Td>
<AgentFeatures features={a.features} />
</Td>

View File

@@ -0,0 +1,21 @@
import Link from "next/link";
export function CheckEventsLink({
agentId,
checkId,
}: {
agentId?: string | null;
checkId?: string | null;
}) {
if (!checkId) return <span className="text-neutral-500"></span>;
const params = new URLSearchParams();
if (agentId) params.set("agent_id", agentId);
params.set("check_id", checkId);
return (
<Link href={`/events?${params.toString()}`} className="text-blue-300 hover:text-blue-200">
{checkId}
</Link>
);
}

View File

@@ -0,0 +1,97 @@
"use client";
import { createContext, useContext, useMemo, useState } from "react";
import { DEFAULT_TIME_ZONE, fmtDate, normalizeTimeZone } from "@/lib/format";
type TimeZoneMode = "configured" | "browser" | "utc";
const modes: TimeZoneMode[] = ["configured", "browser", "utc"];
type TimeZoneContextValue = {
mode: TimeZoneMode;
configuredTimeZone: string;
browserTimeZone: string | null;
timeZone: string;
cycleMode: () => void;
};
const TimeZoneContext = createContext<TimeZoneContextValue | null>(null);
export function TimeZoneProvider({
configuredTimeZone,
children,
}: {
configuredTimeZone: string;
children: React.ReactNode;
}) {
const configured = normalizeTimeZone(configuredTimeZone);
const [mode, setMode] = useState<TimeZoneMode>("configured");
const [browserTimeZone, setBrowserTimeZone] = useState<string | null>(null);
const value = useMemo<TimeZoneContextValue>(() => {
const timeZone =
mode === "configured"
? configured
: mode === "browser"
? (browserTimeZone ?? configured)
: DEFAULT_TIME_ZONE;
return {
mode,
configuredTimeZone: configured,
browserTimeZone,
timeZone,
cycleMode: () => {
const next = modes[(modes.indexOf(mode) + 1) % modes.length];
if (next === "browser" && browserTimeZone === null) {
setBrowserTimeZone(normalizeTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone));
}
setMode(next);
},
};
}, [browserTimeZone, configured, mode]);
return <TimeZoneContext.Provider value={value}>{children}</TimeZoneContext.Provider>;
}
export function DateTime({ iso }: { iso?: string | null }) {
const { timeZone } = useDisplayTimeZone();
return <time dateTime={iso ?? undefined}>{fmtDate(iso, timeZone)}</time>;
}
export function TimeZoneCarousel() {
const { mode, configuredTimeZone, browserTimeZone, timeZone, cycleMode } = useDisplayTimeZone();
const title = `Timezone: ${modeLabel(mode)} (${timeZone}). Default: ${configuredTimeZone}; browser: ${browserTimeZone ?? "on switch"}; UTC: ${DEFAULT_TIME_ZONE}`;
return (
<button
type="button"
onClick={cycleMode}
title={title}
aria-label={title}
className="ml-auto inline-flex shrink-0 items-center gap-2 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"
>
<span className="text-neutral-500">TZ</span>
<span>{modeLabel(mode)}</span>
<span className="max-w-36 truncate text-emerald-300">{timeZone}</span>
<span className="text-neutral-600"></span>
</button>
);
}
function useDisplayTimeZone(): TimeZoneContextValue {
const value = useContext(TimeZoneContext);
if (!value) throw new Error("TimeZoneProvider is missing");
return value;
}
function modeLabel(mode: TimeZoneMode): string {
switch (mode) {
case "configured":
return "default";
case "browser":
return "browser";
case "utc":
return "utc";
}
}

View File

@@ -1,7 +1,34 @@
export function fmtDate(iso?: string | null): string {
export const DEFAULT_TIME_ZONE = "UTC";
export function normalizeTimeZone(timeZone?: string | null): string {
const value = timeZone?.trim();
if (!value) return DEFAULT_TIME_ZONE;
try {
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date(0));
return value;
} catch {
return DEFAULT_TIME_ZONE;
}
}
export function fmtDate(iso?: string | null, timeZone = DEFAULT_TIME_ZONE): string {
if (!iso) return "—";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toISOString().replace("T", " ").replace(/\..+$/, "Z");
if (Number.isNaN(d.getTime())) return iso;
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: normalizeTimeZone(timeZone),
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
timeZoneName: "short",
}).formatToParts(d);
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
return `${get("year")}-${get("month")}-${get("day")} ${get("hour")}:${get("minute")}:${get("second")} ${get("timeZoneName")}`;
}
export function statusBadgeClass(s: string): string {

7
web/src/lib/timezone.ts Normal file
View File

@@ -0,0 +1,7 @@
import "server-only";
import { DEFAULT_TIME_ZONE, normalizeTimeZone } from "@/lib/format";
export function getUiTimeZone(): string {
return normalizeTimeZone(process.env.MONLET_WEB_TIME_ZONE ?? process.env.TZ ?? DEFAULT_TIME_ZONE);
}

View File

@@ -19,6 +19,12 @@ test("agents list links to detail", async ({ page }) => {
await expect(page.locator("thead")).toContainText("↓");
await expect(page.locator("body")).toContainText("checks 2");
await expect(page.locator("body")).toContainText("warn 1");
await expect(page.getByRole("button", { name: /Timezone: default/ })).toBeVisible();
await page.getByRole("button", { name: /Timezone: default/ }).click();
await expect(page.getByRole("button", { name: /Timezone: browser/ })).toBeVisible();
await page.getByRole("button", { name: /Timezone: browser/ }).click();
await expect(page.getByRole("button", { name: /Timezone: utc \(UTC\)/ })).toBeVisible();
await expect(page.locator("tbody")).toContainText("UTC");
await page.getByPlaceholder("search agent").fill("host-1");
await expect(page).toHaveURL(/\/agents$/);
await expect(page.locator("tbody")).toContainText("host-1");
@@ -45,6 +51,8 @@ test("checks page lists rows", async ({ page }) => {
await page.goto("/checks");
await expect(page.locator("body")).toContainText("disk");
await expect(page.locator("body")).toContainText("load");
await page.getByRole("link", { name: "disk" }).click();
await expect(page).toHaveURL(/\/events\?agent_id=agent-1&check_id=disk$/);
});
test("incidents filter renders", async ({ page }) => {
@@ -53,8 +61,11 @@ test("incidents filter renders", async ({ page }) => {
});
test("events page", async ({ page }) => {
await page.goto("/events");
await page.goto("/events?agent_id=agent-1&check_id=disk");
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
await expect(page.getByRole("button", { name: "query" })).toHaveCount(0);
await expect(page.getByRole("link", { name: /agent_id=.*agent-1/ })).toBeVisible();
await expect(page.getByRole("link", { name: /check_id=.*disk/ })).toBeVisible();
});
test("outbox page", async ({ page }) => {