store only status-change events; UI client-side polling refactor

This commit is contained in:
Stanislav Rossovskii
2026-05-27 14:58:46 +04:00
parent 71f0035b0b
commit d5f312f200
48 changed files with 2247 additions and 753 deletions

View File

@@ -195,7 +195,7 @@ paths:
$ref: "#/components/responses/Unauthorized" $ref: "#/components/responses/Unauthorized"
/api/v1/events/query: /api/v1/events/query:
get: get:
summary: Query stored events summary: Query check status-change history
operationId: queryEvents operationId: queryEvents
parameters: parameters:
- name: agent_id - name: agent_id
@@ -368,11 +368,16 @@ components:
accepted: accepted:
type: integer type: integer
minimum: 0 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: deduplicated:
type: integer type: integer
minimum: 0 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: HeartbeatRequest:
type: object type: object
additionalProperties: false additionalProperties: false
@@ -486,7 +491,11 @@ components:
type: string type: string
maxLength: 256 maxLength: 256
StoredCheckResultEvent: 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: allOf:
- $ref: "#/components/schemas/CheckResultEvent" - $ref: "#/components/schemas/CheckResultEvent"
- type: object - type: object
@@ -537,6 +546,9 @@ components:
type: integer type: integer
incident_key: incident_key:
type: string type: string
summary:
type: string
maxLength: 8192
Incident: Incident:
type: object type: object
required: [id, incident_key, state, severity, opened_at] required: [id, incident_key, state, severity, opened_at]

View File

@@ -125,6 +125,21 @@ api "/api/v1/agents/agent-stale" | grep -q '"status":"dead"' \
api "/api/v1/agents/agent-dead" | grep -q '"status":"dead"' \ api "/api/v1/agents/agent-dead" | grep -q '"status":"dead"' \
|| fail "agent-dead did not transition to 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). # At least one agent must still be alive (agent-mixed keeps pushing).
api "/api/v1/agents/agent-mixed" | grep -q '"status":"alive"' \ api "/api/v1/agents/agent-mixed" | grep -q '"status":"alive"' \
|| fail "agent-mixed not alive" || fail "agent-mixed not alive"

View File

@@ -1,3 +1,3 @@
#!/bin/sh #!/bin/sh
echo "ok: $(date -u +%FT%TZ)" printf "ok"
exit 0 exit 0

View File

@@ -43,6 +43,7 @@ def upgrade() -> None:
last_observed_at TIMESTAMPTZ NOT NULL, last_observed_at TIMESTAMPTZ NOT NULL,
last_event_id UUID NOT NULL, last_event_id UUID NOT NULL,
incident_key TEXT NOT NULL, incident_key TEXT NOT NULL,
summary TEXT,
PRIMARY KEY (agent_id, check_id) PRIMARY KEY (agent_id, check_id)
); );
""" """

View File

@@ -42,6 +42,7 @@ async def list_checks(
last_observed_at=to_utc(r.last_observed_at), last_observed_at=to_utc(r.last_observed_at),
exit_code=r.exit_code, exit_code=r.exit_code,
incident_key=r.incident_key, incident_key=r.incident_key,
summary=r.summary,
) )
for r in rows for r in rows
] ]

View File

@@ -71,6 +71,7 @@ class Check(Base):
last_observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) last_observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
last_event_id: Mapped[UUID] = mapped_column(_uuid(), nullable=False) last_event_id: Mapped[UUID] = mapped_column(_uuid(), nullable=False)
incident_key: Mapped[str] = mapped_column(Text, nullable=False) incident_key: Mapped[str] = mapped_column(Text, nullable=False)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = ( __table_args__ = (
PrimaryKeyConstraint("agent_id", "check_id", name="checks_pkey"), PrimaryKeyConstraint("agent_id", "check_id", name="checks_pkey"),

View File

@@ -152,6 +152,7 @@ class CheckState(BaseModel):
last_observed_at: datetime last_observed_at: datetime
exit_code: int exit_code: int
incident_key: str incident_key: str
summary: str | None = None
class Incident(BaseModel): class Incident(BaseModel):

View File

@@ -1,7 +1,9 @@
import asyncio import asyncio
from datetime import UTC, datetime, timedelta 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 sqlalchemy.ext.asyncio import async_sessionmaker
from .. import metrics from .. import metrics
@@ -12,6 +14,138 @@ from ..settings import get_settings
log = get_logger("monlet.detector") log = get_logger("monlet.detector")
OUTBOX_TERMINAL_STATES = ("sent", "failed", "discarded") 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: 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) stale_cut = now - timedelta(seconds=s.stale_after_sec)
dead_cut = now - timedelta(seconds=s.dead_after_sec) dead_cut = now - timedelta(seconds=s.dead_after_sec)
async with sm() as session: async with sm() as session:
await session.execute( agents = (await session.execute(select(Agent))).scalars().all()
update(Agent) for agent in agents:
.where(Agent.last_seen_at <= dead_cut) if agent.last_seen_at <= dead_cut:
.where(Agent.status != "dead") next_status = "dead"
.values(status="dead") elif agent.last_seen_at <= stale_cut:
) next_status = "stale"
await session.execute( else:
update(Agent) next_status = "alive"
.where(Agent.last_seen_at <= stale_cut) if agent.status != next_status:
.where(Agent.last_seen_at > dead_cut) agent.status = next_status
.where(Agent.status != "stale") if next_status in ("stale", "dead"):
.values(status="stale") await _open_liveness_incident(session, agent, next_status, now)
) else:
await session.execute( await _resolve_liveness_incident(session, agent, now)
update(Agent)
.where(Agent.last_seen_at > stale_cut)
.where(Agent.status != "alive")
.values(status="alive")
)
await _prune_history(session) await _prune_history(session)
await session.commit() await session.commit()

View File

@@ -155,11 +155,24 @@ async def ingest_event(
agent_id: str, agent_id: str,
ev: CheckResultEvent, ev: CheckResultEvent,
) -> bool: ) -> 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) observed = to_utc(ev.observed_at)
output = redact(ev.output) output = redact(ev.output)
check_summary = (output or "")[:200] if output else None
incident_key = _incident_key(agent_id, ev) incident_key = _incident_key(agent_id, ev)
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
changed = cur is None or cur.status != ev.status or cur.exit_code != ev.exit_code
if is_late:
metrics.events_total.labels(result="late").inc()
return False
if changed:
stmt = pg_insert(Event).values( stmt = pg_insert(Event).values(
event_id=UUID(ev.event_id), event_id=UUID(ev.event_id),
agent_id=agent_id, agent_id=agent_id,
@@ -173,41 +186,48 @@ async def ingest_event(
notifications_enabled=ev.notifications_enabled, notifications_enabled=ev.notifications_enabled,
incident_key=incident_key, incident_key=incident_key,
) )
stmt = stmt.on_conflict_do_nothing(index_elements=[Event.event_id]).returning(Event.event_id) stmt = stmt.on_conflict_do_nothing(index_elements=[Event.event_id]).returning(
Event.event_id
)
result = await session.execute(stmt) result = await session.execute(stmt)
inserted = result.scalar_one_or_none() inserted = result.scalar_one_or_none()
if inserted is None: if inserted is None:
metrics.events_total.labels(result="deduplicated").inc() metrics.events_total.labels(result="deduplicated").inc()
return False return False
metrics.events_total.labels(result="accepted").inc() 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
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,
)
)
else: else:
cur.status = ev.status metrics.events_total.labels(result="unchanged").inc()
cur.exit_code = ev.exit_code
cur.last_observed_at = observed # Upsert Check to be safe under concurrent first-write race for the same (agent_id, check_id).
cur.last_event_id = UUID(ev.event_id) # When changed=False keep the existing last_event_id and incident_key — incident_key changes
cur.incident_key = incident_key # 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) transition, inc = await _apply_incident(session, agent_id, ev, incident_key)
if transition and inc is not None and ev.notifications_enabled: if transition and inc is not None and ev.notifications_enabled:

