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({