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"
/api/v1/events/query:
get:
summary: Query stored events
summary: Query check status-change history
operationId: queryEvents
parameters:
- name: agent_id
@@ -368,11 +368,16 @@ components:
accepted:
type: integer
minimum: 0
description: Number of events stored on this request.
description: |
Number of events that produced a new status-change row on this request.
deduplicated:
type: integer
minimum: 0
description: Number of events whose `event_id` was already known.
description: |
Number of events that were not stored as a change. Includes events whose
`event_id` was already known, events whose `(status, exit_code)` matched
the previously observed value for the same check, and events received late
(older than the current `last_observed_at` of the check).
HeartbeatRequest:
type: object
additionalProperties: false
@@ -486,7 +491,11 @@ components:
type: string
maxLength: 256
StoredCheckResultEvent:
description: Stored event returned by `GET /events/query`. Includes the resolved `agent_id` and server-side `received_at`.
description: |
Stored status-change event returned by `GET /events/query`. The server persists
only events that change `(status, exit_code)` versus the previous observed value
for the same `(agent_id, check_id)`. Repeated identical results are not stored.
Includes the resolved `agent_id` and server-side `received_at`.
allOf:
- $ref: "#/components/schemas/CheckResultEvent"
- type: object
@@ -537,6 +546,9 @@ components:
type: integer
incident_key:
type: string
summary:
type: string
maxLength: 8192
Incident:
type: object
required: [id, incident_key, state, severity, opened_at]

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"' \
|| fail "agent-dead did not transition to dead"
log "asserting dead agents have open liveness incidents"
api "/api/v1/incidents?state=open&limit=500" | python3 -c "
import json,sys
d=json.load(sys.stdin)
want={'agent-stale','agent-dead'}
have={
i['agent_id']
for i in d['items']
if i['check_id']=='agent_liveness' and i['state']=='open' and i['severity']=='critical'
}
missing=want-have
print('liveness incidents:', sorted(have))
sys.exit(0 if not missing else 1)" \
|| fail "missing critical agent_liveness incident for dead agents"
# At least one agent must still be alive (agent-mixed keeps pushing).
api "/api/v1/agents/agent-mixed" | grep -q '"status":"alive"' \
|| fail "agent-mixed not alive"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,9 @@
import asyncio
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from sqlalchemy import delete, func, select, update
from sqlalchemy import delete, func, select, text
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import async_sessionmaker
from .. import metrics
@@ -12,6 +14,138 @@ from ..settings import get_settings
log = get_logger("monlet.detector")
OUTBOX_TERMINAL_STATES = ("sent", "failed", "discarded")
LIVENESS_CHECK_ID = "agent_liveness"
def _liveness_key(agent_id: str) -> str:
return f"agent:{agent_id}:liveness"
def _liveness_severity(status: str) -> str:
return "critical" if status == "dead" else "warning"
async def _enqueue_liveness_outbox(
session,
incident: Incident,
event_type: str,
agent: Agent,
status: str,
now: datetime,
summary: str,
severity: str,
) -> None:
notifiers = get_settings().enabled_notifiers
if not notifiers:
return
for name in notifiers:
session.add(
NotificationOutbox(
id=uuid4(),
incident_id=incident.id,
notifier=name,
event_type=event_type,
state="pending",
attempts=0,
next_attempt_at=now,
payload={
"incident_id": str(incident.id),
"agent_id": agent.agent_id,
"check_id": LIVENESS_CHECK_ID,
"status": status,
"severity": severity,
"incident_key": incident.incident_key,
"observed_at": now.isoformat(),
"opened_at": incident.opened_at.isoformat() if incident.opened_at else None,
"resolved_at": incident.resolved_at.isoformat()
if incident.resolved_at
else None,
"summary": summary,
"output": summary,
"event_type": event_type,
},
)
)
async def _open_liveness_incident(session, agent: Agent, status: str, now: datetime) -> None:
severity = _liveness_severity(status)
summary = f"agent is {status}"
incident_key = _liveness_key(agent.agent_id)
new_id = uuid4()
stmt = (
pg_insert(Incident)
.values(
id=new_id,
incident_key=incident_key,
agent_id=agent.agent_id,
check_id=LIVENESS_CHECK_ID,
state="open",
severity=severity,
opened_at=now,
summary=summary,
last_event_id=uuid4(),
)
.on_conflict_do_nothing(
index_elements=[Incident.incident_key],
index_where=text("state = 'open'"),
)
.returning(Incident.id)
)
inserted_id = (await session.execute(stmt)).scalar_one_or_none()
if inserted_id is not None:
await session.flush()
inc = (
await session.execute(select(Incident).where(Incident.id == inserted_id))
).scalar_one()
metrics.incidents_opened_total.inc()
await _enqueue_liveness_outbox(
session, inc, "firing", agent, status, now, summary=summary, severity=severity
)
return
inc = (
await session.execute(
select(Incident).where(
Incident.incident_key == incident_key,
Incident.state == "open",
)
)
).scalar_one()
if status == "dead" and inc.severity != "critical":
inc.severity = "critical"
inc.summary = summary
inc.last_event_id = uuid4()
await _enqueue_liveness_outbox(
session, inc, "firing", agent, status, now, summary=summary, severity="critical"
)
async def _resolve_liveness_incident(session, agent: Agent, now: datetime) -> None:
inc = (
await session.execute(
select(Incident).where(
Incident.incident_key == _liveness_key(agent.agent_id),
Incident.state == "open",
)
)
).scalar_one_or_none()
if inc is None:
return
inc.state = "resolved"
inc.resolved_at = now
inc.last_event_id = uuid4()
metrics.incidents_resolved_total.inc()
await _enqueue_liveness_outbox(
session,
inc,
"resolved",
agent,
"ok",
now,
summary="agent is alive",
severity="ok",
)
async def _prune_history(session) -> None:
@@ -44,25 +178,20 @@ async def _tick(sm: async_sessionmaker) -> None:
stale_cut = now - timedelta(seconds=s.stale_after_sec)
dead_cut = now - timedelta(seconds=s.dead_after_sec)
async with sm() as session:
await session.execute(
update(Agent)
.where(Agent.last_seen_at <= dead_cut)
.where(Agent.status != "dead")
.values(status="dead")
)
await session.execute(
update(Agent)
.where(Agent.last_seen_at <= stale_cut)
.where(Agent.last_seen_at > dead_cut)
.where(Agent.status != "stale")
.values(status="stale")
)
await session.execute(
update(Agent)
.where(Agent.last_seen_at > stale_cut)
.where(Agent.status != "alive")
.values(status="alive")
)
agents = (await session.execute(select(Agent))).scalars().all()
for agent in agents:
if agent.last_seen_at <= dead_cut:
next_status = "dead"
elif agent.last_seen_at <= stale_cut:
next_status = "stale"
else:
next_status = "alive"
if agent.status != next_status:
agent.status = next_status
if next_status in ("stale", "dead"):
await _open_liveness_incident(session, agent, next_status, now)
else:
await _resolve_liveness_incident(session, agent, now)
await _prune_history(session)
await session.commit()