View File

@@ -20,7 +20,7 @@ class Settings(BaseSettings):
port: int = 8000 port: int = 8000
enable_detector: bool = True enable_detector: bool = True
enable_notifier_worker: 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 outbox_retention_max_rows: int = 50_000
notifier_tick_sec: int = 5 notifier_tick_sec: int = 5
notifier_batch_size: int = 20 notifier_batch_size: int = 20

View File

@@ -6,7 +6,7 @@ from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
from monlet_server.models import Agent, Event, Incident, NotificationOutbox 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 monlet_server.settings import reset_settings_cache
from ._helpers import make_event, make_heartbeat 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) sm = async_sessionmaker(engine, expire_on_commit=False)
await _tick(sm) await _tick(sm)
session.expire_all()
statuses = { statuses = {
r.agent_id: r.status for r in (await session.execute(select(Agent))).scalars().all() 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-stale"] == "stale"
assert statuses["agent-dead"] == "dead" 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 @pytest.mark.asyncio
async def test_detector_prunes_bounded_history( async def test_detector_prunes_bounded_history(

View File

@@ -9,7 +9,7 @@ from ._helpers import make_event, make_heartbeat
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_event_accepted_and_state(app_client, auth_headers, session): 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) 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( r = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers "/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 assert n == 1
chk = (await session.execute(select(Check))).scalar_one() chk = (await session.execute(select(Check))).scalar_one()
assert chk.status == "ok" assert chk.status == "ok"
assert chk.summary == ev["output"]
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -64,6 +65,53 @@ async def test_oversize_batch_400(app_client, auth_headers):
assert r.status_code == 400 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 @pytest.mark.asyncio
async def test_late_event_does_not_move_state(app_client, auth_headers, session): async def test_late_event_does_not_move_state(app_client, auth_headers, session):
from datetime import UTC, datetime, timedelta 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) await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
newer = datetime.now(UTC).replace(microsecond=0) newer = datetime.now(UTC).replace(microsecond=0)
older = (newer - timedelta(minutes=5)).isoformat() 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) ev_old = make_event(status="ok", exit_code=0, observed_at=older)
await app_client.post( await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev_now]}, headers=auth_headers "/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() chk = (await session.execute(select(Check))).scalar_one()
assert chk.status == "critical" assert chk.status == "critical"
assert chk.summary == ev_now["output"]
n = (await session.execute(select(func.count()).select_from(Event))).scalar_one() n = (await session.execute(select(func.count()).select_from(Event))).scalar_one()
assert n == 2 assert n == 1

View File

@@ -35,7 +35,7 @@ async def test_incident_key_too_long(app_client, auth_headers):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_events_query_cursor_pagination(app_client, auth_headers): async def test_events_query_cursor_pagination(app_client, auth_headers):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=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( await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers "/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers
) )

View File

