diff --git a/api/openapi.yaml b/api/openapi.yaml index 9fc03ff..71c2c26 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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] diff --git a/deploy/README.md b/deploy/README.md index 304b0e3..8d66116 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -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 diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index fa29797..f802f01 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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" diff --git a/docs/ops/deployment-checklist.md b/docs/ops/deployment-checklist.md index aac7732..ea97f53 100644 --- a/docs/ops/deployment-checklist.md +++ b/docs/ops/deployment-checklist.md @@ -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). diff --git a/server/.env.example b/server/.env.example index 477edab..6e8e10c 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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= diff --git a/server/README.md b/server/README.md index 8bf4efb..a26ba1a 100644 --- a/server/README.md +++ b/server/README.md @@ -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 | diff --git a/server/monlet_server/api/agents.py b/server/monlet_server/api/agents.py index bd7641a..26a3bf9 100644 --- a/server/monlet_server/api/agents.py +++ b/server/monlet_server/api/agents.py @@ -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 {}, ) diff --git a/server/monlet_server/api/checks.py b/server/monlet_server/api/checks.py index 7e101f0..b7e9b29 100644 --- a/server/monlet_server/api/checks.py +++ b/server/monlet_server/api/checks.py @@ -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, ) diff --git a/server/monlet_server/api/events.py b/server/monlet_server/api/events.py index 432cd26..d42f192 100644 --- a/server/monlet_server/api/events.py +++ b/server/monlet_server/api/events.py @@ -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, diff --git a/server/monlet_server/api/incidents.py b/server/monlet_server/api/incidents.py index a502a5a..befcdf9 100644 --- a/server/monlet_server/api/incidents.py +++ b/server/monlet_server/api/incidents.py @@ -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) diff --git a/server/monlet_server/api/outbox.py b/server/monlet_server/api/outbox.py index d049e37..f4720d0 100644 --- a/server/monlet_server/api/outbox.py +++ b/server/monlet_server/api/outbox.py @@ -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 ], diff --git a/server/monlet_server/schemas.py b/server/monlet_server/schemas.py index 29cc2b7..9c99ad1 100644 --- a/server/monlet_server/schemas.py +++ b/server/monlet_server/schemas.py @@ -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: diff --git a/server/monlet_server/services/ingestion.py b/server/monlet_server/services/ingestion.py index ee5fe1d..ebbf077 100644 --- a/server/monlet_server/services/ingestion.py +++ b/server/monlet_server/services/ingestion.py @@ -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) diff --git a/server/monlet_server/services/notifiers/alertmanager.py b/server/monlet_server/services/notifiers/alertmanager.py index eaec2b2..7a7ae9c 100644 --- a/server/monlet_server/services/notifiers/alertmanager.py +++ b/server/monlet_server/services/notifiers/alertmanager.py @@ -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 = { diff --git a/server/monlet_server/services/notifiers/debug.py b/server/monlet_server/services/notifiers/debug.py index 7308d10..8d9ffd3 100644 --- a/server/monlet_server/services/notifiers/debug.py +++ b/server/monlet_server/services/notifiers/debug.py @@ -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) diff --git a/server/monlet_server/services/notifiers/registry.py b/server/monlet_server/services/notifiers/registry.py index 8030395..3d0dd5d 100644 --- a/server/monlet_server/services/notifiers/registry.py +++ b/server/monlet_server/services/notifiers/registry.py @@ -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 diff --git a/server/monlet_server/services/notifiers/telegram.py b/server/monlet_server/services/notifiers/telegram.py index 74d4889..479d41f 100644 --- a/server/monlet_server/services/notifiers/telegram.py +++ b/server/monlet_server/services/notifiers/telegram.py @@ -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: diff --git a/server/monlet_server/services/notifiers/webhook.py b/server/monlet_server/services/notifiers/webhook.py index 600a009..a05af9e 100644 --- a/server/monlet_server/services/notifiers/webhook.py +++ b/server/monlet_server/services/notifiers/webhook.py @@ -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: diff --git a/server/monlet_server/settings.py b/server/monlet_server/settings.py index bec3148..a00c1c8 100644 --- a/server/monlet_server/settings.py +++ b/server/monlet_server/settings.py @@ -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] = [] diff --git a/server/monlet_server/timeutil.py b/server/monlet_server/timeutil.py new file mode 100644 index 0000000..c98f782 --- /dev/null +++ b/server/monlet_server/timeutil.py @@ -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 diff --git a/server/tests/test_events_ingestion.py b/server/tests/test_events_ingestion.py index 2246622..2bf04ba 100644 --- a/server/tests/test_events_ingestion.py +++ b/server/tests/test_events_ingestion.py @@ -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)] diff --git a/server/tests/test_heartbeat.py b/server/tests/test_heartbeat.py index ef356ee..3618366 100644 --- a/server/tests/test_heartbeat.py +++ b/server/tests/test_heartbeat.py @@ -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() diff --git a/server/tests/test_notifiers.py b/server/tests/test_notifiers.py index 0e3f5a5..fe7becd 100644 --- a/server/tests/test_notifiers.py +++ b/server/tests/test_notifiers.py @@ -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: diff --git a/web/Dockerfile b/web/Dockerfile index 952d6e5..e9df2aa 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -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 diff --git a/web/README.md b/web/README.md index 5ae343b..ad50783 100644 --- a/web/README.md +++ b/web/README.md @@ -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. diff --git a/web/src/app/agents/[agent_id]/page.tsx b/web/src/app/agents/[agent_id]/page.tsx index 144f272..c8fcd48 100644 --- a/web/src/app/agents/[agent_id]/page.tsx +++ b/web/src/app/agents/[agent_id]/page.tsx @@ -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({
status
{agent.status}
last_seen_at
-
{fmtDate(agent.last_seen_at)}
+
+ +
features
@@ -104,11 +108,7 @@ export default async function AgentDetailPage({
- {tab === "checks" ? ( - - ) : ( - - )} + {tab === "checks" ? : } ); } @@ -169,10 +169,14 @@ function ChecksTable({ {checks.map((c) => ( - {c.check_id} + + + {c.status} {c.exit_code ?? "—"} - {fmtDate(c.last_observed_at)} + + + ))} @@ -197,8 +201,12 @@ function EventsTable({ events }: { events: StoredEvent[] }) { {events.map((e) => ( - {fmtDate(e.observed_at)} - {e.check_id} + + + + + + {e.status} {e.duration_ms} diff --git a/web/src/app/checks/page.tsx b/web/src/app/checks/page.tsx index f5b75a9..fa97f95 100644 --- a/web/src/app/checks/page.tsx +++ b/web/src/app/checks/page.tsx @@ -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} - {c.check_id} + + + {c.status} {c.exit_code ?? "—"} - {fmtDate(c.last_observed_at)} + + + {c.incident_key ?? "—"} ))} diff --git a/web/src/app/events/page.tsx b/web/src/app/events/page.tsx index a0f0ec1..d21aec3 100644 --- a/web/src/app/events/page.tsx +++ b/web/src/app/events/page.tsx @@ -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 ( <> -
- - - - + {data.items.length === 0 ? ( ) : ( @@ -57,10 +48,16 @@ export default async function EventsPage({ {data.items.map((e) => ( - {fmtDate(e.observed_at)} - {fmtDate(e.received_at)} + + + + + + {e.agent_id} - {e.check_id} + + + {e.status} {e.exit_code} {e.duration_ms} @@ -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 ( - +
+ {agentId ? ( + + ) : null} + {checkId ? ( + + ) : null} + + clear + +
); } + +function FilterChip({ label, value, href }: { label: string; value: string; href: string }) { + return ( + + {label}= + {value} + x + + ); +} + +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"; +} diff --git a/web/src/app/incidents/page.tsx b/web/src/app/incidents/page.tsx index 4940fc1..8c6d112 100644 --- a/web/src/app/incidents/page.tsx +++ b/web/src/app/incidents/page.tsx @@ -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({ {data.items.map((i) => ( - {fmtDate(i.opened_at)} + + + {i.state} {i.severity} {i.agent_id ?? "—"} - {i.check_id ?? "—"} - {fmtDate(i.resolved_at)} + + + + + + {i.summary ?? "—"} ))} diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 143bbfd..4474bbc 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -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 ( -
- - -
-
{children}
- {refreshIntervalMs > 0 && } + +
+ + + +
+
{children}
+ {refreshIntervalMs > 0 && } +
); diff --git a/web/src/app/outbox/page.tsx b/web/src/app/outbox/page.tsx index 07ab9ca..d00399b 100644 --- a/web/src/app/outbox/page.tsx +++ b/web/src/app/outbox/page.tsx @@ -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({ {data.items.map((o) => ( - {fmtDate(o.updated_at)} + + + {o.notifier} {o.event_type} {o.state} {o.attempts} - {fmtDate(o.next_attempt_at)} + + + {o.last_error ?? "—"} ))} diff --git a/web/src/components/AgentsBrowser.tsx b/web/src/components/AgentsBrowser.tsx index 4745865..414a729 100644 --- a/web/src/components/AgentsBrowser.tsx +++ b/web/src/components/AgentsBrowser.tsx @@ -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; @@ -104,7 +105,9 @@ export function AgentsBrowser({ rows: initialRows }: { rows: AgentRow[] }) { - {fmtDate(a.last_seen_at)} + + + diff --git a/web/src/components/CheckEventsLink.tsx b/web/src/components/CheckEventsLink.tsx new file mode 100644 index 0000000..3178c54 --- /dev/null +++ b/web/src/components/CheckEventsLink.tsx @@ -0,0 +1,21 @@ +import Link from "next/link"; + +export function CheckEventsLink({ + agentId, + checkId, +}: { + agentId?: string | null; + checkId?: string | null; +}) { + if (!checkId) return ; + + const params = new URLSearchParams(); + if (agentId) params.set("agent_id", agentId); + params.set("check_id", checkId); + + return ( + + {checkId} + + ); +} diff --git a/web/src/components/TimeZoneControls.tsx b/web/src/components/TimeZoneControls.tsx new file mode 100644 index 0000000..2edb8b0 --- /dev/null +++ b/web/src/components/TimeZoneControls.tsx @@ -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(null); + +export function TimeZoneProvider({ + configuredTimeZone, + children, +}: { + configuredTimeZone: string; + children: React.ReactNode; +}) { + const configured = normalizeTimeZone(configuredTimeZone); + const [mode, setMode] = useState("configured"); + const [browserTimeZone, setBrowserTimeZone] = useState(null); + + const value = useMemo(() => { + 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 {children}; +} + +export function DateTime({ iso }: { iso?: string | null }) { + const { timeZone } = useDisplayTimeZone(); + return ; +} + +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 ( + + ); +} + +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"; + } +} diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 4a17718..afb33dd 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -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 { diff --git a/web/src/lib/timezone.ts b/web/src/lib/timezone.ts new file mode 100644 index 0000000..cc58acc --- /dev/null +++ b/web/src/lib/timezone.ts @@ -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); +} diff --git a/web/tests/smoke.spec.ts b/web/tests/smoke.spec.ts index 019fc7d..8427253 100644 --- a/web/tests/smoke.spec.ts +++ b/web/tests/smoke.spec.ts @@ -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 }) => {