View File

@@ -155,11 +155,24 @@ async def ingest_event(
agent_id: str,
ev: CheckResultEvent,
) -> bool:
"""Return True if accepted, False if deduplicated."""
"""Return True if a new change row was written, False if not (duplicate/unchanged/late)."""
observed = to_utc(ev.observed_at)
output = redact(ev.output)
check_summary = (output or "")[:200] if output else None
incident_key = _incident_key(agent_id, ev)
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(
event_id=UUID(ev.event_id),
agent_id=agent_id,
@@ -173,41 +186,48 @@ async def ingest_event(
notifications_enabled=ev.notifications_enabled,
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)
inserted = result.scalar_one_or_none()
if inserted is None:
metrics.events_total.labels(result="deduplicated").inc()
return False
metrics.events_total.labels(result="accepted").inc()
chk_res = await session.execute(
select(Check).where(Check.agent_id == agent_id, Check.check_id == ev.check_id)
)
cur = chk_res.scalar_one_or_none()
is_late = cur is not None and cur.last_observed_at > observed
if is_late:
return True
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:
cur.status = ev.status
cur.exit_code = ev.exit_code
cur.last_observed_at = observed
cur.last_event_id = UUID(ev.event_id)
cur.incident_key = incident_key
metrics.events_total.labels(result="unchanged").inc()
# Upsert Check to be safe under concurrent first-write race for the same (agent_id, check_id).
# When changed=False keep the existing last_event_id and incident_key — incident_key changes
# are only meaningful at transitions and would otherwise desync from any open Incident.
check_values = {
"agent_id": agent_id,
"check_id": ev.check_id,
"status": ev.status,
"exit_code": ev.exit_code,
"last_observed_at": observed,
"last_event_id": UUID(ev.event_id),
"incident_key": incident_key,
"summary": check_summary,
}
update_set = {
"status": ev.status,
"exit_code": ev.exit_code,
"last_observed_at": observed,
"summary": check_summary,
}
if changed:
update_set["last_event_id"] = UUID(ev.event_id)
update_set["incident_key"] = incident_key
chk_stmt = pg_insert(Check).values(**check_values)
chk_stmt = chk_stmt.on_conflict_do_update(
index_elements=[Check.agent_id, Check.check_id], set_=update_set
)
await session.execute(chk_stmt)
if not changed:
return False
transition, inc = await _apply_incident(session, agent_id, ev, incident_key)
if transition and inc is not None and ev.notifications_enabled:

View File

@@ -20,7 +20,7 @@ class Settings(BaseSettings):
port: int = 8000
enable_detector: bool = True
enable_notifier_worker: bool = True
events_retention_max_rows: int = 100_000
events_retention_max_rows: int = 20_000
outbox_retention_max_rows: int = 50_000
notifier_tick_sec: int = 5
notifier_batch_size: int = 20

View File

@@ -6,7 +6,7 @@ from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from monlet_server.models import Agent, Event, Incident, NotificationOutbox
from monlet_server.services.detector import _tick
from monlet_server.services.detector import LIVENESS_CHECK_ID, _tick
from monlet_server.settings import reset_settings_cache
from ._helpers import make_event, make_heartbeat
@@ -39,6 +39,7 @@ async def test_detector_transitions(app_client, auth_headers, engine, session):
sm = async_sessionmaker(engine, expire_on_commit=False)
await _tick(sm)
session.expire_all()
statuses = {
r.agent_id: r.status for r in (await session.execute(select(Agent))).scalars().all()
@@ -47,6 +48,38 @@ async def test_detector_transitions(app_client, auth_headers, engine, session):
assert statuses["agent-stale"] == "stale"
assert statuses["agent-dead"] == "dead"
incidents = (
(await session.execute(select(Incident).where(Incident.check_id == LIVENESS_CHECK_ID)))
.scalars()
.all()
)
by_agent = {i.agent_id: i for i in incidents}
assert "agent-alive" not in by_agent
assert by_agent["agent-stale"].state == "open"
assert by_agent["agent-stale"].severity == "warning"
assert by_agent["agent-stale"].summary == "agent is stale"
assert by_agent["agent-dead"].state == "open"
assert by_agent["agent-dead"].severity == "critical"
assert by_agent["agent-dead"].summary == "agent is dead"
await session.execute(
update(Agent).where(Agent.agent_id == "agent-dead").values(last_seen_at=datetime.now(UTC))
)
await session.commit()
await _tick(sm)
session.expire_all()
resolved = (
await session.execute(
select(Incident).where(
Incident.agent_id == "agent-dead",
Incident.check_id == LIVENESS_CHECK_ID,
)
)
).scalar_one()
assert resolved.state == "resolved"
assert resolved.resolved_at is not None
@pytest.mark.asyncio
async def test_detector_prunes_bounded_history(

View File

@@ -9,7 +9,7 @@ from ._helpers import make_event, make_heartbeat
@pytest.mark.asyncio
async def test_event_accepted_and_state(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
ev = make_event(status="ok")
ev = make_event(status="ok", output="disk ok")
r = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
)
@@ -19,6 +19,7 @@ async def test_event_accepted_and_state(app_client, auth_headers, session):
assert n == 1
chk = (await session.execute(select(Check))).scalar_one()
assert chk.status == "ok"
assert chk.summary == ev["output"]
@pytest.mark.asyncio
@@ -64,6 +65,53 @@ async def test_oversize_batch_400(app_client, auth_headers):
assert r.status_code == 400
@pytest.mark.asyncio
async def test_repeated_same_status_not_stored(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
evs = [make_event(status="ok") for _ in range(5)]
r = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers
)
assert r.status_code == 202
assert r.json() == {"accepted": 1, "deduplicated": 4}
n = (await session.execute(select(func.count()).select_from(Event))).scalar_one()
assert n == 1
@pytest.mark.asyncio
async def test_status_transitions_stored(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
seq = [
make_event(status="ok"),
make_event(status="warning", exit_code=1),
make_event(status="warning", exit_code=1),
make_event(status="critical", exit_code=2),
make_event(status="ok"),
]
r = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": seq}, headers=auth_headers
)
assert r.status_code == 202
assert r.json() == {"accepted": 4, "deduplicated": 1}
n = (await session.execute(select(func.count()).select_from(Event))).scalar_one()
assert n == 4
@pytest.mark.asyncio
async def test_same_status_different_exit_code_stored(app_client, auth_headers, session):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
seq = [
make_event(status="warning", exit_code=1),
make_event(status="warning", exit_code=3),
]
r = await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": seq}, headers=auth_headers
)
assert r.json() == {"accepted": 2, "deduplicated": 0}
n = (await session.execute(select(func.count()).select_from(Event))).scalar_one()
assert n == 2
@pytest.mark.asyncio
async def test_late_event_does_not_move_state(app_client, auth_headers, session):
from datetime import UTC, datetime, timedelta
@@ -71,7 +119,9 @@ async def test_late_event_does_not_move_state(app_client, auth_headers, session)
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
newer = datetime.now(UTC).replace(microsecond=0)
older = (newer - timedelta(minutes=5)).isoformat()
ev_now = make_event(status="critical", exit_code=2, observed_at=newer.isoformat())
ev_now = make_event(
status="critical", exit_code=2, observed_at=newer.isoformat(), output="current critical"
)
ev_old = make_event(status="ok", exit_code=0, observed_at=older)
await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev_now]}, headers=auth_headers
@@ -81,5 +131,6 @@ async def test_late_event_does_not_move_state(app_client, auth_headers, session)
)
chk = (await session.execute(select(Check))).scalar_one()
assert chk.status == "critical"
assert chk.summary == ev_now["output"]
n = (await session.execute(select(func.count()).select_from(Event))).scalar_one()
assert n == 2
assert n == 1