@@ -1,55 +1,14 @@
import Link from "next/link"; import { AgentDetailBrowser } from "@/components/AgentDetailBrowser";
import { ErrorPanel } from "@/components/DataPanel";
import { AgentFeatures } from "@/components/AgentFeatures"; import { ApiError, api } from "@/lib/api";
import { CheckEventsLink } from "@/components/CheckEventsLink"; import { withLivenessEvents } from "@/lib/check-groups";
import { ErrorPanel, Td, Th } from "@/components/DataPanel"; import { loadChecks } from "@/lib/loaders";
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";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type SortKey = "severity" | "check_id" | "last_seen"; type SortKey = "severity" | "check_id" | "last_seen";
type TabKey = "checks" | "events"; type TabKey = "checks" | "events";
type LoadResult =
| { kind: "ok"; agent: Agent; checks: CheckState[]; events: StoredEvent[] }
| { kind: "not_found" }
| { kind: "error"; error: unknown };
const severityRank: Record<CheckState["status"], number> = {
critical: 0,
warning: 1,
unknown: 2,
ok: 3,
};
async function load(agentId: string, sort: SortKey, tab: TabKey): Promise<LoadResult> {
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<CheckState[]> {
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 { function normalizeSort(sort?: string): SortKey {
return sort === "check_id" || sort === "last_seen" ? sort : "severity"; return sort === "check_id" || sort === "last_seen" ? sort : "severity";
} }
@@ -58,14 +17,6 @@ function normalizeTab(tab?: string): TabKey {
return tab === "events" ? "events" : "checks"; 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({ export default async function AgentDetailPage({
params, params,
searchParams, searchParams,
@@ -77,163 +28,27 @@ export default async function AgentDetailPage({
const sp = await searchParams; const sp = await searchParams;
const sort = normalizeSort(sp.sort); const sort = normalizeSort(sp.sort);
const tab = normalizeTab(sp.tab); const tab = normalizeTab(sp.tab);
const r = await load(agent_id, sort, tab);
if (r.kind === "not_found") return <div className="text-sm text-neutral-400">Agent not found.</div>;
if (r.kind === "error") return <ErrorPanel error={r.error} />;
const { agent, checks, events } = r;
return (
<div className="space-y-6">
<div>
<Link href="/agents" className="text-blue-300 hover:text-blue-200 text-sm">
agents
</Link>
<h1 className="mt-2 text-lg font-semibold">{agent.agent_id}</h1>
</div>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-neutral-400">hostname</dt>
<dd>{agent.hostname}</dd>
<dt className="text-neutral-400">status</dt>
<dd className={statusBadgeClass(agent.status)}>{agent.status}</dd>
<dt className="text-neutral-400">last_seen_at</dt>
<dd>
<DateTime iso={agent.last_seen_at} />
</dd>
<dt className="text-neutral-400">features</dt>
<dd>
<AgentFeatures features={agent.features} />
</dd>
<dt className="text-neutral-400">labels</dt>
<dd>
<LabelChips labels={agent.labels} />
</dd>
</dl>
<TabNav agentId={agent.agent_id} current={tab} sort={sort} />
{tab === "checks" ? <ChecksTable agentId={agent.agent_id} checks={checks} sort={sort} /> : <EventsTable events={events} />}
</div>
);
}
function TabNav({ agentId, current, sort }: { agentId: string; current: TabKey; sort: SortKey }) { let data;
const base = `/agents/${encodeURIComponent(agentId)}`; 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 <div className="text-sm text-neutral-400">Agent not found.</div>;
}
return <ErrorPanel error={e} />;
}
return ( return (
<div className="flex gap-4 border-b border-neutral-800 text-sm"> <AgentDetailBrowser
<Link key={`${agent_id}:${tab}:${sort}`}
href={`${base}?tab=checks&sort=${sort}`} initialData={data}
className={`pb-2 ${current === "checks" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`} sort={sort}
> tab={tab}
Checks />
</Link>
<Link
href={`${base}?tab=events&sort=${sort}`}
className={`pb-2 ${current === "events" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
>
Events
</Link>
</div>
);
}
function ChecksTable({
agentId,
checks,
sort,
}: {
agentId: string;
checks: CheckState[];
sort: SortKey;
}) {
if (checks.length === 0) return <div className="text-sm text-neutral-500">No checks.</div>;
return (
<section>
<table className="w-full text-sm">
<thead>
<tr>
<Th>
<SortLink agentId={agentId} sort="check_id" current={sort}>
check_id
</SortLink>
</Th>
<Th>
<SortLink agentId={agentId} sort="severity" current={sort}>
status
</SortLink>
</Th>
<Th>exit</Th>
<Th>
<SortLink agentId={agentId} sort="last_seen" current={sort}>
last_observed_at
</SortLink>
</Th>
</tr>
</thead>
<tbody>
{checks.map((c) => (
<tr key={c.check_id}>
<Td>
<CheckEventsLink agentId={agentId} checkId={c.check_id} />
</Td>
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
<Td>{c.exit_code ?? "—"}</Td>
<Td>
<DateTime iso={c.last_observed_at} />
</Td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function EventsTable({ events }: { events: StoredEvent[] }) {
if (events.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
return (
<section>
<table className="w-full text-sm">
<thead>
<tr>
<Th>observed_at</Th>
<Th>check_id</Th>
<Th>status</Th>
<Th>duration_ms</Th>
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.event_id}>
<Td>
<DateTime iso={e.observed_at} />
</Td>
<Td>
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
</Td>
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
<Td>{e.duration_ms}</Td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function SortLink({
agentId,
sort,
current,
children,
}: {
agentId: string;
sort: SortKey;
current: SortKey;
children: React.ReactNode;
}) {
return (
<Link
href={`/agents/${encodeURIComponent(agentId)}?tab=checks&sort=${sort}`}
className={sort === current ? "text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}
>
{children}
</Link>
); );
} }

View File

@@ -2,51 +2,10 @@ import { redirect } from "next/navigation";
import { AgentsBrowser } from "@/components/AgentsBrowser"; import { AgentsBrowser } from "@/components/AgentsBrowser";
import { ErrorPanel } from "@/components/DataPanel"; import { ErrorPanel } from "@/components/DataPanel";
import { api, type Agent, type CheckState } from "@/lib/api"; import { loadInventory } from "@/lib/loaders";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type CheckStatus = CheckState["status"];
type CheckCounts = Record<CheckStatus, number>;
async function load() {
const [agents, checksByAgent] = await Promise.all([
loadAgents(),
loadCheckCounts(),
]);
return { agents, checksByAgent };
}
async function loadAgents(): Promise<Agent[]> {
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<Map<string, CheckCounts>> {
const byAgent = new Map<string, CheckCounts>();
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({ export default async function AgentsPage({
searchParams, searchParams,
}: { }: {
@@ -55,17 +14,11 @@ export default async function AgentsPage({
const sp = await searchParams; const sp = await searchParams;
if (Object.keys(sp).length > 0) redirect("/agents"); if (Object.keys(sp).length > 0) redirect("/agents");
let data: Awaited<ReturnType<typeof load>>; let data;
try { try {
data = await load(); data = await loadInventory();
} catch (e) { } catch (e) {
return <ErrorPanel error={e} />; return <ErrorPanel error={e} />;
} }
return <AgentsBrowser initialData={data} />;
const rows = data.agents.map((agent) => ({
agent,
checks: data.checksByAgent.get(agent.agent_id) ?? emptyCheckCounts(),
}));
return <AgentsBrowser rows={rows} />;
} }

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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<ReturnType<typeof api.queryEvents>>;
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 <ErrorPanel error={e} />;
}
return (
<CheckDetailBrowser
key={`${check_id}:${tab}`}
checkId={check_id}
initialData={data}
initialEvents={events}
tab={tab}
/>
);
}

View File

@@ -1,69 +1,15 @@
import Link from "next/link"; import { ChecksBrowser } from "@/components/ChecksBrowser";
import { ErrorPanel } from "@/components/DataPanel";
import { CheckEventsLink } from "@/components/CheckEventsLink"; import { loadInventory } from "@/lib/loaders";
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";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function ChecksPage({ export default async function ChecksPage() {
searchParams,
}: {
searchParams: Promise<{ cursor?: string }>;
}) {
const sp = await searchParams;
let data; let data;
try { try {
data = await api.listChecks({ cursor: sp.cursor }); data = await loadInventory();
} catch (e) { } catch (e) {
return <ErrorPanel error={e} />; return <ErrorPanel error={e} />;
} }
return ( return <ChecksBrowser initialData={data} />;
<>
<PageHeader title="Checks" count={data.items.length} />
{data.items.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>agent_id</Th>
<Th>check_id</Th>
<Th>status</Th>
<Th>exit</Th>
<Th>last_observed_at</Th>
<Th>incident_key</Th>
</tr>
</thead>
<tbody>
{data.items.map((c) => (
<tr key={`${c.agent_id}:${c.check_id}`}>
<Td>
<Link
href={`/agents/${encodeURIComponent(c.agent_id)}`}
className="text-blue-300 hover:text-blue-200"
>
{c.agent_id}
</Link>
</Td>
<Td>
<CheckEventsLink agentId={c.agent_id} checkId={c.check_id} />
</Td>
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
<Td>{c.exit_code ?? "—"}</Td>
<Td>
<DateTime iso={c.last_observed_at} />
</Td>
<Td className="text-xs text-neutral-400">{c.incident_key ?? "—"}</Td>
</tr>
))}
</tbody>
</table>
)}
<Pager basePath="/checks" cursor={sp.cursor} nextCursor={data.next_cursor} />
</>
);
} }

View File

@@ -1,11 +1,6 @@
import Link from "next/link"; import { ErrorPanel } from "@/components/DataPanel";
import { EventsBrowser } from "@/components/EventsBrowser";
import { CheckEventsLink } from "@/components/CheckEventsLink"; import { loadEventsWithLiveness } from "@/lib/loaders";
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";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -17,102 +12,21 @@ export default async function EventsPage({
const sp = await searchParams; const sp = await searchParams;
let data; let data;
try { try {
data = await api.queryEvents({ data = await loadEventsWithLiveness({
agent_id: sp.agent_id, agentId: sp.agent_id,
check_id: sp.check_id, checkId: sp.check_id,
cursor: sp.cursor, cursor: sp.cursor,
}); });
} catch (e) { } catch (e) {
return <ErrorPanel error={e} />; return <ErrorPanel error={e} />;
} }
return ( return (
<> <EventsBrowser
<PageHeader title="Events" count={data.items.length} /> key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}`}
<ActiveFilters agentId={sp.agent_id} checkId={sp.check_id} /> initialData={data}
{data.items.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>observed_at</Th>
<Th>received_at</Th>
<Th>agent_id</Th>
<Th>check_id</Th>
<Th>status</Th>
<Th>exit</Th>
<Th>duration_ms</Th>
<Th>output</Th>
</tr>
</thead>
<tbody>
{data.items.map((e) => (
<tr key={e.event_id}>
<Td>
<DateTime iso={e.observed_at} />
</Td>
<Td>
<DateTime iso={e.received_at} />
</Td>
<Td>{e.agent_id}</Td>
<Td>
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
</Td>
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
<Td>{e.exit_code}</Td>
<Td>{e.duration_ms}</Td>
<Td className="text-xs text-neutral-400 max-w-md truncate">{e.output ?? "—"}</Td>
</tr>
))}
</tbody>
</table>
)}
<Pager
basePath="/events"
cursor={sp.cursor} cursor={sp.cursor}
nextCursor={data.next_cursor} agentId={sp.agent_id}
extra={{ agent_id: sp.agent_id, check_id: sp.check_id }} checkId={sp.check_id}
/> />
</>
); );
} }
function ActiveFilters({ agentId, checkId }: { agentId?: string; checkId?: string }) {
if (!agentId && !checkId) return null;
return (
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
{agentId ? (
<FilterChip label="agent_id" value={agentId} href={eventsHref({ check_id: checkId })} />
) : null}
{checkId ? (
<FilterChip label="check_id" value={checkId} href={eventsHref({ agent_id: agentId })} />
) : null}
<Link href="/events" className="text-neutral-500 hover:text-neutral-300">
clear
</Link>
</div>
);
}
function FilterChip({ label, value, href }: { label: string; value: string; href: string }) {
return (
<Link
href={href}
className="inline-flex max-w-xs items-center gap-1 rounded border border-neutral-800 bg-neutral-900 px-2 py-1 text-neutral-300 hover:border-neutral-600 hover:text-neutral-100"
title={`${label}=${value}`}
>
<span className="text-neutral-500">{label}=</span>
<span className="truncate">{value}</span>
<span className="text-neutral-600">x</span>
</Link>
);
}
function eventsHref(params: { agent_id?: string; check_id?: string }): string {
const qs = new URLSearchParams();
if (params.agent_id) qs.set("agent_id", params.agent_id);
if (params.check_id) qs.set("check_id", params.check_id);
const query = qs.toString();
return query ? `/events?${query}` : "/events";
}

View File

@@ -1,89 +1,29 @@
import Link from "next/link"; import { ErrorPanel } from "@/components/DataPanel";
import { IncidentsBrowser } from "@/components/IncidentsBrowser";
import { CheckEventsLink } from "@/components/CheckEventsLink"; import { loadIncidents, loadInventory } from "@/lib/loaders";
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";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const STATES = ["all", "open", "resolved"] as const;
export default async function IncidentsPage({ export default async function IncidentsPage({
searchParams, searchParams,
}: { }: {
searchParams: Promise<{ cursor?: string; state?: string }>; searchParams: Promise<{ state?: string }>;
}) { }) {
const sp = await searchParams; const sp = await searchParams;
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined; const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
let data; let data;
let inventory;
try { try {
data = await api.listIncidents({ state, cursor: sp.cursor }); [data, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
} catch (e) { } catch (e) {
return <ErrorPanel error={e} />; return <ErrorPanel error={e} />;
} }
return ( return (
<> <IncidentsBrowser
<PageHeader title="Incidents" count={data.items.length} /> key={state ?? ""}
<div className="mb-3 flex gap-3 text-sm"> initialData={data}
{STATES.map((s) => { initialInventory={inventory}
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`; state={state}
const active = (s === "all" && !state) || s === state;
return (
<Link
key={s}
href={href}
className={active ? "text-white underline" : "text-neutral-400 hover:text-white"}
>
{s}
</Link>
);
})}
</div>
{data.items.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>opened_at</Th>
<Th>state</Th>
<Th>severity</Th>
<Th>agent_id</Th>
<Th>check_id</Th>
<Th>resolved_at</Th>
<Th>summary</Th>
</tr>
</thead>
<tbody>
{data.items.map((i) => (
<tr key={i.id}>
<Td>
<DateTime iso={i.opened_at} />
</Td>
<Td className={statusBadgeClass(i.state)}>{i.state}</Td>
<Td className={statusBadgeClass(i.severity)}>{i.severity}</Td>
<Td>{i.agent_id ?? "—"}</Td>
<Td>
<CheckEventsLink agentId={i.agent_id} checkId={i.check_id} />
</Td>
<Td>
<DateTime iso={i.resolved_at} />
</Td>
<Td className="text-neutral-400">{i.summary ?? "—"}</Td>
</tr>
))}
</tbody>
</table>
)}
<Pager
basePath="/incidents"
cursor={sp.cursor}
nextCursor={data.next_cursor}
extra={{ state }}
/> />
</>
); );
} }

View File

@@ -1,6 +1,5 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { AutoRefresh } from "@/components/AutoRefresh";
import { TimeZoneCarousel, TimeZoneProvider } from "@/components/TimeZoneControls"; import { TimeZoneCarousel, TimeZoneProvider } from "@/components/TimeZoneControls";
import { getUiTimeZone } from "@/lib/timezone"; import { getUiTimeZone } from "@/lib/timezone";
import "./globals.css"; import "./globals.css";
@@ -19,16 +18,6 @@ const NAV = [
{ href: "/outbox", label: "Outbox" }, { 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() { function Brand() {
return ( return (
<Link href="/" aria-label="Monlet overview" className="group flex shrink-0 items-center gap-2"> <Link href="/" aria-label="Monlet overview" className="group flex shrink-0 items-center gap-2">
@@ -47,7 +36,6 @@ function Brand() {
} }
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
const refreshIntervalMs = getRefreshIntervalMs();
const configuredTimeZone = getUiTimeZone(); const configuredTimeZone = getUiTimeZone();
return ( return (
@@ -66,7 +54,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<TimeZoneCarousel /> <TimeZoneCarousel />
</header> </header>
<main className="flex-1 px-4 py-6">{children}</main> <main className="flex-1 px-4 py-6">{children}</main>
{refreshIntervalMs > 0 && <AutoRefresh intervalMs={refreshIntervalMs} />}
</TimeZoneProvider> </TimeZoneProvider>
</body> </body>
</html> </html>

View File

@@ -1,8 +1,6 @@
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel"; import { ErrorPanel } from "@/components/DataPanel";
import { Pager } from "@/components/Pager"; import { OutboxBrowser } from "@/components/OutboxBrowser";
import { DateTime } from "@/components/TimeZoneControls";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { statusBadgeClass } from "@/lib/format";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -18,44 +16,5 @@ export default async function OutboxPage({
} catch (e) { } catch (e) {
return <ErrorPanel error={e} />; return <ErrorPanel error={e} />;
} }
return ( return <OutboxBrowser key={sp.cursor ?? ""} initialData={data} cursor={sp.cursor} />;
<>
<PageHeader title="Notification outbox" count={data.items.length} />
{data.items.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>updated_at</Th>
<Th>notifier</Th>
<Th>event</Th>
<Th>state</Th>
<Th>attempts</Th>
<Th>next_attempt_at</Th>
<Th>last_error</Th>
</tr>
</thead>
<tbody>
{data.items.map((o) => (
<tr key={o.id}>
<Td>
<DateTime iso={o.updated_at} />
</Td>
<Td>{o.notifier}</Td>
<Td>{o.event_type}</Td>
<Td className={statusBadgeClass(o.state)}>{o.state}</Td>
<Td>{o.attempts}</Td>
<Td>
<DateTime iso={o.next_attempt_at} />
</Td>
<Td className="text-xs text-red-300">{o.last_error ?? "—"}</Td>
</tr>
))}
</tbody>
</table>
)}
<Pager basePath="/outbox" cursor={sp.cursor} nextCursor={data.next_cursor} />
</>
);
} }

View File

@@ -1,81 +1,15 @@
import Link from "next/link";
import { ErrorPanel } from "@/components/DataPanel"; import { ErrorPanel } from "@/components/DataPanel";
import { api } from "@/lib/api"; import { OverviewDashboard } from "@/components/OverviewDashboard";
import { statusBadgeClass } from "@/lib/format"; import { loadOverview } from "@/lib/loaders";
export const dynamic = "force-dynamic"; 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<Record<string, number>>((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() { export default async function OverviewPage() {
let data: Awaited<ReturnType<typeof load>>; let data;
try { try {
data = await load(); data = await loadOverview();
} catch (e) { } catch (e) {
return <ErrorPanel error={e} />; return <ErrorPanel error={e} />;
} }
return ( return <OverviewDashboard initialData={data} />;
<div className="space-y-6">
<h1 className="text-lg font-semibold">Overview</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card title="Agents" total={data.totalAgents} href="/agents" tally={data.agents} />
<Card title="Checks" total={data.totalChecks} href="/checks" tally={data.checks} />
<Card
title="Open incidents"
total={data.openIncidents}
href="/incidents"
tally={{}}
highlight={data.openIncidents > 0 ? "open" : undefined}
/>
</div>
</div>
);
}
function Card({
title,
total,
href,
tally,
highlight,
}: {
title: string;
total: number;
href: string;
tally: Record<string, number>;
highlight?: string;
}) {
return (
<Link href={href} className="block border border-neutral-800 hover:border-neutral-600 p-4">
<div className="text-xs uppercase tracking-wide text-neutral-400">{title}</div>
<div className={`mt-1 text-3xl ${highlight ? statusBadgeClass(highlight) : ""}`}>{total}</div>
<div className="mt-2 flex flex-wrap gap-3 text-xs">
{Object.entries(tally).map(([k, v]) => (
<span key={k} className={statusBadgeClass(k)}>
{k} {v}
</span>
))}
</div>
</Link>
);
} }

View File

@@ -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<CheckState["status"], number> = {
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 (
<div className="space-y-6">
<div>
<Link href="/agents" className="text-sm text-blue-300 hover:text-blue-200">
agents
</Link>
<h1 className="mt-2 text-lg font-semibold">{agent.agent_id}</h1>
</div>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-neutral-400">hostname</dt>
<dd>{agent.hostname}</dd>
<dt className="text-neutral-400">status</dt>
<dd className={statusBadgeClass(agent.status)}>{agent.status}</dd>
<dt className="text-neutral-400">last_seen_at</dt>
<dd>
<DateTime iso={agent.last_seen_at} />
</dd>
<dt className="text-neutral-400">features</dt>
<dd>
<AgentFeatures features={agent.features} />
</dd>
<dt className="text-neutral-400">labels</dt>
<dd>
<LabelChips labels={agent.labels} />
</dd>
</dl>
<TabNav agentId={agent.agent_id} current={tab} sort={sort} />
{tab === "checks" ? <ChecksTable agentId={agent.agent_id} checks={checks} sort={sort} /> : <EventsTable events={events} />}
</div>
);
}
function TabNav({ agentId, current, sort }: { agentId: string; current: TabKey; sort: SortKey }) {
const base = `/agents/${encodeURIComponent(agentId)}`;
return (
<div className="flex gap-4 border-b border-neutral-800 text-sm">
<Link
href={`${base}?tab=checks&sort=${sort}`}
className={`pb-2 ${current === "checks" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
>
Checks
</Link>
<Link
href={`${base}?tab=events&sort=${sort}`}
className={`pb-2 ${current === "events" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
>
Events
</Link>
</div>
);
}
function ChecksTable({
agentId,
checks,
sort,
}: {
agentId: string;
checks: CheckState[];
sort: SortKey;
}) {
if (checks.length === 0) return <div className="text-sm text-neutral-500">No checks.</div>;
return (
<section>
<table className="w-full text-sm">
<thead>
<tr>
<Th>
<SortLink agentId={agentId} sort="check_id" current={sort}>
check_id
</SortLink>
</Th>
<Th>
<SortLink agentId={agentId} sort="severity" current={sort}>
status
</SortLink>
</Th>
<Th>
<SortLink agentId={agentId} sort="last_seen" current={sort}>
last_observed_at
</SortLink>
</Th>
<Th>summary</Th>
</tr>
</thead>
<tbody>
{checks.map((c) => (
<tr key={c.check_id}>
<Td>
<CheckEventsLink agentId={agentId} checkId={c.check_id} />
</Td>
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
<Td>
<DateTime iso={c.last_observed_at} />
</Td>
<Td className="max-w-md truncate text-xs text-neutral-400">{c.summary ?? "—"}</Td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function EventsTable({ events }: { events: StoredEvent[] }) {
if (events.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
return (
<section>
<table className="w-full text-sm">
<thead>
<tr>
<Th>observed_at</Th>
<Th>check_id</Th>
<Th>status</Th>
<Th>duration_ms</Th>
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.event_id}>
<Td>
<DateTime iso={e.observed_at} />
</Td>
<Td>
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
</Td>
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
<Td>{e.duration_ms}</Td>
</tr>
))}
</tbody>
</table>
</section>
);
}
function SortLink({
agentId,
sort,
current,
children,
}: {
agentId: string;
sort: SortKey;
current: SortKey;
children: React.ReactNode;
}) {
return (
<Link
href={`/agents/${encodeURIComponent(agentId)}?tab=checks&sort=${sort}`}
className={sort === current ? "text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}
>
{children}
</Link>
);
}
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);
});
}

View File

@@ -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"] }) { export function AgentFeatures({ features }: { features?: Agent["features"] }) {
const enabled = enabledFeatures(features); const enabled = enabledFeatures(features);

View File

@@ -6,9 +6,13 @@ import { useMemo, useState } from "react";
import { AgentFeatures } from "@/components/AgentFeatures"; import { AgentFeatures } from "@/components/AgentFeatures";
import { LabelChips } from "@/components/Labels"; import { LabelChips } from "@/components/Labels";
import { DateTime } from "@/components/TimeZoneControls"; 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 { 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 CheckStatus = CheckState["status"];
type CheckCounts = Record<CheckStatus, number>; type CheckCounts = Record<CheckStatus, number>;
type SortField = "host" | "status" | "last_seen_at"; type SortField = "host" | "status" | "last_seen_at";
@@ -20,10 +24,12 @@ const checkStatuses: CheckStatus[] = ["ok", "warning", "critical", "unknown"];
const statusRank: Record<Agent["status"], number> = { alive: 0, stale: 1, dead: 2 }; const statusRank: Record<Agent["status"], number> = { alive: 0, stale: 1, dead: 2 };
const checkSortStatuses: CheckStatus[] = ["critical", "warning", "unknown", "ok"]; 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 [query, setQuery] = useState("");
const [sort, setSort] = useState<SortField>("status"); const [sort, setSort] = useState<SortField>("status");
const [dir, setDir] = useState<SortDir>("desc"); const [dir, setDir] = useState<SortDir>("desc");
const initialRows = useMemo(() => rowsFromInventory(data), [data]);
const rows = useMemo( const rows = useMemo(
() => sortAgents(filterAgents(initialRows, query), sort, dir), () => sortAgents(filterAgents(initialRows, query), sort, dir),
@@ -239,6 +245,19 @@ function emptyCheckCounts(): CheckCounts {
return { ok: 0, warning: 0, critical: 0, unknown: 0 }; return { ok: 0, warning: 0, critical: 0, unknown: 0 };
} }
function rowsFromInventory(data: InventoryData): AgentRow[] {
const checksByAgent = new Map<string, CheckCounts>();
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 { function shortStatus(status: CheckStatus): string {
switch (status) { switch (status) {
case "ok": case "ok":

View File

@@ -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<typeof setInterval> | 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;
}

View File

@@ -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 (
<div>
<Link href="/checks" className="text-sm text-blue-300 hover:text-blue-200">
checks
</Link>
<h1 className="mt-2 text-lg font-semibold">{checkId}</h1>
<div className="mt-4 text-sm text-neutral-500">Check not found.</div>
</div>
);
}
return (
<div className="space-y-6">
<div>
<Link href="/checks" className="text-sm text-blue-300 hover:text-blue-200">
checks
</Link>
<h1 className="mt-2 text-lg font-semibold">{checkId}</h1>
</div>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
<dt className="text-neutral-400">status</dt>
<dd>
<CheckStatusSummary counts={group.counts} />
</dd>
<dt className="text-neutral-400">agents</dt>
<dd>{group.checks.length}</dd>
<dt className="text-neutral-400">last_observed_at</dt>
<dd>
<DateTime iso={group.lastObservedAt} />
</dd>
</dl>
<TabNav checkId={checkId} current={tab} />
{tab === "agents" ? (
<AgentsForCheck group={group} />
) : (
<EventsForCheck checkId={checkId} initialData={initialEvents} agentsById={agentsById} />
)}
</div>
);
}
function TabNav({ checkId, current }: { checkId: string; current: TabKey }) {
const base = `/checks/${encodeURIComponent(checkId)}`;
return (
<div className="flex gap-4 border-b border-neutral-800 text-sm">
<Link
href={`${base}?tab=agents`}
className={`pb-2 ${current === "agents" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
>
Agents
</Link>
<Link
href={`${base}?tab=events`}
className={`pb-2 ${current === "events" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
>
Events
</Link>
</div>
);
}
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 (
<table className="w-full text-sm">
<thead>
<tr>
<Th>HOST</Th>
<Th>STATUS</Th>
<Th>LAST_OBSERVED_AT</Th>
<Th>SUMMARY</Th>
</tr>
</thead>
<tbody>
{rows.map(({ check, agent }) => (
<tr key={check.agent_id}>
<Td>
<Link
href={`/agents/${encodeURIComponent(check.agent_id)}`}
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
>
<span>{agent?.hostname || check.agent_id}</span>
{agent && agent.hostname !== check.agent_id ? (
<span className="text-xs text-neutral-500">{check.agent_id}</span>
) : null}
</Link>
</Td>
<Td className={statusBadgeClass(check.status)}>{check.status}</Td>
<Td>
<DateTime iso={check.last_observed_at} />
</Td>
<Td className="max-w-md truncate text-xs text-neutral-400">{check.summary ?? "—"}</Td>
</tr>
))}
</tbody>
</table>
);
}
function EventsForCheck({
checkId,
initialData,
agentsById,
}: {
checkId: string;
initialData: EventsPageData;
agentsById: Map<string, Agent>;
}) {
const { data } = usePolling(
initialData,
`/api/poll/events?check_id=${encodeURIComponent(checkId)}&limit=50`,
);
if (data.items.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
return (
<table className="w-full text-sm">
<thead>
<tr>
<Th>OBSERVED_AT</Th>
<Th>RECEIVED_AT</Th>
<Th>HOST</Th>
<Th>STATUS</Th>
<Th>DURATION_MS</Th>
<Th>SUMMARY</Th>
</tr>
</thead>
<tbody>
{data.items.map((event) => {
const agent = agentsById.get(event.agent_id);
return (
<tr key={event.event_id}>
<Td>
<DateTime iso={event.observed_at} />
</Td>
<Td>
<DateTime iso={event.received_at} />
</Td>
<Td>
<Link
href={`/agents/${encodeURIComponent(event.agent_id)}`}
className="text-blue-300 hover:text-blue-200"
>
{agent?.hostname || event.agent_id}
</Link>
</Td>
<Td className={statusBadgeClass(event.status)}>{event.status}</Td>
<Td>{event.duration_ms}</Td>
<Td className="max-w-md truncate text-xs text-neutral-400">{event.output ?? "—"}</Td>
</tr>
);
})}
</tbody>
</table>
);
}
function hostName(row: { agent?: Agent; check: { agent_id: string } }): string {
return row.agent?.hostname || row.check.agent_id;
}

View File

@@ -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 (
<span className="inline-flex items-center gap-1.5 rounded border border-neutral-800 bg-neutral-900 px-2 py-0.5 text-xs">
{checkStatuses.map((status, i) => (
<span key={status} className="inline-flex items-center gap-1.5">
{i > 0 ? <span className="text-neutral-700">/</span> : null}
<span className={statusBadgeClass(status)}>
{shortStatus(status)} {counts[status]}
</span>
</span>
))}
</span>
);
}

View File

@@ -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<SortField>("status");
const [dir, setDir] = useState<SortDir>("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 (
<>
<div className="mb-4 flex items-baseline gap-3">
<h1 className="text-lg font-semibold">Checks</h1>
<span className="text-sm text-neutral-400">{rows.length} items</span>
</div>
<div className="mb-4 flex max-w-md gap-2">
<input
type="search"
value={query}
onChange={(event) => 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 ? (
<button
type="button"
onClick={() => setQuery("")}
className="border border-neutral-900 px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-300"
>
clear
</button>
) : null}
</div>
{rows.length === 0 ? (
<div className="text-sm text-neutral-500 italic">No data.</div>
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>
<SortButton field="check" current={sort} dir={dir} onClick={chooseSort}>
CHECK
</SortButton>
</Th>
<Th>
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
STATUS
</SortButton>
</Th>
<Th>
<SortButton field="last_observed_at" current={sort} dir={dir} onClick={chooseSort}>
LAST_OBSERVED_AT
</SortButton>
</Th>
<Th>AGENTS</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.checkId}>
<Td>
<Link
href={`/checks/${encodeURIComponent(row.checkId)}`}
className="text-blue-300 hover:text-blue-200"
>
{row.checkId}
</Link>
</Td>
<Td>
<CheckStatusSummary counts={row.counts} />
</Td>
<Td>
<DateTime iso={row.lastObservedAt} />
</Td>
<Td>{row.checks.length}</Td>
</tr>
))}
</tbody>
</table>
)}
</>
);
}
function Th({ children }: { children: React.ReactNode }) {
return (
<th className="border-b border-neutral-800 px-2 py-2 text-left text-xs font-semibold uppercase tracking-wide text-neutral-400">
{children}
</th>
);
}
function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return <td className={`border-b border-neutral-900 px-2 py-1.5 align-top ${className}`}>{children}</td>;
}
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 (
<button
type="button"
onClick={() => onClick(field)}
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
>
<span>{children}</span>
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
</button>
);
}
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);
}
}

View File

@@ -1,5 +1,3 @@
import { ApiError } from "@/lib/api";
export function PageHeader({ title, count }: { title: string; count?: number }) { export function PageHeader({ title, count }: { title: string; count?: number }) {
return ( return (
<div className="mb-4 flex items-baseline gap-3"> <div className="mb-4 flex items-baseline gap-3">
@@ -11,7 +9,7 @@ export function PageHeader({ title, count }: { title: string; count?: number })
export function ErrorPanel({ error }: { error: unknown }) { export function ErrorPanel({ error }: { error: unknown }) {
const e = error instanceof Error ? error : new Error(String(error)); const e = error instanceof Error ? error : new Error(String(error));
const status = error instanceof ApiError ? error.status : "—"; const status = hasStatus(error) ? error.status : "—";
return ( return (
<div className="border border-red-700 bg-red-950/40 p-3 text-sm"> <div className="border border-red-700 bg-red-950/40 p-3 text-sm">
<div className="font-semibold text-red-300">API error ({String(status)})</div> <div className="font-semibold text-red-300">API error ({String(status)})</div>
@@ -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 }) { export function EmptyPanel({ label = "No data" }: { label?: string }) {
return <div className="text-sm text-neutral-500 italic">{label}.</div>; return <div className="text-sm text-neutral-500 italic">{label}.</div>;
} }

View File

@@ -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 (
<>
<PageHeader title="Events" count={data.items.length} />
<ActiveFilters agentId={agentId} checkId={checkId} />
{data.items.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>observed_at</Th>
<Th>received_at</Th>
<Th>agent_id</Th>
<Th>check_id</Th>
<Th>status</Th>
<Th>duration_ms</Th>
<Th>summary</Th>
</tr>
</thead>
<tbody>
{data.items.map((e) => (
<tr key={e.event_id}>
<Td>
<DateTime iso={e.observed_at} />
</Td>
<Td>
<DateTime iso={e.received_at} />
</Td>
<Td>{e.agent_id}</Td>
<Td>
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
</Td>
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
<Td>{e.duration_ms}</Td>
<Td className="max-w-md truncate text-xs text-neutral-400">{e.output ?? "—"}</Td>
</tr>
))}
</tbody>
</table>
)}
<Pager
basePath="/events"
cursor={cursor}
nextCursor={data.next_cursor}
extra={{ agent_id: agentId, check_id: checkId }}
/>
</>
);
}
function ActiveFilters({ agentId, checkId }: { agentId?: string; checkId?: string }) {
if (!agentId && !checkId) return null;
return (
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
{agentId ? (
<FilterChip label="agent_id" value={agentId} href={eventsHref({ check_id: checkId })} />
) : null}
{checkId ? (
<FilterChip label="check_id" value={checkId} href={eventsHref({ agent_id: agentId })} />
) : null}
<Link href="/events" className="text-neutral-500 hover:text-neutral-300">
clear
</Link>
</div>
);
}
function FilterChip({ label, value, href }: { label: string; value: string; href: string }) {
return (
<Link
href={href}
className="inline-flex max-w-xs items-center gap-1 rounded border border-neutral-800 bg-neutral-900 px-2 py-1 text-neutral-300 hover:border-neutral-600 hover:text-neutral-100"
title={`${label}=${value}`}
>
<span className="text-neutral-500">{label}=</span>
<span className="truncate">{value}</span>
<span className="text-neutral-600">x</span>
</Link>
);
}
function eventsHref(params: { agent_id?: string; check_id?: string }): string {
const qs = new URLSearchParams();
if (params.agent_id) qs.set("agent_id", params.agent_id);
if (params.check_id) qs.set("check_id", params.check_id);
const query = qs.toString();
return query ? `/events?${query}` : "/events";
}
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";
}

View File

@@ -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<Incident["state"], number> = { resolved: 0, open: 1 };
const severityRank: Record<Incident["severity"], number> = { 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<SortField>("status");
const [dir, setDir] = useState<SortDir>("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 (
<>
<PageHeader title="Incidents" count={rows.length} />
<div className="mb-3 flex gap-3 text-sm">
{STATES.map((s) => {
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`;
const active = (s === "all" && !state) || s === state;
return (
<Link
key={s}
href={href}
className={active ? "text-white underline" : "text-neutral-400 hover:text-white"}
>
{s}
</Link>
);
})}
</div>
{rows.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
STATUS
</SortButton>
</Th>
<Th>
<SortButton field="host" current={sort} dir={dir} onClick={chooseSort}>
HOST
</SortButton>
</Th>
<Th>
<SortButton field="check" current={sort} dir={dir} onClick={chooseSort}>
CHECK
</SortButton>
</Th>
<Th>
<SortButton field="opened_at" current={sort} dir={dir} onClick={chooseSort}>
OPENED_AT
</SortButton>
</Th>
<Th>
<SortButton field="resolved_at" current={sort} dir={dir} onClick={chooseSort}>
RESOLVED_AT
</SortButton>
</Th>
<Th>SUMMARY</Th>
</tr>
</thead>
<tbody>
{rows.map(({ incident, agent }) => (
<IncidentRow key={incident.id} incident={incident} agent={agent} />
))}
</tbody>
</table>
)}
</>
);
}
function IncidentRow({ incident, agent }: { incident: Incident; agent?: Agent }) {
const agentId = incidentAgentId(incident);
const checkId = incidentCheckId(incident);
return (
<tr>
<Td>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 whitespace-nowrap">
<span className={statusBadgeClass(incident.state)}>{incident.state}</span>
<span className={statusBadgeClass(incident.severity)}>{incident.severity}</span>
</div>
</Td>
<Td>
{agentId ? (
<Link
href={`/agents/${encodeURIComponent(agentId)}`}
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
>
<span>{agent?.hostname || agentId}</span>
{agent && agent.hostname !== agentId ? (
<span className="text-xs text-neutral-500">{agentId}</span>
) : null}
</Link>
) : (
"—"
)}
</Td>
<Td>
<IncidentCheckLink agentId={agentId} checkId={checkId} />
</Td>
<Td>
<DateTime iso={incident.opened_at} />
</Td>
<Td>
<DateTime iso={incident.resolved_at} />
</Td>
<Td className="max-w-md truncate text-xs text-neutral-400">{incident.summary ?? "—"}</Td>
</tr>
);
}
function IncidentCheckLink({ agentId, checkId }: { agentId: string; checkId: string }) {
if (!checkId) return <span className="text-neutral-500"></span>;
if (checkId === "agent_liveness") {
return (
<Link
href={`/checks/${encodeURIComponent(checkId)}`}
className="text-blue-300 hover:text-blue-200"
>
{checkId}
</Link>
);
}
return agentId ? <CheckEventsLink agentId={agentId} checkId={checkId} /> : <span>{checkId}</span>;
}
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 (
<button
type="button"
onClick={() => onClick(field)}
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
>
<span>{children}</span>
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
</button>
);
}
function fieldDefaultDir(field: SortField): SortDir {
return field === "host" || field === "check" ? "asc" : "desc";
}
function sortIncidents(
incidents: Incident[],
agentsById: Map<string, Agent>,
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";
}

View File

@@ -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 (
<>
<PageHeader title="Notification outbox" count={data.items.length} />
{data.items.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>updated_at</Th>
<Th>notifier</Th>
<Th>event</Th>
<Th>state</Th>
<Th>attempts</Th>
<Th>next_attempt_at</Th>
<Th>last_error</Th>
</tr>
</thead>
<tbody>
{data.items.map((o) => (
<tr key={o.id}>
<Td>
<DateTime iso={o.updated_at} />
</Td>
<Td>{o.notifier}</Td>
<Td>{o.event_type}</Td>
<Td className={statusBadgeClass(o.state)}>{o.state}</Td>
<Td>{o.attempts}</Td>
<Td>
<DateTime iso={o.next_attempt_at} />
</Td>
<Td className="text-xs text-red-300">{o.last_error ?? "—"}</Td>
</tr>
))}
</tbody>
</table>
)}
<Pager basePath="/outbox" cursor={cursor} nextCursor={data.next_cursor} />
</>
);
}

View File

@@ -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 (
<div className="space-y-6">
<h1 className="text-lg font-semibold">Overview</h1>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card title="Agents" total={data.totalAgents} href="/agents" tally={data.agents} />
<Card title="Checks" total={data.totalChecks} href="/checks" tally={data.checks} />
<Card
title="Open incidents"
total={data.openIncidents}
href="/incidents"
tally={{}}
highlight={data.openIncidents > 0 ? "open" : undefined}
/>
</div>
</div>
);
}
function Card({
title,
total,
href,
tally,
highlight,
}: {
title: string;
total: number;
href: string;
tally: Record<string, number>;
highlight?: string;
}) {
return (
<Link href={href} className="block border border-neutral-800 p-4 hover:border-neutral-600">
<div className="text-xs uppercase tracking-wide text-neutral-400">{title}</div>
<div className={`mt-1 text-3xl ${highlight ? statusBadgeClass(highlight) : ""}`}>{total}</div>
<div className="mt-2 flex flex-wrap gap-3 text-xs">
{Object.entries(tally).map(([k, v]) => (
<span key={k} className={statusBadgeClass(k)}>
{k} {v}
</span>
))}
</div>
</Link>
);
}

View File

@@ -206,7 +206,10 @@ export interface components {
}; };
HeartbeatRequest: { HeartbeatRequest: {
agent_id: string; 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; observed_at: string;
hostname: string; hostname: string;
features: components["schemas"]["AgentFeatures"]; features: components["schemas"]["AgentFeatures"];
@@ -236,7 +239,10 @@ export interface components {
/** @description UUIDv7 string (lower-case, with dashes). See ADR-0006. */ /** @description UUIDv7 string (lower-case, with dashes). See ADR-0006. */
event_id: string; event_id: string;
check_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; observed_at: string;
/** @enum {string} */ /** @enum {string} */
status: "ok" | "warning" | "critical" | "unknown"; status: "ok" | "warning" | "critical" | "unknown";
@@ -286,6 +292,7 @@ export interface components {
last_observed_at: string; last_observed_at: string;
exit_code?: number; exit_code?: number;
incident_key?: string; incident_key?: string;
summary?: string;
}; };
Incident: { Incident: {
id: string; id: string;

131
web/src/lib/check-groups.ts Normal file
View File

@@ -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<CheckStatus, number>;
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<CheckStatus, number> = {
ok: 0,
unknown: 1,
warning: 2,
critical: 3,
};
export function groupChecks(checks: CheckState[], agentsById: Map<string, Agent>): CheckGroup[] {
const byCheck = new Map<string, CheckGroup>();
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";
}
}

102
web/src/lib/loaders.ts Normal file
View File

@@ -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<Agent[]> {
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<CheckState[]> {
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<InventoryData> {
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<Agent[]> {
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<OverviewData> {
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<string, number> {
return xs.reduce<Record<string, number>>((acc, x) => {
const k = x.status ?? "unknown";
acc[k] = (acc[k] ?? 0) + 1;
return acc;
}, {});
}

View File

@@ -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 },
);
}

88
web/src/lib/usePolling.ts Normal file
View File

@@ -0,0 +1,88 @@
"use client";
import { useEffect, useState } from "react";
const DEFAULT_POLL_MS = 10_000;
export function usePolling<T>(initialData: T, url: string, intervalMs = pollIntervalMs()) {
const [data, setData] = useState(initialData);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (intervalMs <= 0) return;
let timer: ReturnType<typeof setInterval> | 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<T>;
})
.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;
}

17
web/src/lib/view-types.ts Normal file
View File

@@ -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<string, number>;
checks: Record<string, number>;
openIncidents: number;
totalAgents: number;
totalChecks: number;
};

View File

@@ -19,7 +19,7 @@ const fixtures = {
{ {
agent_id: "agent-2", agent_id: "agent-2",
hostname: "host-2", hostname: "host-2",
status: "stale", status: "dead",
last_seen_at: now, last_seen_at: now,
features: { push: true, metrics: false }, features: { push: true, metrics: false },
labels: {}, labels: {},
@@ -44,6 +44,7 @@ const fixtures = {
exit_code: 0, exit_code: 0,
last_observed_at: now, last_observed_at: now,
incident_key: "agent-1:disk", incident_key: "agent-1:disk",
summary: "disk ok",
}, },
{ {
agent_id: "agent-1", agent_id: "agent-1",
@@ -52,6 +53,7 @@ const fixtures = {
exit_code: 1, exit_code: 1,
last_observed_at: now, last_observed_at: now,
incident_key: "agent-1:load", incident_key: "agent-1:load",
summary: "load high",
}, },
], ],
next_cursor: null, next_cursor: null,
@@ -68,6 +70,27 @@ const fixtures = {
opened_at: now, opened_at: now,
summary: "load high", 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, next_cursor: null,
}, },
@@ -82,6 +105,22 @@ const fixtures = {
status: "ok", status: "ok",
exit_code: 0, exit_code: 0,
duration_ms: 12, 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, 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"); res.setHeader("Content-Type", "application/json");
if (!body) { if (!body) {
res.statusCode = 404; res.statusCode = 404;

View File

@@ -38,6 +38,8 @@ test("agents list links to detail", async ({ page }) => {
await expect(page.getByRole("link", { name: "Checks" }).last()).toBeVisible(); await expect(page.getByRole("link", { name: "Checks" }).last()).toBeVisible();
await expect(page.locator("tbody").first().locator("tr").first()).toContainText("warning"); 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("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 page.getByRole("link", { name: "check_id" }).click();
await expect(page).toHaveURL(/sort=check_id/); await expect(page).toHaveURL(/sort=check_id/);
await page.getByRole("link", { name: "Events" }).last().click(); 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 }) => { test("checks page lists rows", async ({ page }) => {
await page.goto("/checks"); 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("disk");
await expect(page.locator("body")).toContainText("load"); 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 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 }) => { test("incidents filter renders", async ({ page }) => {
await page.goto("/incidents"); await page.goto("/incidents");
await expect(page.locator("body")).toContainText("load high"); 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 }) => { 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 page.goto("/events?agent_id=agent-1&check_id=disk");
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
await expect(page.getByRole("button", { name: "query" })).toHaveCount(0); await expect(page.getByRole("button", { name: "query" })).toHaveCount(0);