From d5f312f200c64a296f374867d04e4fb12cf2b8e5 Mon Sep 17 00:00:00 2001 From: Stanislav Rossovskii Date: Wed, 27 May 2026 14:58:46 +0400 Subject: [PATCH] store only status-change events; UI client-side polling refactor --- api/openapi.yaml | 20 +- deploy/e2e-showcase.sh | 15 ++ deploy/showcase/checks/exit_0.sh | 2 +- server/alembic/versions/0001_baseline.py | 1 + server/monlet_server/api/checks.py | 1 + server/monlet_server/models.py | 1 + server/monlet_server/schemas.py | 1 + server/monlet_server/services/detector.py | 169 +++++++++++-- server/monlet_server/services/ingestion.py | 102 +++++--- server/monlet_server/settings.py | 2 +- server/tests/test_detector.py | 35 ++- server/tests/test_events_ingestion.py | 57 ++++- server/tests/test_incident_key.py | 2 +- web/src/app/agents/[agent_id]/page.tsx | 235 ++---------------- web/src/app/agents/page.tsx | 55 +--- web/src/app/api/poll/agent/route.ts | 41 +++ web/src/app/api/poll/events/route.ts | 23 ++ web/src/app/api/poll/incidents/route.ts | 19 ++ web/src/app/api/poll/inventory/route.ts | 14 ++ web/src/app/api/poll/outbox/route.ts | 19 ++ web/src/app/api/poll/overview/route.ts | 14 ++ web/src/app/checks/[check_id]/page.tsx | 51 ++++ web/src/app/checks/page.tsx | 66 +---- web/src/app/events/page.tsx | 112 +-------- web/src/app/incidents/page.tsx | 84 +------ web/src/app/layout.tsx | 13 - web/src/app/outbox/page.tsx | 47 +--- web/src/app/page.tsx | 76 +----- web/src/components/AgentDetailBrowser.tsx | 216 ++++++++++++++++ web/src/components/AgentFeatures.tsx | 4 +- web/src/components/AgentsBrowser.tsx | 23 +- web/src/components/AutoRefresh.tsx | 51 ---- web/src/components/CheckDetailBrowser.tsx | 212 ++++++++++++++++ web/src/components/CheckStatusSummary.tsx | 18 ++ web/src/components/ChecksBrowser.tsx | 181 ++++++++++++++ web/src/components/DataPanel.tsx | 8 +- web/src/components/EventsBrowser.tsx | 136 ++++++++++ web/src/components/IncidentsBrowser.tsx | 276 +++++++++++++++++++++ web/src/components/OutboxBrowser.tsx | 68 +++++ web/src/components/OverviewDashboard.tsx | 56 +++++ web/src/lib/api-types.ts | 11 +- web/src/lib/check-groups.ts | 131 ++++++++++ web/src/lib/loaders.ts | 102 ++++++++ web/src/lib/poll-response.ts | 18 ++ web/src/lib/usePolling.ts | 88 +++++++ web/src/lib/view-types.ts | 17 ++ web/tests/mock-server.mjs | 64 ++++- web/tests/smoke.spec.ts | 43 +++- 48 files changed, 2247 insertions(+), 753 deletions(-) create mode 100644 web/src/app/api/poll/agent/route.ts create mode 100644 web/src/app/api/poll/events/route.ts create mode 100644 web/src/app/api/poll/incidents/route.ts create mode 100644 web/src/app/api/poll/inventory/route.ts create mode 100644 web/src/app/api/poll/outbox/route.ts create mode 100644 web/src/app/api/poll/overview/route.ts create mode 100644 web/src/app/checks/[check_id]/page.tsx create mode 100644 web/src/components/AgentDetailBrowser.tsx delete mode 100644 web/src/components/AutoRefresh.tsx create mode 100644 web/src/components/CheckDetailBrowser.tsx create mode 100644 web/src/components/CheckStatusSummary.tsx create mode 100644 web/src/components/ChecksBrowser.tsx create mode 100644 web/src/components/EventsBrowser.tsx create mode 100644 web/src/components/IncidentsBrowser.tsx create mode 100644 web/src/components/OutboxBrowser.tsx create mode 100644 web/src/components/OverviewDashboard.tsx create mode 100644 web/src/lib/check-groups.ts create mode 100644 web/src/lib/loaders.ts create mode 100644 web/src/lib/poll-response.ts create mode 100644 web/src/lib/usePolling.ts create mode 100644 web/src/lib/view-types.ts diff --git a/api/openapi.yaml b/api/openapi.yaml index 71c2c26..4891722 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -195,7 +195,7 @@ paths: $ref: "#/components/responses/Unauthorized" /api/v1/events/query: get: - summary: Query stored events + summary: Query check status-change history operationId: queryEvents parameters: - name: agent_id @@ -368,11 +368,16 @@ components: accepted: type: integer minimum: 0 - description: Number of events stored on this request. + description: | + Number of events that produced a new status-change row on this request. deduplicated: type: integer minimum: 0 - description: Number of events whose `event_id` was already known. + description: | + Number of events that were not stored as a change. Includes events whose + `event_id` was already known, events whose `(status, exit_code)` matched + the previously observed value for the same check, and events received late + (older than the current `last_observed_at` of the check). HeartbeatRequest: type: object additionalProperties: false @@ -486,7 +491,11 @@ components: type: string maxLength: 256 StoredCheckResultEvent: - description: Stored event returned by `GET /events/query`. Includes the resolved `agent_id` and server-side `received_at`. + description: | + Stored status-change event returned by `GET /events/query`. The server persists + only events that change `(status, exit_code)` versus the previous observed value + for the same `(agent_id, check_id)`. Repeated identical results are not stored. + Includes the resolved `agent_id` and server-side `received_at`. allOf: - $ref: "#/components/schemas/CheckResultEvent" - type: object @@ -537,6 +546,9 @@ components: type: integer incident_key: type: string + summary: + type: string + maxLength: 8192 Incident: type: object required: [id, incident_key, state, severity, opened_at] diff --git a/deploy/e2e-showcase.sh b/deploy/e2e-showcase.sh index f360f1e..643593f 100755 --- a/deploy/e2e-showcase.sh +++ b/deploy/e2e-showcase.sh @@ -125,6 +125,21 @@ api "/api/v1/agents/agent-stale" | grep -q '"status":"dead"' \ api "/api/v1/agents/agent-dead" | grep -q '"status":"dead"' \ || fail "agent-dead did not transition to dead" +log "asserting dead agents have open liveness incidents" +api "/api/v1/incidents?state=open&limit=500" | python3 -c " +import json,sys +d=json.load(sys.stdin) +want={'agent-stale','agent-dead'} +have={ + i['agent_id'] + for i in d['items'] + if i['check_id']=='agent_liveness' and i['state']=='open' and i['severity']=='critical' +} +missing=want-have +print('liveness incidents:', sorted(have)) +sys.exit(0 if not missing else 1)" \ + || fail "missing critical agent_liveness incident for dead agents" + # At least one agent must still be alive (agent-mixed keeps pushing). api "/api/v1/agents/agent-mixed" | grep -q '"status":"alive"' \ || fail "agent-mixed not alive" diff --git a/deploy/showcase/checks/exit_0.sh b/deploy/showcase/checks/exit_0.sh index 83f64f4..7ec7099 100755 --- a/deploy/showcase/checks/exit_0.sh +++ b/deploy/showcase/checks/exit_0.sh @@ -1,3 +1,3 @@ #!/bin/sh -echo "ok: $(date -u +%FT%TZ)" +printf "ok" exit 0 diff --git a/server/alembic/versions/0001_baseline.py b/server/alembic/versions/0001_baseline.py index d64f658..2e74195 100644 --- a/server/alembic/versions/0001_baseline.py +++ b/server/alembic/versions/0001_baseline.py @@ -43,6 +43,7 @@ def upgrade() -> None: last_observed_at TIMESTAMPTZ NOT NULL, last_event_id UUID NOT NULL, incident_key TEXT NOT NULL, + summary TEXT, PRIMARY KEY (agent_id, check_id) ); """ diff --git a/server/monlet_server/api/checks.py b/server/monlet_server/api/checks.py index b7e9b29..1ccc4bb 100644 --- a/server/monlet_server/api/checks.py +++ b/server/monlet_server/api/checks.py @@ -42,6 +42,7 @@ async def list_checks( last_observed_at=to_utc(r.last_observed_at), exit_code=r.exit_code, incident_key=r.incident_key, + summary=r.summary, ) for r in rows ] diff --git a/server/monlet_server/models.py b/server/monlet_server/models.py index 764f245..a810810 100644 --- a/server/monlet_server/models.py +++ b/server/monlet_server/models.py @@ -71,6 +71,7 @@ class Check(Base): last_observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) last_event_id: Mapped[UUID] = mapped_column(_uuid(), nullable=False) incident_key: Mapped[str] = mapped_column(Text, nullable=False) + summary: Mapped[str | None] = mapped_column(Text, nullable=True) __table_args__ = ( PrimaryKeyConstraint("agent_id", "check_id", name="checks_pkey"), diff --git a/server/monlet_server/schemas.py b/server/monlet_server/schemas.py index 9c99ad1..74733c8 100644 --- a/server/monlet_server/schemas.py +++ b/server/monlet_server/schemas.py @@ -152,6 +152,7 @@ class CheckState(BaseModel): last_observed_at: datetime exit_code: int incident_key: str + summary: str | None = None class Incident(BaseModel): diff --git a/server/monlet_server/services/detector.py b/server/monlet_server/services/detector.py index 31ede3f..75832b2 100644 --- a/server/monlet_server/services/detector.py +++ b/server/monlet_server/services/detector.py @@ -1,7 +1,9 @@ import asyncio from datetime import UTC, datetime, timedelta +from uuid import uuid4 -from sqlalchemy import delete, func, select, update +from sqlalchemy import delete, func, select, text +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import async_sessionmaker from .. import metrics @@ -12,6 +14,138 @@ from ..settings import get_settings log = get_logger("monlet.detector") OUTBOX_TERMINAL_STATES = ("sent", "failed", "discarded") +LIVENESS_CHECK_ID = "agent_liveness" + + +def _liveness_key(agent_id: str) -> str: + return f"agent:{agent_id}:liveness" + + +def _liveness_severity(status: str) -> str: + return "critical" if status == "dead" else "warning" + + +async def _enqueue_liveness_outbox( + session, + incident: Incident, + event_type: str, + agent: Agent, + status: str, + now: datetime, + summary: str, + severity: str, +) -> None: + notifiers = get_settings().enabled_notifiers + if not notifiers: + return + for name in notifiers: + session.add( + NotificationOutbox( + id=uuid4(), + incident_id=incident.id, + notifier=name, + event_type=event_type, + state="pending", + attempts=0, + next_attempt_at=now, + payload={ + "incident_id": str(incident.id), + "agent_id": agent.agent_id, + "check_id": LIVENESS_CHECK_ID, + "status": status, + "severity": severity, + "incident_key": incident.incident_key, + "observed_at": now.isoformat(), + "opened_at": incident.opened_at.isoformat() if incident.opened_at else None, + "resolved_at": incident.resolved_at.isoformat() + if incident.resolved_at + else None, + "summary": summary, + "output": summary, + "event_type": event_type, + }, + ) + ) + + +async def _open_liveness_incident(session, agent: Agent, status: str, now: datetime) -> None: + severity = _liveness_severity(status) + summary = f"agent is {status}" + incident_key = _liveness_key(agent.agent_id) + new_id = uuid4() + stmt = ( + pg_insert(Incident) + .values( + id=new_id, + incident_key=incident_key, + agent_id=agent.agent_id, + check_id=LIVENESS_CHECK_ID, + state="open", + severity=severity, + opened_at=now, + summary=summary, + last_event_id=uuid4(), + ) + .on_conflict_do_nothing( + index_elements=[Incident.incident_key], + index_where=text("state = 'open'"), + ) + .returning(Incident.id) + ) + inserted_id = (await session.execute(stmt)).scalar_one_or_none() + if inserted_id is not None: + await session.flush() + inc = ( + await session.execute(select(Incident).where(Incident.id == inserted_id)) + ).scalar_one() + metrics.incidents_opened_total.inc() + await _enqueue_liveness_outbox( + session, inc, "firing", agent, status, now, summary=summary, severity=severity + ) + return + + inc = ( + await session.execute( + select(Incident).where( + Incident.incident_key == incident_key, + Incident.state == "open", + ) + ) + ).scalar_one() + if status == "dead" and inc.severity != "critical": + inc.severity = "critical" + inc.summary = summary + inc.last_event_id = uuid4() + await _enqueue_liveness_outbox( + session, inc, "firing", agent, status, now, summary=summary, severity="critical" + ) + + +async def _resolve_liveness_incident(session, agent: Agent, now: datetime) -> None: + inc = ( + await session.execute( + select(Incident).where( + Incident.incident_key == _liveness_key(agent.agent_id), + Incident.state == "open", + ) + ) + ).scalar_one_or_none() + if inc is None: + return + inc.state = "resolved" + inc.resolved_at = now + inc.last_event_id = uuid4() + metrics.incidents_resolved_total.inc() + await _enqueue_liveness_outbox( + session, + inc, + "resolved", + agent, + "ok", + now, + summary="agent is alive", + severity="ok", + ) async def _prune_history(session) -> None: @@ -44,25 +178,20 @@ async def _tick(sm: async_sessionmaker) -> None: stale_cut = now - timedelta(seconds=s.stale_after_sec) dead_cut = now - timedelta(seconds=s.dead_after_sec) async with sm() as session: - await session.execute( - update(Agent) - .where(Agent.last_seen_at <= dead_cut) - .where(Agent.status != "dead") - .values(status="dead") - ) - await session.execute( - update(Agent) - .where(Agent.last_seen_at <= stale_cut) - .where(Agent.last_seen_at > dead_cut) - .where(Agent.status != "stale") - .values(status="stale") - ) - await session.execute( - update(Agent) - .where(Agent.last_seen_at > stale_cut) - .where(Agent.status != "alive") - .values(status="alive") - ) + agents = (await session.execute(select(Agent))).scalars().all() + for agent in agents: + if agent.last_seen_at <= dead_cut: + next_status = "dead" + elif agent.last_seen_at <= stale_cut: + next_status = "stale" + else: + next_status = "alive" + if agent.status != next_status: + agent.status = next_status + if next_status in ("stale", "dead"): + await _open_liveness_incident(session, agent, next_status, now) + else: + await _resolve_liveness_incident(session, agent, now) await _prune_history(session) await session.commit() diff --git a/server/monlet_server/services/ingestion.py b/server/monlet_server/services/ingestion.py index ebbf077..2830584 100644 --- a/server/monlet_server/services/ingestion.py +++ b/server/monlet_server/services/ingestion.py @@ -155,59 +155,79 @@ async def ingest_event( agent_id: str, ev: CheckResultEvent, ) -> bool: - """Return True if accepted, False if deduplicated.""" + """Return True if a new change row was written, False if not (duplicate/unchanged/late).""" observed = to_utc(ev.observed_at) output = redact(ev.output) + check_summary = (output or "")[:200] if output else None incident_key = _incident_key(agent_id, ev) - stmt = pg_insert(Event).values( - event_id=UUID(ev.event_id), - agent_id=agent_id, - check_id=ev.check_id, - observed_at=observed, - status=ev.status, - exit_code=ev.exit_code, - duration_ms=ev.duration_ms, - output=output, - output_truncated=ev.output_truncated, - notifications_enabled=ev.notifications_enabled, - incident_key=incident_key, - ) - stmt = stmt.on_conflict_do_nothing(index_elements=[Event.event_id]).returning(Event.event_id) - result = await session.execute(stmt) - inserted = result.scalar_one_or_none() - if inserted is None: - metrics.events_total.labels(result="deduplicated").inc() - return False - - metrics.events_total.labels(result="accepted").inc() - chk_res = await session.execute( select(Check).where(Check.agent_id == agent_id, Check.check_id == ev.check_id) ) cur = chk_res.scalar_one_or_none() is_late = cur is not None and cur.last_observed_at > observed - if is_late: - return True + changed = cur is None or cur.status != ev.status or cur.exit_code != ev.exit_code - if cur is None: - session.add( - Check( - agent_id=agent_id, - check_id=ev.check_id, - status=ev.status, - exit_code=ev.exit_code, - last_observed_at=observed, - last_event_id=UUID(ev.event_id), - incident_key=incident_key, - ) + if is_late: + metrics.events_total.labels(result="late").inc() + return False + + if changed: + stmt = pg_insert(Event).values( + event_id=UUID(ev.event_id), + agent_id=agent_id, + check_id=ev.check_id, + observed_at=observed, + status=ev.status, + exit_code=ev.exit_code, + duration_ms=ev.duration_ms, + output=output, + output_truncated=ev.output_truncated, + notifications_enabled=ev.notifications_enabled, + incident_key=incident_key, ) + stmt = stmt.on_conflict_do_nothing(index_elements=[Event.event_id]).returning( + Event.event_id + ) + result = await session.execute(stmt) + inserted = result.scalar_one_or_none() + if inserted is None: + metrics.events_total.labels(result="deduplicated").inc() + return False + metrics.events_total.labels(result="accepted").inc() else: - cur.status = ev.status - cur.exit_code = ev.exit_code - cur.last_observed_at = observed - cur.last_event_id = UUID(ev.event_id) - cur.incident_key = incident_key + metrics.events_total.labels(result="unchanged").inc() + + # Upsert Check to be safe under concurrent first-write race for the same (agent_id, check_id). + # When changed=False keep the existing last_event_id and incident_key — incident_key changes + # are only meaningful at transitions and would otherwise desync from any open Incident. + check_values = { + "agent_id": agent_id, + "check_id": ev.check_id, + "status": ev.status, + "exit_code": ev.exit_code, + "last_observed_at": observed, + "last_event_id": UUID(ev.event_id), + "incident_key": incident_key, + "summary": check_summary, + } + update_set = { + "status": ev.status, + "exit_code": ev.exit_code, + "last_observed_at": observed, + "summary": check_summary, + } + if changed: + update_set["last_event_id"] = UUID(ev.event_id) + update_set["incident_key"] = incident_key + chk_stmt = pg_insert(Check).values(**check_values) + chk_stmt = chk_stmt.on_conflict_do_update( + index_elements=[Check.agent_id, Check.check_id], set_=update_set + ) + await session.execute(chk_stmt) + + if not changed: + return False transition, inc = await _apply_incident(session, agent_id, ev, incident_key) if transition and inc is not None and ev.notifications_enabled: diff --git a/server/monlet_server/settings.py b/server/monlet_server/settings.py index a00c1c8..59dc325 100644 --- a/server/monlet_server/settings.py +++ b/server/monlet_server/settings.py @@ -20,7 +20,7 @@ class Settings(BaseSettings): port: int = 8000 enable_detector: bool = True enable_notifier_worker: bool = True - events_retention_max_rows: int = 100_000 + events_retention_max_rows: int = 20_000 outbox_retention_max_rows: int = 50_000 notifier_tick_sec: int = 5 notifier_batch_size: int = 20 diff --git a/server/tests/test_detector.py b/server/tests/test_detector.py index 191c7d9..7cb85c9 100644 --- a/server/tests/test_detector.py +++ b/server/tests/test_detector.py @@ -6,7 +6,7 @@ from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import async_sessionmaker from monlet_server.models import Agent, Event, Incident, NotificationOutbox -from monlet_server.services.detector import _tick +from monlet_server.services.detector import LIVENESS_CHECK_ID, _tick from monlet_server.settings import reset_settings_cache from ._helpers import make_event, make_heartbeat @@ -39,6 +39,7 @@ async def test_detector_transitions(app_client, auth_headers, engine, session): sm = async_sessionmaker(engine, expire_on_commit=False) await _tick(sm) + session.expire_all() statuses = { r.agent_id: r.status for r in (await session.execute(select(Agent))).scalars().all() @@ -47,6 +48,38 @@ async def test_detector_transitions(app_client, auth_headers, engine, session): assert statuses["agent-stale"] == "stale" assert statuses["agent-dead"] == "dead" + incidents = ( + (await session.execute(select(Incident).where(Incident.check_id == LIVENESS_CHECK_ID))) + .scalars() + .all() + ) + by_agent = {i.agent_id: i for i in incidents} + assert "agent-alive" not in by_agent + assert by_agent["agent-stale"].state == "open" + assert by_agent["agent-stale"].severity == "warning" + assert by_agent["agent-stale"].summary == "agent is stale" + assert by_agent["agent-dead"].state == "open" + assert by_agent["agent-dead"].severity == "critical" + assert by_agent["agent-dead"].summary == "agent is dead" + + await session.execute( + update(Agent).where(Agent.agent_id == "agent-dead").values(last_seen_at=datetime.now(UTC)) + ) + await session.commit() + await _tick(sm) + session.expire_all() + + resolved = ( + await session.execute( + select(Incident).where( + Incident.agent_id == "agent-dead", + Incident.check_id == LIVENESS_CHECK_ID, + ) + ) + ).scalar_one() + assert resolved.state == "resolved" + assert resolved.resolved_at is not None + @pytest.mark.asyncio async def test_detector_prunes_bounded_history( diff --git a/server/tests/test_events_ingestion.py b/server/tests/test_events_ingestion.py index 2bf04ba..0558f97 100644 --- a/server/tests/test_events_ingestion.py +++ b/server/tests/test_events_ingestion.py @@ -9,7 +9,7 @@ from ._helpers import make_event, make_heartbeat @pytest.mark.asyncio async def test_event_accepted_and_state(app_client, auth_headers, session): await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) - ev = make_event(status="ok") + ev = make_event(status="ok", output="disk ok") r = await app_client.post( "/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers ) @@ -19,6 +19,7 @@ async def test_event_accepted_and_state(app_client, auth_headers, session): assert n == 1 chk = (await session.execute(select(Check))).scalar_one() assert chk.status == "ok" + assert chk.summary == ev["output"] @pytest.mark.asyncio @@ -64,6 +65,53 @@ async def test_oversize_batch_400(app_client, auth_headers): assert r.status_code == 400 +@pytest.mark.asyncio +async def test_repeated_same_status_not_stored(app_client, auth_headers, session): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + evs = [make_event(status="ok") for _ in range(5)] + r = await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers + ) + assert r.status_code == 202 + assert r.json() == {"accepted": 1, "deduplicated": 4} + n = (await session.execute(select(func.count()).select_from(Event))).scalar_one() + assert n == 1 + + +@pytest.mark.asyncio +async def test_status_transitions_stored(app_client, auth_headers, session): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + seq = [ + make_event(status="ok"), + make_event(status="warning", exit_code=1), + make_event(status="warning", exit_code=1), + make_event(status="critical", exit_code=2), + make_event(status="ok"), + ] + r = await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": seq}, headers=auth_headers + ) + assert r.status_code == 202 + assert r.json() == {"accepted": 4, "deduplicated": 1} + n = (await session.execute(select(func.count()).select_from(Event))).scalar_one() + assert n == 4 + + +@pytest.mark.asyncio +async def test_same_status_different_exit_code_stored(app_client, auth_headers, session): + await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) + seq = [ + make_event(status="warning", exit_code=1), + make_event(status="warning", exit_code=3), + ] + r = await app_client.post( + "/api/v1/events", json={"agent_id": "agent-1", "events": seq}, headers=auth_headers + ) + assert r.json() == {"accepted": 2, "deduplicated": 0} + n = (await session.execute(select(func.count()).select_from(Event))).scalar_one() + assert n == 2 + + @pytest.mark.asyncio async def test_late_event_does_not_move_state(app_client, auth_headers, session): from datetime import UTC, datetime, timedelta @@ -71,7 +119,9 @@ async def test_late_event_does_not_move_state(app_client, auth_headers, session) await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) newer = datetime.now(UTC).replace(microsecond=0) older = (newer - timedelta(minutes=5)).isoformat() - ev_now = make_event(status="critical", exit_code=2, observed_at=newer.isoformat()) + ev_now = make_event( + status="critical", exit_code=2, observed_at=newer.isoformat(), output="current critical" + ) ev_old = make_event(status="ok", exit_code=0, observed_at=older) await app_client.post( "/api/v1/events", json={"agent_id": "agent-1", "events": [ev_now]}, headers=auth_headers @@ -81,5 +131,6 @@ async def test_late_event_does_not_move_state(app_client, auth_headers, session) ) chk = (await session.execute(select(Check))).scalar_one() assert chk.status == "critical" + assert chk.summary == ev_now["output"] n = (await session.execute(select(func.count()).select_from(Event))).scalar_one() - assert n == 2 + assert n == 1 diff --git a/server/tests/test_incident_key.py b/server/tests/test_incident_key.py index e616bdb..a580db3 100644 --- a/server/tests/test_incident_key.py +++ b/server/tests/test_incident_key.py @@ -35,7 +35,7 @@ async def test_incident_key_too_long(app_client, auth_headers): @pytest.mark.asyncio async def test_events_query_cursor_pagination(app_client, auth_headers): await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers) - evs = [make_event() for _ in range(5)] + evs = [make_event(check_id=f"chk-{i}") for i in range(5)] await app_client.post( "/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers ) diff --git a/web/src/app/agents/[agent_id]/page.tsx b/web/src/app/agents/[agent_id]/page.tsx index c8fcd48..48e0fc7 100644 --- a/web/src/app/agents/[agent_id]/page.tsx +++ b/web/src/app/agents/[agent_id]/page.tsx @@ -1,55 +1,14 @@ -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 { statusBadgeClass } from "@/lib/format"; +import { AgentDetailBrowser } from "@/components/AgentDetailBrowser"; +import { ErrorPanel } from "@/components/DataPanel"; +import { ApiError, api } from "@/lib/api"; +import { withLivenessEvents } from "@/lib/check-groups"; +import { loadChecks } from "@/lib/loaders"; export const dynamic = "force-dynamic"; type SortKey = "severity" | "check_id" | "last_seen"; type TabKey = "checks" | "events"; -type LoadResult = - | { kind: "ok"; agent: Agent; checks: CheckState[]; events: StoredEvent[] } - | { kind: "not_found" } - | { kind: "error"; error: unknown }; - -const severityRank: Record = { - critical: 0, - warning: 1, - unknown: 2, - ok: 3, -}; - -async function load(agentId: string, sort: SortKey, tab: TabKey): Promise { - try { - const [agent, checks, events] = await Promise.all([ - api.getAgent(agentId), - tab === "checks" ? loadChecks(agentId).then((items) => sortChecks(items, sort)) : [], - tab === "events" ? api.queryEvents({ agent_id: agentId, limit: 50 }).then((page) => page.items) : [], - ]); - return { kind: "ok", agent, checks, events }; - } catch (e) { - if (e instanceof ApiError && e.status === 404) return { kind: "not_found" }; - return { kind: "error", error: e }; - } -} - -async function loadChecks(agentId: string): Promise { - const checks: CheckState[] = []; - let cursor: string | undefined; - do { - const page = await api.listChecks({ agent_id: agentId, cursor, limit: 500 }); - checks.push(...page.items); - cursor = page.next_cursor ?? undefined; - } while (cursor); - return checks; -} - function normalizeSort(sort?: string): SortKey { return sort === "check_id" || sort === "last_seen" ? sort : "severity"; } @@ -58,14 +17,6 @@ function normalizeTab(tab?: string): TabKey { return tab === "events" ? "events" : "checks"; } -function sortChecks(checks: CheckState[], sort: SortKey): CheckState[] { - return [...checks].sort((a, b) => { - if (sort === "check_id") return a.check_id.localeCompare(b.check_id); - if (sort === "last_seen") return b.last_observed_at.localeCompare(a.last_observed_at); - return severityRank[a.status] - severityRank[b.status] || a.check_id.localeCompare(b.check_id); - }); -} - export default async function AgentDetailPage({ params, searchParams, @@ -77,163 +28,27 @@ export default async function AgentDetailPage({ const sp = await searchParams; const sort = normalizeSort(sp.sort); const tab = normalizeTab(sp.tab); - const r = await load(agent_id, sort, tab); - if (r.kind === "not_found") return
Agent not found.
; - if (r.kind === "error") return ; - const { agent, checks, events } = r; - return ( -
-
- - ← agents - -

{agent.agent_id}

-
-
-
hostname
-
{agent.hostname}
-
status
-
{agent.status}
-
last_seen_at
-
- -
-
features
-
- -
-
labels
-
- -
-
- - {tab === "checks" ? : } -
- ); -} -function TabNav({ agentId, current, sort }: { agentId: string; current: TabKey; sort: SortKey }) { - const base = `/agents/${encodeURIComponent(agentId)}`; + let data; + try { + const [agent, checks, events] = await Promise.all([ + api.getAgent(agent_id), + loadChecks(agent_id), + api.queryEvents({ agent_id, limit: 50 }).then((page) => page.items), + ]); + data = { agent, checks, events: withLivenessEvents([agent], events, { agentId: agent_id }) }; + } catch (e) { + if (e instanceof ApiError && e.status === 404) { + return
Agent not found.
; + } + return ; + } return ( -
- - Checks - - - Events - -
- ); -} - -function ChecksTable({ - agentId, - checks, - sort, -}: { - agentId: string; - checks: CheckState[]; - sort: SortKey; -}) { - if (checks.length === 0) return
No checks.
; - return ( -
- - - - - - - - - - - {checks.map((c) => ( - - - - - - - ))} - -
- - check_id - - - - status - - exit - - last_observed_at - -
- - {c.status}{c.exit_code ?? "—"} - -
-
- ); -} - -function EventsTable({ events }: { events: StoredEvent[] }) { - if (events.length === 0) return
No events.
; - return ( -
- - - - - - - - - - - {events.map((e) => ( - - - - - - - ))} - -
observed_atcheck_idstatusduration_ms
- - - - {e.status}{e.duration_ms}
-
- ); -} - -function SortLink({ - agentId, - sort, - current, - children, -}: { - agentId: string; - sort: SortKey; - current: SortKey; - children: React.ReactNode; -}) { - return ( - - {children} - + ); } diff --git a/web/src/app/agents/page.tsx b/web/src/app/agents/page.tsx index 0dabd98..809180e 100644 --- a/web/src/app/agents/page.tsx +++ b/web/src/app/agents/page.tsx @@ -2,51 +2,10 @@ import { redirect } from "next/navigation"; import { AgentsBrowser } from "@/components/AgentsBrowser"; import { ErrorPanel } from "@/components/DataPanel"; -import { api, type Agent, type CheckState } from "@/lib/api"; +import { loadInventory } from "@/lib/loaders"; export const dynamic = "force-dynamic"; -type CheckStatus = CheckState["status"]; -type CheckCounts = Record; - -async function load() { - const [agents, checksByAgent] = await Promise.all([ - loadAgents(), - loadCheckCounts(), - ]); - return { agents, checksByAgent }; -} - -async function loadAgents(): Promise { - const agents: Agent[] = []; - let cursor: string | undefined; - do { - const page = await api.listAgents({ cursor, limit: 500 }); - agents.push(...page.items); - cursor = page.next_cursor ?? undefined; - } while (cursor); - return agents; -} - -async function loadCheckCounts(): Promise> { - const byAgent = new Map(); - let cursor: string | undefined; - do { - const page = await api.listChecks({ cursor, limit: 500 }); - for (const c of page.items) { - const counts = byAgent.get(c.agent_id) ?? emptyCheckCounts(); - counts[c.status] += 1; - byAgent.set(c.agent_id, counts); - } - cursor = page.next_cursor ?? undefined; - } while (cursor); - return byAgent; -} - -function emptyCheckCounts(): CheckCounts { - return { ok: 0, warning: 0, critical: 0, unknown: 0 }; -} - export default async function AgentsPage({ searchParams, }: { @@ -55,17 +14,11 @@ export default async function AgentsPage({ const sp = await searchParams; if (Object.keys(sp).length > 0) redirect("/agents"); - let data: Awaited>; + let data; try { - data = await load(); + data = await loadInventory(); } catch (e) { return ; } - - const rows = data.agents.map((agent) => ({ - agent, - checks: data.checksByAgent.get(agent.agent_id) ?? emptyCheckCounts(), - })); - - return ; + return ; } diff --git a/web/src/app/api/poll/agent/route.ts b/web/src/app/api/poll/agent/route.ts new file mode 100644 index 0000000..ccfa303 --- /dev/null +++ b/web/src/app/api/poll/agent/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server"; + +import { ApiError, api } from "@/lib/api"; +import { withLivenessEvents } from "@/lib/check-groups"; +import { loadChecks } from "@/lib/loaders"; +import { jsonError } from "@/lib/poll-response"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const agentId = url.searchParams.get("agent_id"); + + if (!agentId) { + return NextResponse.json( + { error: { code: "validation", message: "agent_id is required" } }, + { status: 400 }, + ); + } + + try { + const [agent, checks, events] = await Promise.all([ + api.getAgent(agentId), + loadChecks(agentId), + api.queryEvents({ agent_id: agentId, limit: 50 }).then((page) => page.items), + ]); + return NextResponse.json({ + agent, + checks, + events: withLivenessEvents([agent], events, { agentId }), + }); + } catch (e) { + if (e instanceof ApiError && e.status === 404) { + return NextResponse.json( + { error: { code: e.code, message: e.message } }, + { status: 404 }, + ); + } + return jsonError(e); + } +} diff --git a/web/src/app/api/poll/events/route.ts b/web/src/app/api/poll/events/route.ts new file mode 100644 index 0000000..281f7e7 --- /dev/null +++ b/web/src/app/api/poll/events/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; + +import { loadEventsWithLiveness } from "@/lib/loaders"; +import { jsonError } from "@/lib/poll-response"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const limit = Number(url.searchParams.get("limit") ?? "100"); + + try { + const data = await loadEventsWithLiveness({ + agentId: url.searchParams.get("agent_id") ?? undefined, + checkId: url.searchParams.get("check_id") ?? undefined, + cursor: url.searchParams.get("cursor") ?? undefined, + limit: Number.isFinite(limit) ? limit : 100, + }); + return NextResponse.json(data); + } catch (e) { + return jsonError(e); + } +} diff --git a/web/src/app/api/poll/incidents/route.ts b/web/src/app/api/poll/incidents/route.ts new file mode 100644 index 0000000..ff96949 --- /dev/null +++ b/web/src/app/api/poll/incidents/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; + +import { loadIncidents } from "@/lib/loaders"; +import { jsonError } from "@/lib/poll-response"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const stateParam = url.searchParams.get("state"); + const state = stateParam === "open" || stateParam === "resolved" ? stateParam : undefined; + + try { + const data = await loadIncidents(state); + return NextResponse.json(data); + } catch (e) { + return jsonError(e); + } +} diff --git a/web/src/app/api/poll/inventory/route.ts b/web/src/app/api/poll/inventory/route.ts new file mode 100644 index 0000000..4d1d5ce --- /dev/null +++ b/web/src/app/api/poll/inventory/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +import { loadInventory } from "@/lib/loaders"; +import { jsonError } from "@/lib/poll-response"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + return NextResponse.json(await loadInventory()); + } catch (e) { + return jsonError(e); + } +} diff --git a/web/src/app/api/poll/outbox/route.ts b/web/src/app/api/poll/outbox/route.ts new file mode 100644 index 0000000..65d9222 --- /dev/null +++ b/web/src/app/api/poll/outbox/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; + +import { api } from "@/lib/api"; +import { jsonError } from "@/lib/poll-response"; + +export const dynamic = "force-dynamic"; + +export async function GET(request: Request) { + const url = new URL(request.url); + + try { + const data = await api.listOutbox({ + cursor: url.searchParams.get("cursor") ?? undefined, + }); + return NextResponse.json(data); + } catch (e) { + return jsonError(e); + } +} diff --git a/web/src/app/api/poll/overview/route.ts b/web/src/app/api/poll/overview/route.ts new file mode 100644 index 0000000..91c9a7f --- /dev/null +++ b/web/src/app/api/poll/overview/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +import { loadOverview } from "@/lib/loaders"; +import { jsonError } from "@/lib/poll-response"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + return NextResponse.json(await loadOverview()); + } catch (e) { + return jsonError(e); + } +} diff --git a/web/src/app/checks/[check_id]/page.tsx b/web/src/app/checks/[check_id]/page.tsx new file mode 100644 index 0000000..789eb9b --- /dev/null +++ b/web/src/app/checks/[check_id]/page.tsx @@ -0,0 +1,51 @@ +import { CheckDetailBrowser } from "@/components/CheckDetailBrowser"; +import { ErrorPanel } from "@/components/DataPanel"; +import { api } from "@/lib/api"; +import { withLivenessEvents } from "@/lib/check-groups"; +import { loadInventory } from "@/lib/loaders"; + +export const dynamic = "force-dynamic"; + +type TabKey = "agents" | "events"; +type EventsPage = Awaited>; + +function normalizeTab(tab?: string): TabKey { + return tab === "events" ? "events" : "agents"; +} + +export default async function CheckDetailPage({ + params, + searchParams, +}: { + params: Promise<{ check_id: string }>; + searchParams: Promise<{ tab?: string }>; +}) { + const { check_id } = await params; + const sp = await searchParams; + const tab = normalizeTab(sp.tab); + + let data; + let events: EventsPage = { items: [], next_cursor: null }; + try { + data = await loadInventory(); + if (tab === "events") { + const rawEvents = await api.queryEvents({ check_id, limit: 50 }); + events = { + ...rawEvents, + items: withLivenessEvents(data.agents, rawEvents.items, { checkId: check_id }), + }; + } + } catch (e) { + return ; + } + + return ( + + ); +} diff --git a/web/src/app/checks/page.tsx b/web/src/app/checks/page.tsx index fa97f95..0d90d53 100644 --- a/web/src/app/checks/page.tsx +++ b/web/src/app/checks/page.tsx @@ -1,69 +1,15 @@ -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 { statusBadgeClass } from "@/lib/format"; +import { ChecksBrowser } from "@/components/ChecksBrowser"; +import { ErrorPanel } from "@/components/DataPanel"; +import { loadInventory } from "@/lib/loaders"; export const dynamic = "force-dynamic"; -export default async function ChecksPage({ - searchParams, -}: { - searchParams: Promise<{ cursor?: string }>; -}) { - const sp = await searchParams; +export default async function ChecksPage() { let data; try { - data = await api.listChecks({ cursor: sp.cursor }); + data = await loadInventory(); } catch (e) { return ; } - return ( - <> - - {data.items.length === 0 ? ( - - ) : ( - - - - - - - - - - - - - {data.items.map((c) => ( - - - - - - - - - ))} - -
agent_idcheck_idstatusexitlast_observed_atincident_key
- - {c.agent_id} - - - - {c.status}{c.exit_code ?? "—"} - - {c.incident_key ?? "—"}
- )} - - - ); + return ; } diff --git a/web/src/app/events/page.tsx b/web/src/app/events/page.tsx index d21aec3..caf628a 100644 --- a/web/src/app/events/page.tsx +++ b/web/src/app/events/page.tsx @@ -1,11 +1,6 @@ -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 { statusBadgeClass } from "@/lib/format"; +import { ErrorPanel } from "@/components/DataPanel"; +import { EventsBrowser } from "@/components/EventsBrowser"; +import { loadEventsWithLiveness } from "@/lib/loaders"; export const dynamic = "force-dynamic"; @@ -17,102 +12,21 @@ export default async function EventsPage({ const sp = await searchParams; let data; try { - data = await api.queryEvents({ - agent_id: sp.agent_id, - check_id: sp.check_id, + data = await loadEventsWithLiveness({ + agentId: sp.agent_id, + checkId: sp.check_id, cursor: sp.cursor, }); } catch (e) { return ; } return ( - <> - - - {data.items.length === 0 ? ( - - ) : ( - - - - - - - - - - - - - - - {data.items.map((e) => ( - - - - - - - - - - - ))} - -
observed_atreceived_atagent_idcheck_idstatusexitduration_msoutput
- - - - {e.agent_id} - - {e.status}{e.exit_code}{e.duration_ms}{e.output ?? "—"}
- )} - - + ); } - -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 8c6d112..74ad4fa 100644 --- a/web/src/app/incidents/page.tsx +++ b/web/src/app/incidents/page.tsx @@ -1,89 +1,29 @@ -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 { statusBadgeClass } from "@/lib/format"; +import { ErrorPanel } from "@/components/DataPanel"; +import { IncidentsBrowser } from "@/components/IncidentsBrowser"; +import { loadIncidents, loadInventory } from "@/lib/loaders"; export const dynamic = "force-dynamic"; -const STATES = ["all", "open", "resolved"] as const; - export default async function IncidentsPage({ searchParams, }: { - searchParams: Promise<{ cursor?: string; state?: string }>; + searchParams: Promise<{ state?: string }>; }) { const sp = await searchParams; const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined; let data; + let inventory; try { - data = await api.listIncidents({ state, cursor: sp.cursor }); + [data, inventory] = await Promise.all([loadIncidents(state), loadInventory()]); } catch (e) { return ; } return ( - <> - -
- {STATES.map((s) => { - const href = s === "all" ? "/incidents" : `/incidents?state=${s}`; - const active = (s === "all" && !state) || s === state; - return ( - - {s} - - ); - })} -
- {data.items.length === 0 ? ( - - ) : ( - - - - - - - - - - - - - - {data.items.map((i) => ( - - - - - - - - - - ))} - -
opened_atstateseverityagent_idcheck_idresolved_atsummary
- - {i.state}{i.severity}{i.agent_id ?? "—"} - - - - {i.summary ?? "—"}
- )} - - + ); } diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 4474bbc..d4204e7 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -1,6 +1,5 @@ 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"; @@ -19,16 +18,6 @@ const NAV = [ { href: "/outbox", label: "Outbox" }, ]; -const DEFAULT_REFRESH_MS = 10_000; - -function getRefreshIntervalMs(): number { - const raw = process.env.NEXT_PUBLIC_MONLET_REFRESH_MS?.trim(); - if (!raw) return DEFAULT_REFRESH_MS; - - const parsed = Number(raw); - return Number.isFinite(parsed) ? parsed : DEFAULT_REFRESH_MS; -} - function Brand() { return ( @@ -47,7 +36,6 @@ function Brand() { } export default function RootLayout({ children }: { children: React.ReactNode }) { - const refreshIntervalMs = getRefreshIntervalMs(); const configuredTimeZone = getUiTimeZone(); return ( @@ -66,7 +54,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
{children}
- {refreshIntervalMs > 0 && } diff --git a/web/src/app/outbox/page.tsx b/web/src/app/outbox/page.tsx index d00399b..47d9047 100644 --- a/web/src/app/outbox/page.tsx +++ b/web/src/app/outbox/page.tsx @@ -1,8 +1,6 @@ -import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel"; -import { Pager } from "@/components/Pager"; -import { DateTime } from "@/components/TimeZoneControls"; +import { ErrorPanel } from "@/components/DataPanel"; +import { OutboxBrowser } from "@/components/OutboxBrowser"; import { api } from "@/lib/api"; -import { statusBadgeClass } from "@/lib/format"; export const dynamic = "force-dynamic"; @@ -18,44 +16,5 @@ export default async function OutboxPage({ } catch (e) { return ; } - return ( - <> - - {data.items.length === 0 ? ( - - ) : ( - - - - - - - - - - - - - - {data.items.map((o) => ( - - - - - - - - - - ))} - -
updated_atnotifiereventstateattemptsnext_attempt_atlast_error
- - {o.notifier}{o.event_type}{o.state}{o.attempts} - - {o.last_error ?? "—"}
- )} - - - ); + return ; } diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index 650e23b..eebc291 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -1,81 +1,15 @@ -import Link from "next/link"; - import { ErrorPanel } from "@/components/DataPanel"; -import { api } from "@/lib/api"; -import { statusBadgeClass } from "@/lib/format"; +import { OverviewDashboard } from "@/components/OverviewDashboard"; +import { loadOverview } from "@/lib/loaders"; export const dynamic = "force-dynamic"; -async function load() { - const [agents, checks, incidents] = await Promise.all([ - api.listAgents({ limit: 500 }), - api.listChecks({ limit: 500 }), - api.listIncidents({ state: "open", limit: 500 }), - ]); - const tally = (xs: { status?: string }[]) => - xs.reduce>((acc, x) => { - const k = x.status ?? "unknown"; - acc[k] = (acc[k] ?? 0) + 1; - return acc; - }, {}); - return { - agents: tally(agents.items), - checks: tally(checks.items), - openIncidents: incidents.items.length, - totalAgents: agents.items.length, - totalChecks: checks.items.length, - }; -} - export default async function OverviewPage() { - let data: Awaited>; + let data; try { - data = await load(); + data = await loadOverview(); } catch (e) { return ; } - return ( -
-

Overview

-
- - - 0 ? "open" : undefined} - /> -
-
- ); -} - -function Card({ - title, - total, - href, - tally, - highlight, -}: { - title: string; - total: number; - href: string; - tally: Record; - highlight?: string; -}) { - return ( - -
{title}
-
{total}
-
- {Object.entries(tally).map(([k, v]) => ( - - {k} {v} - - ))} -
- - ); + return ; } diff --git a/web/src/components/AgentDetailBrowser.tsx b/web/src/components/AgentDetailBrowser.tsx new file mode 100644 index 0000000..e1773db --- /dev/null +++ b/web/src/components/AgentDetailBrowser.tsx @@ -0,0 +1,216 @@ +"use client"; + +import Link from "next/link"; +import { useMemo } from "react"; + +import { AgentFeatures } from "@/components/AgentFeatures"; +import { CheckEventsLink } from "@/components/CheckEventsLink"; +import { Td, Th } from "@/components/DataPanel"; +import { LabelChips } from "@/components/Labels"; +import { DateTime } from "@/components/TimeZoneControls"; +import type { components } from "@/lib/api-types"; +import { withLivenessChecks } from "@/lib/check-groups"; +import { statusBadgeClass } from "@/lib/format"; +import { usePolling } from "@/lib/usePolling"; + +type Agent = components["schemas"]["Agent"]; +type CheckState = components["schemas"]["CheckState"]; +type StoredEvent = components["schemas"]["StoredCheckResultEvent"]; +type SortKey = "severity" | "check_id" | "last_seen"; +type TabKey = "checks" | "events"; +type AgentDetailData = { + agent: Agent; + checks: CheckState[]; + events: StoredEvent[]; +}; + +const severityRank: Record = { + critical: 0, + warning: 1, + unknown: 2, + ok: 3, +}; + +export function AgentDetailBrowser({ + initialData, + sort, + tab, +}: { + initialData: AgentDetailData; + sort: SortKey; + tab: TabKey; +}) { + const { data } = usePolling( + initialData, + `/api/poll/agent?agent_id=${encodeURIComponent(initialData.agent.agent_id)}`, + ); + const { agent, events } = data; + const checks = useMemo( + () => sortChecks(withLivenessChecks([agent], data.checks), sort), + [agent, data.checks, sort], + ); + + return ( +
+
+ + ← agents + +

{agent.agent_id}

+
+
+
hostname
+
{agent.hostname}
+
status
+
{agent.status}
+
last_seen_at
+
+ +
+
features
+
+ +
+
labels
+
+ +
+
+ + {tab === "checks" ? : } +
+ ); +} + +function TabNav({ agentId, current, sort }: { agentId: string; current: TabKey; sort: SortKey }) { + const base = `/agents/${encodeURIComponent(agentId)}`; + return ( +
+ + Checks + + + Events + +
+ ); +} + +function ChecksTable({ + agentId, + checks, + sort, +}: { + agentId: string; + checks: CheckState[]; + sort: SortKey; +}) { + if (checks.length === 0) return
No checks.
; + return ( +
+ + + + + + + + + + + {checks.map((c) => ( + + + + + + + ))} + +
+ + check_id + + + + status + + + + last_observed_at + + summary
+ + {c.status} + + {c.summary ?? "—"}
+
+ ); +} + +function EventsTable({ events }: { events: StoredEvent[] }) { + if (events.length === 0) return
No events.
; + return ( +
+ + + + + + + + + + + {events.map((e) => ( + + + + + + + ))} + +
observed_atcheck_idstatusduration_ms
+ + + + {e.status}{e.duration_ms}
+
+ ); +} + +function SortLink({ + agentId, + sort, + current, + children, +}: { + agentId: string; + sort: SortKey; + current: SortKey; + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} + +function sortChecks(checks: CheckState[], sort: SortKey): CheckState[] { + return [...checks].sort((a, b) => { + if (sort === "check_id") return a.check_id.localeCompare(b.check_id); + if (sort === "last_seen") return b.last_observed_at.localeCompare(a.last_observed_at); + return severityRank[a.status] - severityRank[b.status] || a.check_id.localeCompare(b.check_id); + }); +} diff --git a/web/src/components/AgentFeatures.tsx b/web/src/components/AgentFeatures.tsx index bd7d592..27a178b 100644 --- a/web/src/components/AgentFeatures.tsx +++ b/web/src/components/AgentFeatures.tsx @@ -1,4 +1,6 @@ -import type { Agent } from "@/lib/api"; +import type { components } from "@/lib/api-types"; + +type Agent = components["schemas"]["Agent"]; export function AgentFeatures({ features }: { features?: Agent["features"] }) { const enabled = enabledFeatures(features); diff --git a/web/src/components/AgentsBrowser.tsx b/web/src/components/AgentsBrowser.tsx index 414a729..a01be20 100644 --- a/web/src/components/AgentsBrowser.tsx +++ b/web/src/components/AgentsBrowser.tsx @@ -6,9 +6,13 @@ 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 type { components } from "@/lib/api-types"; import { statusBadgeClass } from "@/lib/format"; +import { usePolling } from "@/lib/usePolling"; +import type { InventoryData } from "@/lib/view-types"; +type Agent = components["schemas"]["Agent"]; +type CheckState = components["schemas"]["CheckState"]; type CheckStatus = CheckState["status"]; type CheckCounts = Record; type SortField = "host" | "status" | "last_seen_at"; @@ -20,10 +24,12 @@ const checkStatuses: CheckStatus[] = ["ok", "warning", "critical", "unknown"]; const statusRank: Record = { alive: 0, stale: 1, dead: 2 }; const checkSortStatuses: CheckStatus[] = ["critical", "warning", "unknown", "ok"]; -export function AgentsBrowser({ rows: initialRows }: { rows: AgentRow[] }) { +export function AgentsBrowser({ initialData }: { initialData: InventoryData }) { + const { data } = usePolling(initialData, "/api/poll/inventory"); const [query, setQuery] = useState(""); const [sort, setSort] = useState("status"); const [dir, setDir] = useState("desc"); + const initialRows = useMemo(() => rowsFromInventory(data), [data]); const rows = useMemo( () => sortAgents(filterAgents(initialRows, query), sort, dir), @@ -239,6 +245,19 @@ function emptyCheckCounts(): CheckCounts { return { ok: 0, warning: 0, critical: 0, unknown: 0 }; } +function rowsFromInventory(data: InventoryData): AgentRow[] { + const checksByAgent = new Map(); + for (const c of data.checks) { + const counts = checksByAgent.get(c.agent_id) ?? emptyCheckCounts(); + counts[c.status] += 1; + checksByAgent.set(c.agent_id, counts); + } + return data.agents.map((agent) => ({ + agent, + checks: checksByAgent.get(agent.agent_id) ?? emptyCheckCounts(), + })); +} + function shortStatus(status: CheckStatus): string { switch (status) { case "ok": diff --git a/web/src/components/AutoRefresh.tsx b/web/src/components/AutoRefresh.tsx deleted file mode 100644 index 7a2dd0c..0000000 --- a/web/src/components/AutoRefresh.tsx +++ /dev/null @@ -1,51 +0,0 @@ -"use client"; - -import { useRouter } from "next/navigation"; -import { useEffect } from "react"; - -export function AutoRefresh({ intervalMs }: { intervalMs: number }) { - const router = useRouter(); - - useEffect(() => { - if (intervalMs <= 0) return; - - let timer: ReturnType | undefined; - - const stop = () => { - if (timer !== undefined) { - clearInterval(timer); - timer = undefined; - } - }; - - const start = () => { - stop(); - timer = setInterval(() => { - if (document.visibilityState === "visible") { - router.refresh(); - } - }, intervalMs); - }; - - const handleVisibilityChange = () => { - if (document.visibilityState === "visible") { - router.refresh(); - start(); - } else { - stop(); - } - }; - - if (document.visibilityState === "visible") { - start(); - } - - document.addEventListener("visibilitychange", handleVisibilityChange); - return () => { - stop(); - document.removeEventListener("visibilitychange", handleVisibilityChange); - }; - }, [intervalMs, router]); - - return null; -} diff --git a/web/src/components/CheckDetailBrowser.tsx b/web/src/components/CheckDetailBrowser.tsx new file mode 100644 index 0000000..1b6222a --- /dev/null +++ b/web/src/components/CheckDetailBrowser.tsx @@ -0,0 +1,212 @@ +"use client"; + +import Link from "next/link"; +import { useMemo } from "react"; + +import { CheckStatusSummary } from "@/components/CheckStatusSummary"; +import { Td, Th } from "@/components/DataPanel"; +import { DateTime } from "@/components/TimeZoneControls"; +import type { components } from "@/lib/api-types"; +import { + groupChecks, + statusSeverity, + withLivenessChecks, + type Agent, + type CheckGroup, +} from "@/lib/check-groups"; +import { statusBadgeClass } from "@/lib/format"; +import { usePolling } from "@/lib/usePolling"; +import type { InventoryData } from "@/lib/view-types"; + +type StoredEvent = components["schemas"]["StoredCheckResultEvent"]; +type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null }; +type TabKey = "agents" | "events"; + +export function CheckDetailBrowser({ + checkId, + initialData, + initialEvents, + tab, +}: { + checkId: string; + initialData: InventoryData; + initialEvents: EventsPageData; + tab: TabKey; +}) { + const { data } = usePolling(initialData, "/api/poll/inventory"); + const agentsById = useMemo(() => new Map(data.agents.map((agent) => [agent.agent_id, agent])), [data.agents]); + const checks = useMemo( + () => withLivenessChecks(data.agents, data.checks), + [data.agents, data.checks], + ); + const group = useMemo( + () => groupChecks(checks, agentsById).find((item) => item.checkId === checkId), + [agentsById, checkId, checks], + ); + + if (!group) { + return ( +
+ + ← checks + +

{checkId}

+
Check not found.
+
+ ); + } + + return ( +
+
+ + ← checks + +

{checkId}

+
+
+
status
+
+ +
+
agents
+
{group.checks.length}
+
last_observed_at
+
+ +
+
+ + {tab === "agents" ? ( + + ) : ( + + )} +
+ ); +} + +function TabNav({ checkId, current }: { checkId: string; current: TabKey }) { + const base = `/checks/${encodeURIComponent(checkId)}`; + return ( +
+ + Agents + + + Events + +
+ ); +} + +function AgentsForCheck({ group }: { group: CheckGroup }) { + const rows = [...group.checks].sort( + (a, b) => + statusSeverity[b.check.status] - statusSeverity[a.check.status] || + hostName(a).localeCompare(hostName(b)), + ); + + return ( + + + + + + + + + + + {rows.map(({ check, agent }) => ( + + + + + + + ))} + +
HOSTSTATUSLAST_OBSERVED_ATSUMMARY
+ + {agent?.hostname || check.agent_id} + {agent && agent.hostname !== check.agent_id ? ( + {check.agent_id} + ) : null} + + {check.status} + + {check.summary ?? "—"}
+ ); +} + +function EventsForCheck({ + checkId, + initialData, + agentsById, +}: { + checkId: string; + initialData: EventsPageData; + agentsById: Map; +}) { + const { data } = usePolling( + initialData, + `/api/poll/events?check_id=${encodeURIComponent(checkId)}&limit=50`, + ); + + if (data.items.length === 0) return
No events.
; + + return ( + + + + + + + + + + + + + {data.items.map((event) => { + const agent = agentsById.get(event.agent_id); + return ( + + + + + + + + + ); + })} + +
OBSERVED_ATRECEIVED_ATHOSTSTATUSDURATION_MSSUMMARY
+ + + + + + {agent?.hostname || event.agent_id} + + {event.status}{event.duration_ms}{event.output ?? "—"}
+ ); +} + +function hostName(row: { agent?: Agent; check: { agent_id: string } }): string { + return row.agent?.hostname || row.check.agent_id; +} diff --git a/web/src/components/CheckStatusSummary.tsx b/web/src/components/CheckStatusSummary.tsx new file mode 100644 index 0000000..115f146 --- /dev/null +++ b/web/src/components/CheckStatusSummary.tsx @@ -0,0 +1,18 @@ +import type { CheckCounts } from "@/lib/check-groups"; +import { checkStatuses, shortStatus } from "@/lib/check-groups"; +import { statusBadgeClass } from "@/lib/format"; + +export function CheckStatusSummary({ counts }: { counts: CheckCounts }) { + return ( + + {checkStatuses.map((status, i) => ( + + {i > 0 ? / : null} + + {shortStatus(status)} {counts[status]} + + + ))} + + ); +} diff --git a/web/src/components/ChecksBrowser.tsx b/web/src/components/ChecksBrowser.tsx new file mode 100644 index 0000000..3a36fca --- /dev/null +++ b/web/src/components/ChecksBrowser.tsx @@ -0,0 +1,181 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; + +import { CheckStatusSummary } from "@/components/CheckStatusSummary"; +import { DateTime } from "@/components/TimeZoneControls"; +import { + compareStatusCounts, + groupChecks, + type CheckGroup, +} from "@/lib/check-groups"; +import { usePolling } from "@/lib/usePolling"; +import type { InventoryData } from "@/lib/view-types"; + +type SortField = "check" | "status" | "last_observed_at"; +type SortDir = "asc" | "desc"; + +export function ChecksBrowser({ initialData }: { initialData: InventoryData }) { + const { data } = usePolling(initialData, "/api/poll/inventory"); + const [query, setQuery] = useState(""); + const [sort, setSort] = useState("status"); + const [dir, setDir] = useState("desc"); + + const agentsById = useMemo(() => new Map(data.agents.map((agent) => [agent.agent_id, agent])), [data.agents]); + const groups = useMemo(() => groupChecks(data.checks, agentsById), [agentsById, data.checks]); + const rows = useMemo( + () => sortChecks(filterChecks(groups, query), sort, dir), + [dir, groups, query, sort], + ); + + const chooseSort = (field: SortField) => { + if (field === sort) { + setDir((current) => (current === "asc" ? "desc" : "asc")); + return; + } + setSort(field); + setDir(fieldDefaultDir(field)); + }; + + return ( + <> +
+

Checks

+ {rows.length} items +
+
+ setQuery(event.target.value)} + placeholder="search check" + className="min-w-0 flex-1 border border-neutral-800 bg-neutral-950 px-2 py-1.5 text-sm text-neutral-100 outline-none focus:border-neutral-500" + /> + {query ? ( + + ) : null} +
+ {rows.length === 0 ? ( +
No data.
+ ) : ( + + + + + + + + + + + {rows.map((row) => ( + + + + + + + ))} + +
+ + CHECK + + + + STATUS + + + + LAST_OBSERVED_AT + + AGENTS
+ + {row.checkId} + + + + + + {row.checks.length}
+ )} + + ); +} + +function Th({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) { + return {children}; +} + +function SortButton({ + field, + current, + dir, + onClick, + children, +}: { + field: SortField; + current: SortField; + dir: SortDir; + onClick: (field: SortField) => void; + children: React.ReactNode; +}) { + const active = field === current; + return ( + + ); +} + +function filterChecks(rows: CheckGroup[], query: string): CheckGroup[] { + const needle = query.trim().toLowerCase(); + if (!needle) return rows; + return rows.filter((row) => row.checkId.toLowerCase().includes(needle)); +} + +function fieldDefaultDir(field: SortField): SortDir { + return field === "check" ? "asc" : "desc"; +} + +function sortChecks(rows: CheckGroup[], sort: SortField, dir: SortDir): CheckGroup[] { + return [...rows].sort((a, b) => { + const primary = compareByField(a, b, sort); + if (primary !== 0) return dir === "asc" ? primary : -primary; + return a.checkId.localeCompare(b.checkId); + }); +} + +function compareByField(a: CheckGroup, b: CheckGroup, sort: SortField): number { + switch (sort) { + case "check": + return a.checkId.localeCompare(b.checkId); + case "last_observed_at": + return Date.parse(a.lastObservedAt) - Date.parse(b.lastObservedAt); + case "status": + return compareStatusCounts(a, b); + } +} diff --git a/web/src/components/DataPanel.tsx b/web/src/components/DataPanel.tsx index 1030907..7d901bd 100644 --- a/web/src/components/DataPanel.tsx +++ b/web/src/components/DataPanel.tsx @@ -1,5 +1,3 @@ -import { ApiError } from "@/lib/api"; - export function PageHeader({ title, count }: { title: string; count?: number }) { return (
@@ -11,7 +9,7 @@ export function PageHeader({ title, count }: { title: string; count?: number }) export function ErrorPanel({ error }: { error: unknown }) { const e = error instanceof Error ? error : new Error(String(error)); - const status = error instanceof ApiError ? error.status : "—"; + const status = hasStatus(error) ? error.status : "—"; return (
API error ({String(status)})
@@ -20,6 +18,10 @@ export function ErrorPanel({ error }: { error: unknown }) { ); } +function hasStatus(error: unknown): error is { status: number } { + return typeof error === "object" && error !== null && "status" in error; +} + export function EmptyPanel({ label = "No data" }: { label?: string }) { return
{label}.
; } diff --git a/web/src/components/EventsBrowser.tsx b/web/src/components/EventsBrowser.tsx new file mode 100644 index 0000000..0631ea6 --- /dev/null +++ b/web/src/components/EventsBrowser.tsx @@ -0,0 +1,136 @@ +"use client"; + +import Link from "next/link"; +import { useMemo } from "react"; + +import { CheckEventsLink } from "@/components/CheckEventsLink"; +import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel"; +import { Pager } from "@/components/Pager"; +import { DateTime } from "@/components/TimeZoneControls"; +import type { components } from "@/lib/api-types"; +import { statusBadgeClass } from "@/lib/format"; +import { usePolling } from "@/lib/usePolling"; + +type StoredEvent = components["schemas"]["StoredCheckResultEvent"]; +type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null }; + +export function EventsBrowser({ + initialData, + cursor, + agentId, + checkId, +}: { + initialData: EventsPageData; + cursor?: string; + agentId?: string; + checkId?: string; +}) { + const url = useMemo(() => eventsPollUrl({ cursor, agentId, checkId }), [agentId, checkId, cursor]); + const { data } = usePolling(initialData, url); + + return ( + <> + + + {data.items.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + {data.items.map((e) => ( + + + + + + + + + + ))} + +
observed_atreceived_atagent_idcheck_idstatusduration_mssummary
+ + + + {e.agent_id} + + {e.status}{e.duration_ms}{e.output ?? "—"}
+ )} + + + ); +} + +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"; +} + +function eventsPollUrl({ + cursor, + agentId, + checkId, +}: { + cursor?: string; + agentId?: string; + checkId?: string; +}) { + const qs = new URLSearchParams(); + if (cursor) qs.set("cursor", cursor); + if (agentId) qs.set("agent_id", agentId); + if (checkId) qs.set("check_id", checkId); + const query = qs.toString(); + return query ? `/api/poll/events?${query}` : "/api/poll/events"; +} diff --git a/web/src/components/IncidentsBrowser.tsx b/web/src/components/IncidentsBrowser.tsx new file mode 100644 index 0000000..dc157d3 --- /dev/null +++ b/web/src/components/IncidentsBrowser.tsx @@ -0,0 +1,276 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; + +import { CheckEventsLink } from "@/components/CheckEventsLink"; +import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel"; +import { DateTime } from "@/components/TimeZoneControls"; +import type { components } from "@/lib/api-types"; +import { statusBadgeClass } from "@/lib/format"; +import { usePolling } from "@/lib/usePolling"; +import type { InventoryData } from "@/lib/view-types"; + +const STATES = ["all", "open", "resolved"] as const; + +type Incident = components["schemas"]["Incident"]; +type Agent = components["schemas"]["Agent"]; +type IncidentsPageData = { items: Incident[] }; +type SortField = "status" | "host" | "check" | "opened_at" | "resolved_at"; +type SortDir = "asc" | "desc"; + +const stateRank: Record = { resolved: 0, open: 1 }; +const severityRank: Record = { unknown: 1, warning: 2, critical: 3 }; + +export function IncidentsBrowser({ + initialData, + initialInventory, + state, +}: { + initialData: IncidentsPageData; + initialInventory: InventoryData; + state?: "open" | "resolved"; +}) { + const url = useMemo(() => incidentsPollUrl({ state }), [state]); + const { data } = usePolling(initialData, url); + const { data: inventory } = usePolling(initialInventory, "/api/poll/inventory"); + const [sort, setSort] = useState("status"); + const [dir, setDir] = useState("desc"); + const agentsById = useMemo( + () => new Map(inventory.agents.map((agent) => [agent.agent_id, agent])), + [inventory.agents], + ); + const rows = useMemo( + () => sortIncidents(data.items, agentsById, sort, dir), + [agentsById, data.items, dir, sort], + ); + + const chooseSort = (field: SortField) => { + if (field === sort) { + setDir((current) => (current === "asc" ? "desc" : "asc")); + return; + } + setSort(field); + setDir(fieldDefaultDir(field)); + }; + + return ( + <> + +
+ {STATES.map((s) => { + const href = s === "all" ? "/incidents" : `/incidents?state=${s}`; + const active = (s === "all" && !state) || s === state; + return ( + + {s} + + ); + })} +
+ {rows.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + {rows.map(({ incident, agent }) => ( + + ))} + +
+ + STATUS + + + + HOST + + + + CHECK + + + + OPENED_AT + + + + RESOLVED_AT + + SUMMARY
+ )} + + ); +} + +function IncidentRow({ incident, agent }: { incident: Incident; agent?: Agent }) { + const agentId = incidentAgentId(incident); + const checkId = incidentCheckId(incident); + return ( + + +
+ {incident.state} + {incident.severity} +
+ + + {agentId ? ( + + {agent?.hostname || agentId} + {agent && agent.hostname !== agentId ? ( + {agentId} + ) : null} + + ) : ( + "—" + )} + + + + + + + + + + + {incident.summary ?? "—"} + + ); +} + +function IncidentCheckLink({ agentId, checkId }: { agentId: string; checkId: string }) { + if (!checkId) return ; + if (checkId === "agent_liveness") { + return ( + + {checkId} + + ); + } + return agentId ? : {checkId}; +} + +function SortButton({ + field, + current, + dir, + onClick, + children, +}: { + field: SortField; + current: SortField; + dir: SortDir; + onClick: (field: SortField) => void; + children: React.ReactNode; +}) { + const active = field === current; + return ( + + ); +} + +function fieldDefaultDir(field: SortField): SortDir { + return field === "host" || field === "check" ? "asc" : "desc"; +} + +function sortIncidents( + incidents: Incident[], + agentsById: Map, + sort: SortField, + dir: SortDir, +): { incident: Incident; agent?: Agent }[] { + return incidents + .map((incident) => ({ incident, agent: agentsById.get(incidentAgentId(incident)) })) + .sort((a, b) => { + const primary = compareByField(a, b, sort); + if (primary !== 0) return dir === "asc" ? primary : -primary; + return fallbackCompare(a, b); + }); +} + +function compareByField( + a: { incident: Incident; agent?: Agent }, + b: { incident: Incident; agent?: Agent }, + sort: SortField, +): number { + switch (sort) { + case "host": + return hostName(a).localeCompare(hostName(b)) || incidentAgentId(a.incident).localeCompare(incidentAgentId(b.incident)); + case "check": + return incidentCheckId(a.incident).localeCompare(incidentCheckId(b.incident)); + case "opened_at": + return Date.parse(a.incident.opened_at) - Date.parse(b.incident.opened_at); + case "resolved_at": + return dateValue(a.incident.resolved_at) - dateValue(b.incident.resolved_at); + case "status": + return compareIncidentImportance(a.incident, b.incident); + } +} + +function compareIncidentImportance(a: Incident, b: Incident): number { + return ( + stateRank[a.state] - stateRank[b.state] || + severityRank[a.severity] - severityRank[b.severity] || + Date.parse(a.opened_at) - Date.parse(b.opened_at) + ); +} + +function fallbackCompare( + a: { incident: Incident; agent?: Agent }, + b: { incident: Incident; agent?: Agent }, +): number { + return ( + compareIncidentImportance(a.incident, b.incident) || + hostName(a).localeCompare(hostName(b)) || + incidentCheckId(a.incident).localeCompare(incidentCheckId(b.incident)) + ); +} + +function dateValue(value?: string | null): number { + return value ? Date.parse(value) : 0; +} + +function hostName(row: { incident: Incident; agent?: Agent }): string { + return row.agent?.hostname || incidentAgentId(row.incident); +} + +function incidentAgentId(incident: Incident): string { + return incident.agent_id ?? ""; +} + +function incidentCheckId(incident: Incident): string { + return incident.check_id ?? ""; +} + +function incidentsPollUrl({ state }: { state?: string }) { + return state ? `/api/poll/incidents?state=${encodeURIComponent(state)}` : "/api/poll/incidents"; +} diff --git a/web/src/components/OutboxBrowser.tsx b/web/src/components/OutboxBrowser.tsx new file mode 100644 index 0000000..d99e1ee --- /dev/null +++ b/web/src/components/OutboxBrowser.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { useMemo } from "react"; + +import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel"; +import { Pager } from "@/components/Pager"; +import { DateTime } from "@/components/TimeZoneControls"; +import type { components } from "@/lib/api-types"; +import { statusBadgeClass } from "@/lib/format"; +import { usePolling } from "@/lib/usePolling"; + +type OutboxItem = components["schemas"]["NotificationOutboxItem"]; +type OutboxPageData = { items: OutboxItem[]; next_cursor?: string | null }; + +export function OutboxBrowser({ + initialData, + cursor, +}: { + initialData: OutboxPageData; + cursor?: string; +}) { + const url = useMemo(() => { + if (!cursor) return "/api/poll/outbox"; + return `/api/poll/outbox?cursor=${encodeURIComponent(cursor)}`; + }, [cursor]); + const { data } = usePolling(initialData, url); + + return ( + <> + + {data.items.length === 0 ? ( + + ) : ( + + + + + + + + + + + + + + {data.items.map((o) => ( + + + + + + + + + + ))} + +
updated_atnotifiereventstateattemptsnext_attempt_atlast_error
+ + {o.notifier}{o.event_type}{o.state}{o.attempts} + + {o.last_error ?? "—"}
+ )} + + + ); +} diff --git a/web/src/components/OverviewDashboard.tsx b/web/src/components/OverviewDashboard.tsx new file mode 100644 index 0000000..bbb0b9e --- /dev/null +++ b/web/src/components/OverviewDashboard.tsx @@ -0,0 +1,56 @@ +"use client"; + +import Link from "next/link"; + +import { statusBadgeClass } from "@/lib/format"; +import { usePolling } from "@/lib/usePolling"; +import type { OverviewData } from "@/lib/view-types"; + +export function OverviewDashboard({ initialData }: { initialData: OverviewData }) { + const { data } = usePolling(initialData, "/api/poll/overview"); + + return ( +
+

Overview

+
+ + + 0 ? "open" : undefined} + /> +
+
+ ); +} + +function Card({ + title, + total, + href, + tally, + highlight, +}: { + title: string; + total: number; + href: string; + tally: Record; + highlight?: string; +}) { + return ( + +
{title}
+
{total}
+
+ {Object.entries(tally).map(([k, v]) => ( + + {k} {v} + + ))} +
+ + ); +} diff --git a/web/src/lib/api-types.ts b/web/src/lib/api-types.ts index 3d3fead..37ed602 100644 --- a/web/src/lib/api-types.ts +++ b/web/src/lib/api-types.ts @@ -206,7 +206,10 @@ export interface components { }; HeartbeatRequest: { agent_id: string; - /** Format: date-time */ + /** + * Format: date-time + * @description UTC RFC3339 timestamp. Timezone is required; use `Z` or `+00:00`. Non-UTC offsets are rejected. + */ observed_at: string; hostname: string; features: components["schemas"]["AgentFeatures"]; @@ -236,7 +239,10 @@ export interface components { /** @description UUIDv7 string (lower-case, with dashes). See ADR-0006. */ event_id: string; check_id: string; - /** Format: date-time */ + /** + * Format: date-time + * @description UTC RFC3339 timestamp. Timezone is required; use `Z` or `+00:00`. Non-UTC offsets are rejected. + */ observed_at: string; /** @enum {string} */ status: "ok" | "warning" | "critical" | "unknown"; @@ -286,6 +292,7 @@ export interface components { last_observed_at: string; exit_code?: number; incident_key?: string; + summary?: string; }; Incident: { id: string; diff --git a/web/src/lib/check-groups.ts b/web/src/lib/check-groups.ts new file mode 100644 index 0000000..92d09ae --- /dev/null +++ b/web/src/lib/check-groups.ts @@ -0,0 +1,131 @@ +import type { components } from "@/lib/api-types"; + +export type Agent = components["schemas"]["Agent"]; +export type CheckState = components["schemas"]["CheckState"]; +export type StoredEvent = components["schemas"]["StoredCheckResultEvent"]; +export type CheckStatus = CheckState["status"]; +export type CheckCounts = Record; + +export type CheckGroup = { + checkId: string; + checks: Array<{ check: CheckState; agent?: Agent }>; + counts: CheckCounts; + lastObservedAt: string; +}; + +export const LIVENESS_CHECK_ID = "agent_liveness"; +export const checkStatuses: CheckStatus[] = ["ok", "warning", "critical", "unknown"]; + +export const statusSeverity: Record = { + ok: 0, + unknown: 1, + warning: 2, + critical: 3, +}; + +export function groupChecks(checks: CheckState[], agentsById: Map): CheckGroup[] { + const byCheck = new Map(); + for (const check of checks) { + const group = + byCheck.get(check.check_id) ?? + ({ + checkId: check.check_id, + checks: [], + counts: emptyCheckCounts(), + lastObservedAt: check.last_observed_at, + } satisfies CheckGroup); + group.checks.push({ check, agent: agentsById.get(check.agent_id) }); + group.counts[check.status] += 1; + if (Date.parse(check.last_observed_at) > Date.parse(group.lastObservedAt)) { + group.lastObservedAt = check.last_observed_at; + } + byCheck.set(check.check_id, group); + } + return [...byCheck.values()]; +} + +export function withLivenessChecks(agents: Agent[], checks: CheckState[]): CheckState[] { + const regularChecks = checks.filter((check) => check.check_id !== LIVENESS_CHECK_ID); + const livenessChecks = agents + .map(livenessCheckForAgent) + .filter((check): check is CheckState => check !== null); + return [...regularChecks, ...livenessChecks]; +} + +export function livenessCheckForAgent(agent: Agent): CheckState | null { + if (agent.status !== "stale" && agent.status !== "dead") return null; + const status: CheckStatus = agent.status === "dead" ? "critical" : "warning"; + return { + agent_id: agent.agent_id, + check_id: LIVENESS_CHECK_ID, + status, + last_observed_at: agent.last_seen_at, + exit_code: status === "critical" ? 2 : 1, + incident_key: `agent:${agent.agent_id}:liveness`, + summary: `agent is ${agent.status}`, + }; +} + +export function withLivenessEvents( + agents: Agent[], + events: StoredEvent[], + filters: { agentId?: string; checkId?: string; cursor?: string } = {}, +): StoredEvent[] { + if (filters.cursor) return events; + const livenessEvents = agents + .map(livenessEventForAgent) + .filter((event): event is StoredEvent => event !== null) + .filter((event) => { + if (filters.agentId && event.agent_id !== filters.agentId) return false; + if (filters.checkId && event.check_id !== filters.checkId) return false; + return true; + }); + return [...events, ...livenessEvents].sort((a, b) => b.observed_at.localeCompare(a.observed_at)); +} + +export function livenessEventForAgent(agent: Agent): StoredEvent | null { + const check = livenessCheckForAgent(agent); + if (!check) return null; + // Synthetic event represents the current liveness state, not the last heartbeat, + // so observed_at uses "now" to keep it at the top of the events list across polls. + const now = new Date().toISOString(); + return { + event_id: `synthetic:${LIVENESS_CHECK_ID}:${agent.agent_id}:${agent.status}:${agent.last_seen_at}`, + agent_id: agent.agent_id, + check_id: LIVENESS_CHECK_ID, + observed_at: now, + received_at: now, + status: check.status, + exit_code: check.status === "critical" ? 2 : 1, + duration_ms: 0, + output: check.summary, + output_truncated: false, + notifications_enabled: false, + incident_key: check.incident_key, + }; +} + +export function emptyCheckCounts(): CheckCounts { + return { ok: 0, warning: 0, critical: 0, unknown: 0 }; +} + +export function compareStatusCounts(a: CheckGroup, b: CheckGroup): number { + for (const status of ["critical", "warning", "unknown", "ok"] satisfies CheckStatus[]) { + const diff = a.counts[status] - b.counts[status]; + if (diff !== 0) return diff; + } + return 0; +} + +export function shortStatus(status: CheckStatus): string { + switch (status) { + case "ok": + return "ok"; + case "warning": + return "warn"; + case "critical": + return "crit"; + case "unknown": + return "unknown"; + } +} diff --git a/web/src/lib/loaders.ts b/web/src/lib/loaders.ts new file mode 100644 index 0000000..a6c4291 --- /dev/null +++ b/web/src/lib/loaders.ts @@ -0,0 +1,102 @@ +import "server-only"; + +import { ApiError, api, type Agent, type CheckState, type StoredEvent } from "@/lib/api"; +import type { components } from "@/lib/api-types"; +import { LIVENESS_CHECK_ID, withLivenessChecks, withLivenessEvents } from "@/lib/check-groups"; +import type { InventoryData, OverviewData } from "@/lib/view-types"; + +type Incident = components["schemas"]["Incident"]; + +export async function loadAgents(): Promise { + const agents: Agent[] = []; + let cursor: string | undefined; + do { + const page = await api.listAgents({ cursor, limit: 500 }); + agents.push(...page.items); + cursor = page.next_cursor ?? undefined; + } while (cursor); + return agents; +} + +export async function loadChecks(agentId?: string): Promise { + const checks: CheckState[] = []; + let cursor: string | undefined; + do { + const page = await api.listChecks({ agent_id: agentId, cursor, limit: 500 }); + checks.push(...page.items); + cursor = page.next_cursor ?? undefined; + } while (cursor); + return checks; +} + +export async function loadInventory(): Promise { + const [agents, checks] = await Promise.all([loadAgents(), loadChecks()]); + return { agents, checks: withLivenessChecks(agents, checks) }; +} + +export async function loadEventsWithLiveness({ + agentId, + checkId, + cursor, + limit, +}: { + agentId?: string; + checkId?: string; + cursor?: string; + limit?: number; +} = {}): Promise<{ items: StoredEvent[]; next_cursor?: string | null }> { + const data = await api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, limit }); + if (cursor || (checkId && checkId !== LIVENESS_CHECK_ID)) return data; + + const agents = agentId ? await loadOptionalAgent(agentId) : await loadAgents(); + return { + ...data, + items: withLivenessEvents(agents, data.items, { agentId, checkId, cursor }), + }; +} + +async function loadOptionalAgent(agentId: string): Promise { + try { + return [await api.getAgent(agentId)]; + } catch (e) { + if (e instanceof ApiError && e.status === 404) return []; + throw e; + } +} + +export async function loadIncidents( + state?: "open" | "resolved", +): Promise<{ items: Incident[] }> { + const items: Incident[] = []; + let cursor: string | undefined; + do { + const page = await api.listIncidents({ state, cursor, limit: 500 }); + items.push(...page.items); + cursor = page.next_cursor ?? undefined; + } while (cursor); + return { items }; +} + +export async function loadOverview(): Promise { + const [agents, checks, incidents] = await Promise.all([ + loadAgents(), + loadChecks(), + api.listIncidents({ state: "open", limit: 500 }), + ]); + const checksWithLiveness = withLivenessChecks(agents, checks); + return { + agents: tally(agents), + checks: tally(checksWithLiveness), + openIncidents: incidents.items.length, + totalAgents: agents.length, + totalChecks: checksWithLiveness.length, + }; +} + +function tally(xs: { status?: string }[]): Record { + return xs.reduce>((acc, x) => { + const k = x.status ?? "unknown"; + acc[k] = (acc[k] ?? 0) + 1; + return acc; + }, {}); +} diff --git a/web/src/lib/poll-response.ts b/web/src/lib/poll-response.ts new file mode 100644 index 0000000..b5dc369 --- /dev/null +++ b/web/src/lib/poll-response.ts @@ -0,0 +1,18 @@ +import "server-only"; + +import { NextResponse } from "next/server"; + +import { ApiError } from "@/lib/api"; + +export function jsonError(e: unknown) { + if (e instanceof ApiError) { + return NextResponse.json( + { error: { code: e.code, message: e.message } }, + { status: e.status }, + ); + } + return NextResponse.json( + { error: { code: "internal_error", message: "internal server error" } }, + { status: 500 }, + ); +} diff --git a/web/src/lib/usePolling.ts b/web/src/lib/usePolling.ts new file mode 100644 index 0000000..16c3c58 --- /dev/null +++ b/web/src/lib/usePolling.ts @@ -0,0 +1,88 @@ +"use client"; + +import { useEffect, useState } from "react"; + +const DEFAULT_POLL_MS = 10_000; + +export function usePolling(initialData: T, url: string, intervalMs = pollIntervalMs()) { + const [data, setData] = useState(initialData); + const [error, setError] = useState(null); + + useEffect(() => { + if (intervalMs <= 0) return; + + let timer: ReturnType | undefined; + let inFlight = false; + let stopped = false; + let controller: AbortController | undefined; + + const refresh = () => { + if (stopped || inFlight || document.visibilityState !== "visible") return; + inFlight = true; + controller = new AbortController(); + + fetch(url, { cache: "no-store", signal: controller.signal }) + .then(async (response) => { + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error(body?.error?.message ?? response.statusText); + } + return response.json() as Promise; + }) + .then((body) => { + setData(body); + setError(null); + }) + .catch((e: unknown) => { + if (!controller?.signal.aborted) { + setError(e instanceof Error ? e.message : String(e)); + } + }) + .finally(() => { + inFlight = false; + }); + }; + + const start = () => { + stopTimer(); + timer = setInterval(refresh, intervalMs); + }; + + const stopTimer = () => { + if (timer !== undefined) { + clearInterval(timer); + timer = undefined; + } + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + start(); + refresh(); + } else { + stopTimer(); + controller?.abort(); + } + }; + + if (document.visibilityState === "visible") start(); + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + stopped = true; + stopTimer(); + controller?.abort(); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [intervalMs, url]); + + return { data, error }; +} + +export function pollIntervalMs() { + const raw = process.env.NEXT_PUBLIC_MONLET_POLL_MS?.trim(); + if (!raw) return DEFAULT_POLL_MS; + + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : DEFAULT_POLL_MS; +} diff --git a/web/src/lib/view-types.ts b/web/src/lib/view-types.ts new file mode 100644 index 0000000..1096cfa --- /dev/null +++ b/web/src/lib/view-types.ts @@ -0,0 +1,17 @@ +import type { components } from "@/lib/api-types"; + +type Agent = components["schemas"]["Agent"]; +type CheckState = components["schemas"]["CheckState"]; + +export type InventoryData = { + agents: Agent[]; + checks: CheckState[]; +}; + +export type OverviewData = { + agents: Record; + checks: Record; + openIncidents: number; + totalAgents: number; + totalChecks: number; +}; diff --git a/web/tests/mock-server.mjs b/web/tests/mock-server.mjs index f8ae7ad..901103f 100644 --- a/web/tests/mock-server.mjs +++ b/web/tests/mock-server.mjs @@ -19,7 +19,7 @@ const fixtures = { { agent_id: "agent-2", hostname: "host-2", - status: "stale", + status: "dead", last_seen_at: now, features: { push: true, metrics: false }, labels: {}, @@ -44,6 +44,7 @@ const fixtures = { exit_code: 0, last_observed_at: now, incident_key: "agent-1:disk", + summary: "disk ok", }, { agent_id: "agent-1", @@ -52,6 +53,7 @@ const fixtures = { exit_code: 1, last_observed_at: now, incident_key: "agent-1:load", + summary: "load high", }, ], next_cursor: null, @@ -68,6 +70,27 @@ const fixtures = { opened_at: now, summary: "load high", }, + { + id: "00000000-0000-0000-0000-000000000003", + incident_key: "agent:agent-2:liveness", + state: "open", + severity: "critical", + agent_id: "agent-2", + check_id: "agent_liveness", + opened_at: now, + summary: "agent is dead", + }, + { + id: "00000000-0000-0000-0000-000000000004", + incident_key: "agent-2:disk", + state: "resolved", + severity: "critical", + agent_id: "agent-2", + check_id: "disk", + opened_at: now, + resolved_at: now, + summary: "disk recovered", + }, ], next_cursor: null, }, @@ -82,6 +105,22 @@ const fixtures = { status: "ok", exit_code: 0, duration_ms: 12, + output: "disk ok", + output_truncated: false, + notifications_enabled: true, + }, + { + event_id: "00000000-0000-7000-8000-000000000002", + agent_id: "agent-1", + check_id: "load", + observed_at: now, + received_at: now, + status: "warning", + exit_code: 1, + duration_ms: 34, + output: "load high", + output_truncated: false, + notifications_enabled: true, }, ], next_cursor: null, @@ -117,6 +156,29 @@ const server = createServer((req, res) => { }; } } + if (url.pathname === "/api/v1/events/query" && body) { + const agentId = url.searchParams.get("agent_id"); + const checkId = url.searchParams.get("check_id"); + body = { + ...body, + items: body.items.filter((item) => { + if (agentId && item.agent_id !== agentId) return false; + if (checkId && item.check_id !== checkId) return false; + return true; + }), + next_cursor: null, + }; + } + if (url.pathname === "/api/v1/incidents" && body) { + const state = url.searchParams.get("state"); + if (state) { + body = { + ...body, + items: body.items.filter((item) => item.state === state), + next_cursor: null, + }; + } + } res.setHeader("Content-Type", "application/json"); if (!body) { res.statusCode = 404; diff --git a/web/tests/smoke.spec.ts b/web/tests/smoke.spec.ts index 8427253..63aa816 100644 --- a/web/tests/smoke.spec.ts +++ b/web/tests/smoke.spec.ts @@ -38,6 +38,8 @@ test("agents list links to detail", async ({ page }) => { await expect(page.getByRole("link", { name: "Checks" }).last()).toBeVisible(); await expect(page.locator("tbody").first().locator("tr").first()).toContainText("warning"); await expect(page.locator("thead").first()).not.toContainText("incident_key"); + await expect(page.locator("thead").first()).not.toContainText("exit"); + await expect(page.locator("tbody").first()).toContainText("load high"); await page.getByRole("link", { name: "check_id" }).click(); await expect(page).toHaveURL(/sort=check_id/); await page.getByRole("link", { name: "Events" }).last().click(); @@ -49,18 +51,57 @@ test("agents list links to detail", async ({ page }) => { test("checks page lists rows", async ({ page }) => { await page.goto("/checks"); + await expect(page.getByRole("heading", { name: "Checks" })).toBeVisible(); + await expect(page).toHaveURL(/\/checks$/); + await expect(page.locator("thead").first()).not.toContainText("agent_id"); + await expect(page.locator("thead").first()).not.toContainText("incident_key"); + await expect(page.locator("thead").first()).not.toContainText("exit"); + await expect(page.locator("thead").first()).toContainText("↓"); await expect(page.locator("body")).toContainText("disk"); await expect(page.locator("body")).toContainText("load"); + await expect(page.locator("body")).toContainText("agent_liveness"); + await page.getByPlaceholder("search check").fill("lo"); + await expect(page).toHaveURL(/\/checks$/); + await expect(page.locator("tbody")).toContainText("load"); + await expect(page.locator("tbody")).not.toContainText("disk"); + await page.getByRole("button", { name: "clear" }).click(); + await page.getByRole("button", { name: /^CHECK/ }).click(); + await expect(page).toHaveURL(/\/checks$/); await page.getByRole("link", { name: "disk" }).click(); - await expect(page).toHaveURL(/\/events\?agent_id=agent-1&check_id=disk$/); + await expect(page).toHaveURL(/\/checks\/disk$/); + await expect(page.getByRole("link", { name: "Agents" }).last()).toBeVisible(); + await expect(page.locator("body")).toContainText("host-1"); + await expect(page.locator("body")).toContainText("disk ok"); + await page.getByRole("link", { name: "Events" }).last().click(); + await expect(page).toHaveURL(/\/checks\/disk\?tab=events$/); + await expect(page.locator("body")).toContainText("12"); + await expect(page.locator("thead").last()).not.toContainText("exit"); }); test("incidents filter renders", async ({ page }) => { await page.goto("/incidents"); await expect(page.locator("body")).toContainText("load high"); + await expect(page.locator("thead")).toContainText("HOST"); + await expect(page.locator("thead")).not.toContainText("agent_id"); + await expect(page.locator("thead")).toContainText("↓"); + await expect(page.locator("tbody")).toContainText("host-1"); + await expect(page.locator("tbody")).toContainText("agent_liveness"); + await expect(page.locator("tbody")).toContainText("agent is dead"); + await page.getByRole("link", { name: "agent_liveness" }).click(); + await expect(page).toHaveURL(/\/checks\/agent_liveness$/); + await expect(page.locator("body")).toContainText("host-2"); + await page.goto("/incidents"); + await page.getByRole("button", { name: /^HOST/ }).click(); + await expect(page).toHaveURL(/\/incidents$/); + await expect(page.locator("thead")).toContainText("↑"); + await page.getByRole("link", { name: "host-1" }).click(); + await expect(page).toHaveURL(/\/agents\/agent-1$/); }); test("events page", async ({ page }) => { + await page.goto("/events"); + await expect(page.locator("body")).toContainText("agent_liveness"); + await expect(page.locator("body")).toContainText("agent is dead"); 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);