View File

@@ -35,7 +35,7 @@ async def test_incident_key_too_long(app_client, auth_headers):
@pytest.mark.asyncio
async def test_events_query_cursor_pagination(app_client, auth_headers):
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
evs = [make_event() for _ in range(5)]
evs = [make_event(check_id=f"chk-{i}") for i in range(5)]
await app_client.post(
"/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers
)

View File

@@ -1,55 +1,14 @@
import Link from "next/link";
import { AgentFeatures } from "@/components/AgentFeatures";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { ErrorPanel, Td, Th } from "@/components/DataPanel";
import { LabelChips } from "@/components/Labels";
import { DateTime } from "@/components/TimeZoneControls";
import { ApiError, type Agent, type CheckState, type StoredEvent, api } from "@/lib/api";
import { statusBadgeClass } from "@/lib/format";
import { AgentDetailBrowser } from "@/components/AgentDetailBrowser";
import { ErrorPanel } from "@/components/DataPanel";
import { ApiError, api } from "@/lib/api";
import { withLivenessEvents } from "@/lib/check-groups";
import { loadChecks } from "@/lib/loaders";
export const dynamic = "force-dynamic";
type SortKey = "severity" | "check_id" | "last_seen";
type TabKey = "checks" | "events";
type LoadResult =
| { kind: "ok"; agent: Agent; checks: CheckState[]; events: StoredEvent[] }
| { kind: "not_found" }
| { kind: "error"; error: unknown };
const severityRank: Record<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 {
return sort === "check_id" || sort === "last_seen" ? sort : "severity";
}
@@ -58,14 +17,6 @@ function normalizeTab(tab?: string): TabKey {
return tab === "events" ? "events" : "checks";
}
function sortChecks(checks: CheckState[], sort: SortKey): CheckState[] {
return [...checks].sort((a, b) => {
if (sort === "check_id") return a.check_id.localeCompare(b.check_id);
if (sort === "last_seen") return b.last_observed_at.localeCompare(a.last_observed_at);
return severityRank[a.status] - severityRank[b.status] || a.check_id.localeCompare(b.check_id);
});
}
export default async function AgentDetailPage({
params,
searchParams,
@@ -77,163 +28,27 @@ export default async function AgentDetailPage({
const sp = await searchParams;
const sort = normalizeSort(sp.sort);
const tab = normalizeTab(sp.tab);
const r = await load(agent_id, sort, tab);
if (r.kind === "not_found") return <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 }) {
const base = `/agents/${encodeURIComponent(agentId)}`;
let data;
try {
const [agent, checks, events] = await Promise.all([
api.getAgent(agent_id),
loadChecks(agent_id),
api.queryEvents({ agent_id, limit: 50 }).then((page) => page.items),
]);
data = { agent, checks, events: withLivenessEvents([agent], events, { agentId: agent_id }) };
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
return <div className="text-sm text-neutral-400">Agent not found.</div>;
}
return <ErrorPanel error={e} />;
}
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>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>
<AgentDetailBrowser
key={`${agent_id}:${tab}:${sort}`}
initialData={data}
sort={sort}
tab={tab}
/>
);
}

