Improve timestamp handling and event navigation
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 {},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
53
server/monlet_server/timeutil.py
Normal file
53
server/monlet_server/timeutil.py
Normal 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
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
21
web/src/components/CheckEventsLink.tsx
Normal file
21
web/src/components/CheckEventsLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
web/src/components/TimeZoneControls.tsx
Normal file
97
web/src/components/TimeZoneControls.tsx
Normal 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";
|
||||
}
|
||||
}
|
||||
@@ -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
7
web/src/lib/timezone.ts
Normal 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);
|
||||
}
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user