View File

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

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 { CheckEventsLink } from "@/components/CheckEventsLink";
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { Pager } from "@/components/Pager";
import { DateTime } from "@/components/TimeZoneControls";
import { api } from "@/lib/api";
import { statusBadgeClass } from "@/lib/format";
import { ChecksBrowser } from "@/components/ChecksBrowser";
import { ErrorPanel } from "@/components/DataPanel";
import { loadInventory } from "@/lib/loaders";
export const dynamic = "force-dynamic";
export default async function ChecksPage({
searchParams,
}: {
searchParams: Promise<{ cursor?: string }>;
}) {
const sp = await searchParams;
export default async function ChecksPage() {
let data;
try {
data = await api.listChecks({ cursor: sp.cursor });
data = await loadInventory();
} catch (e) {
return <ErrorPanel error={e} />;
}
return (
<>
<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} />
</>
);
return <ChecksBrowser initialData={data} />;
}

View File

@@ -1,11 +1,6 @@
import Link from "next/link";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { Pager } from "@/components/Pager";
import { DateTime } from "@/components/TimeZoneControls";
import { api } from "@/lib/api";
import { statusBadgeClass } from "@/lib/format";
import { ErrorPanel } from "@/components/DataPanel";
import { EventsBrowser } from "@/components/EventsBrowser";
import { loadEventsWithLiveness } from "@/lib/loaders";
export const dynamic = "force-dynamic";
@@ -17,102 +12,21 @@ export default async function EventsPage({
const sp = await searchParams;
let data;
try {
data = await api.queryEvents({
agent_id: sp.agent_id,
check_id: sp.check_id,
data = await loadEventsWithLiveness({
agentId: sp.agent_id,
checkId: sp.check_id,
cursor: sp.cursor,
});
} catch (e) {
return <ErrorPanel error={e} />;
}
return (
<>
<PageHeader title="Events" count={data.items.length} />
<ActiveFilters agentId={sp.agent_id} checkId={sp.check_id} />
{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"
<EventsBrowser
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}`}
initialData={data}
cursor={sp.cursor}
nextCursor={data.next_cursor}
extra={{ agent_id: sp.agent_id, check_id: sp.check_id }}
agentId={sp.agent_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 { CheckEventsLink } from "@/components/CheckEventsLink";
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { Pager } from "@/components/Pager";
import { DateTime } from "@/components/TimeZoneControls";
import { api } from "@/lib/api";
import { statusBadgeClass } from "@/lib/format";
import { ErrorPanel } from "@/components/DataPanel";
import { IncidentsBrowser } from "@/components/IncidentsBrowser";
import { loadIncidents, loadInventory } from "@/lib/loaders";
export const dynamic = "force-dynamic";
const STATES = ["all", "open", "resolved"] as const;
export default async function IncidentsPage({
searchParams,
}: {
searchParams: Promise<{ cursor?: string; state?: string }>;
searchParams: Promise<{ state?: string }>;
}) {
const sp = await searchParams;
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
let data;
let inventory;
try {
data = await api.listIncidents({ state, cursor: sp.cursor });
[data, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
} catch (e) {
return <ErrorPanel error={e} />;
}
return (
<>
<PageHeader title="Incidents" count={data.items.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>
{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 }}
<IncidentsBrowser
key={state ?? ""}
initialData={data}
initialInventory={inventory}
state={state}
/>
</>
);
}

View File

@@ -1,6 +1,5 @@
import type { Metadata } from "next";
import Link from "next/link";
import { AutoRefresh } from "@/components/AutoRefresh";
import { TimeZoneCarousel, TimeZoneProvider } from "@/components/TimeZoneControls";
import { getUiTimeZone } from "@/lib/timezone";
import "./globals.css";
@@ -19,16 +18,6 @@ const NAV = [
{ href: "/outbox", label: "Outbox" },
];
const DEFAULT_REFRESH_MS = 10_000;
function getRefreshIntervalMs(): number {
const raw = process.env.NEXT_PUBLIC_MONLET_REFRESH_MS?.trim();
if (!raw) return DEFAULT_REFRESH_MS;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : DEFAULT_REFRESH_MS;
}
function Brand() {
return (
<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 }) {
const refreshIntervalMs = getRefreshIntervalMs();
const configuredTimeZone = getUiTimeZone();
return (
@@ -66,7 +54,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<TimeZoneCarousel />
</header>
<main className="flex-1 px-4 py-6">{children}</main>
{refreshIntervalMs > 0 && <AutoRefresh intervalMs={refreshIntervalMs} />}
</TimeZoneProvider>
</body>
</html>

View File

@@ -1,8 +1,6 @@
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { Pager } from "@/components/Pager";
import { DateTime } from "@/components/TimeZoneControls";
import { ErrorPanel } from "@/components/DataPanel";
import { OutboxBrowser } from "@/components/OutboxBrowser";
import { api } from "@/lib/api";
import { statusBadgeClass } from "@/lib/format";
export const dynamic = "force-dynamic";
@@ -18,44 +16,5 @@ export default async function OutboxPage({
} catch (e) {
return <ErrorPanel error={e} />;
}
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={sp.cursor} nextCursor={data.next_cursor} />
</>
);
return <OutboxBrowser key={sp.cursor ?? ""} initialData={data} cursor={sp.cursor} />;
}

View File

@@ -1,81 +1,15 @@
import Link from "next/link";
import { ErrorPanel } from "@/components/DataPanel";
import { api } from "@/lib/api";
import { statusBadgeClass } from "@/lib/format";
import { OverviewDashboard } from "@/components/OverviewDashboard";
import { loadOverview } from "@/lib/loaders";
export const dynamic = "force-dynamic";
async function load() {
const [agents, checks, incidents] = await Promise.all([
api.listAgents({ limit: 500 }),
api.listChecks({ limit: 500 }),
api.listIncidents({ state: "open", limit: 500 }),
]);
const tally = (xs: { status?: string }[]) =>
xs.reduce<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() {
let data: Awaited<ReturnType<typeof load>>;
let data;
try {
data = await load();
data = await loadOverview();
} catch (e) {
return <ErrorPanel error={e} />;
}
return (
<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>
);
return <OverviewDashboard initialData={data} />;
}

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"] }) {
const enabled = enabledFeatures(features);

View File

@@ -6,9 +6,13 @@ import { useMemo, useState } from "react";
import { AgentFeatures } from "@/components/AgentFeatures";
import { LabelChips } from "@/components/Labels";
import { DateTime } from "@/components/TimeZoneControls";
import type { Agent, CheckState } from "@/lib/api";
import type { components } from "@/lib/api-types";
import { statusBadgeClass } from "@/lib/format";
import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types";
type Agent = components["schemas"]["Agent"];
type CheckState = components["schemas"]["CheckState"];
type CheckStatus = CheckState["status"];
type CheckCounts = Record<CheckStatus, number>;
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 checkSortStatuses: CheckStatus[] = ["critical", "warning", "unknown", "ok"];
export function AgentsBrowser({ rows: initialRows }: { rows: AgentRow[] }) {
export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
const { data } = usePolling(initialData, "/api/poll/inventory");
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortField>("status");
const [dir, setDir] = useState<SortDir>("desc");
const initialRows = useMemo(() => rowsFromInventory(data), [data]);
const rows = useMemo(
() => sortAgents(filterAgents(initialRows, query), sort, dir),
@@ -239,6 +245,19 @@ function emptyCheckCounts(): CheckCounts {
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
}
function rowsFromInventory(data: InventoryData): AgentRow[] {
const checksByAgent = new Map<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 {
switch (status) {
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 }) {
return (
<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 }) {
const e = error instanceof Error ? error : new Error(String(error));
const status = error instanceof ApiError ? error.status : "—";
const status = hasStatus(error) ? error.status : "—";
return (
<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>
@@ -20,6 +18,10 @@ export function ErrorPanel({ error }: { error: unknown }) {
);
}
function hasStatus(error: unknown): error is { status: number } {
return typeof error === "object" && error !== null && "status" in error;
}
export function EmptyPanel({ label = "No data" }: { label?: string }) {
return <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: {
agent_id: string;
/** Format: date-time */
/**
* Format: date-time
* @description UTC RFC3339 timestamp. Timezone is required; use `Z` or `+00:00`. Non-UTC offsets are rejected.
*/
observed_at: string;
hostname: string;
features: components["schemas"]["AgentFeatures"];
@@ -236,7 +239,10 @@ export interface components {
/** @description UUIDv7 string (lower-case, with dashes). See ADR-0006. */
event_id: string;
check_id: string;
/** Format: date-time */
/**
* Format: date-time
* @description UTC RFC3339 timestamp. Timezone is required; use `Z` or `+00:00`. Non-UTC offsets are rejected.
*/
observed_at: string;
/** @enum {string} */
status: "ok" | "warning" | "critical" | "unknown";
@@ -286,6 +292,7 @@ export interface components {
last_observed_at: string;
exit_code?: number;
incident_key?: string;
summary?: string;
};
Incident: {
id: string;

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",
hostname: "host-2",
status: "stale",
status: "dead",
last_seen_at: now,
features: { push: true, metrics: false },
labels: {},
@@ -44,6 +44,7 @@ const fixtures = {
exit_code: 0,
last_observed_at: now,
incident_key: "agent-1:disk",
summary: "disk ok",
},
{
agent_id: "agent-1",
@@ -52,6 +53,7 @@ const fixtures = {
exit_code: 1,
last_observed_at: now,
incident_key: "agent-1:load",
summary: "load high",
},
],
next_cursor: null,
@@ -68,6 +70,27 @@ const fixtures = {
opened_at: now,
summary: "load high",
},
{
id: "00000000-0000-0000-0000-000000000003",
incident_key: "agent:agent-2:liveness",
state: "open",
severity: "critical",
agent_id: "agent-2",
check_id: "agent_liveness",
opened_at: now,
summary: "agent is dead",
},
{
id: "00000000-0000-0000-0000-000000000004",
incident_key: "agent-2:disk",
state: "resolved",
severity: "critical",
agent_id: "agent-2",
check_id: "disk",
opened_at: now,
resolved_at: now,
summary: "disk recovered",
},
],
next_cursor: null,
},
@@ -82,6 +105,22 @@ const fixtures = {
status: "ok",
exit_code: 0,
duration_ms: 12,
output: "disk ok",
output_truncated: false,
notifications_enabled: true,
},
{
event_id: "00000000-0000-7000-8000-000000000002",
agent_id: "agent-1",
check_id: "load",
observed_at: now,
received_at: now,
status: "warning",
exit_code: 1,
duration_ms: 34,
output: "load high",
output_truncated: false,
notifications_enabled: true,
},
],
next_cursor: null,
@@ -117,6 +156,29 @@ const server = createServer((req, res) => {
};
}
}
if (url.pathname === "/api/v1/events/query" && body) {
const agentId = url.searchParams.get("agent_id");
const checkId = url.searchParams.get("check_id");
body = {
...body,
items: body.items.filter((item) => {
if (agentId && item.agent_id !== agentId) return false;
if (checkId && item.check_id !== checkId) return false;
return true;
}),
next_cursor: null,
};
}
if (url.pathname === "/api/v1/incidents" && body) {
const state = url.searchParams.get("state");
if (state) {
body = {
...body,
items: body.items.filter((item) => item.state === state),
next_cursor: null,
};
}
}
res.setHeader("Content-Type", "application/json");
if (!body) {
res.statusCode = 404;

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.locator("tbody").first().locator("tr").first()).toContainText("warning");
await expect(page.locator("thead").first()).not.toContainText("incident_key");
await expect(page.locator("thead").first()).not.toContainText("exit");
await expect(page.locator("tbody").first()).toContainText("load high");
await page.getByRole("link", { name: "check_id" }).click();
await expect(page).toHaveURL(/sort=check_id/);
await page.getByRole("link", { name: "Events" }).last().click();
@@ -49,18 +51,57 @@ test("agents list links to detail", async ({ page }) => {
test("checks page lists rows", async ({ page }) => {
await page.goto("/checks");
await expect(page.getByRole("heading", { name: "Checks" })).toBeVisible();
await expect(page).toHaveURL(/\/checks$/);
await expect(page.locator("thead").first()).not.toContainText("agent_id");
await expect(page.locator("thead").first()).not.toContainText("incident_key");
await expect(page.locator("thead").first()).not.toContainText("exit");
await expect(page.locator("thead").first()).toContainText("↓");
await expect(page.locator("body")).toContainText("disk");
await expect(page.locator("body")).toContainText("load");
await expect(page.locator("body")).toContainText("agent_liveness");
await page.getByPlaceholder("search check").fill("lo");
await expect(page).toHaveURL(/\/checks$/);
await expect(page.locator("tbody")).toContainText("load");
await expect(page.locator("tbody")).not.toContainText("disk");
await page.getByRole("button", { name: "clear" }).click();
await page.getByRole("button", { name: /^CHECK/ }).click();
await expect(page).toHaveURL(/\/checks$/);
await page.getByRole("link", { name: "disk" }).click();
await expect(page).toHaveURL(/\/events\?agent_id=agent-1&check_id=disk$/);
await expect(page).toHaveURL(/\/checks\/disk$/);
await expect(page.getByRole("link", { name: "Agents" }).last()).toBeVisible();
await expect(page.locator("body")).toContainText("host-1");
await expect(page.locator("body")).toContainText("disk ok");
await page.getByRole("link", { name: "Events" }).last().click();
await expect(page).toHaveURL(/\/checks\/disk\?tab=events$/);
await expect(page.locator("body")).toContainText("12");
await expect(page.locator("thead").last()).not.toContainText("exit");
});
test("incidents filter renders", async ({ page }) => {
await page.goto("/incidents");
await expect(page.locator("body")).toContainText("load high");
await expect(page.locator("thead")).toContainText("HOST");
await expect(page.locator("thead")).not.toContainText("agent_id");
await expect(page.locator("thead")).toContainText("↓");
await expect(page.locator("tbody")).toContainText("host-1");
await expect(page.locator("tbody")).toContainText("agent_liveness");
await expect(page.locator("tbody")).toContainText("agent is dead");
await page.getByRole("link", { name: "agent_liveness" }).click();
await expect(page).toHaveURL(/\/checks\/agent_liveness$/);
await expect(page.locator("body")).toContainText("host-2");
await page.goto("/incidents");
await page.getByRole("button", { name: /^HOST/ }).click();
await expect(page).toHaveURL(/\/incidents$/);
await expect(page.locator("thead")).toContainText("↑");
await page.getByRole("link", { name: "host-1" }).click();
await expect(page).toHaveURL(/\/agents\/agent-1$/);
});
test("events page", async ({ page }) => {
await page.goto("/events");
await expect(page.locator("body")).toContainText("agent_liveness");
await expect(page.locator("body")).toContainText("agent is dead");
await page.goto("/events?agent_id=agent-1&check_id=disk");
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
await expect(page.getByRole("button", { name: "query" })).toHaveCount(0);