store only status-change events; UI client-side polling refactor
This commit is contained in:
@@ -195,7 +195,7 @@ paths:
|
|||||||
$ref: "#/components/responses/Unauthorized"
|
$ref: "#/components/responses/Unauthorized"
|
||||||
/api/v1/events/query:
|
/api/v1/events/query:
|
||||||
get:
|
get:
|
||||||
summary: Query stored events
|
summary: Query check status-change history
|
||||||
operationId: queryEvents
|
operationId: queryEvents
|
||||||
parameters:
|
parameters:
|
||||||
- name: agent_id
|
- name: agent_id
|
||||||
@@ -368,11 +368,16 @@ components:
|
|||||||
accepted:
|
accepted:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 0
|
minimum: 0
|
||||||
description: Number of events stored on this request.
|
description: |
|
||||||
|
Number of events that produced a new status-change row on this request.
|
||||||
deduplicated:
|
deduplicated:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 0
|
minimum: 0
|
||||||
description: Number of events whose `event_id` was already known.
|
description: |
|
||||||
|
Number of events that were not stored as a change. Includes events whose
|
||||||
|
`event_id` was already known, events whose `(status, exit_code)` matched
|
||||||
|
the previously observed value for the same check, and events received late
|
||||||
|
(older than the current `last_observed_at` of the check).
|
||||||
HeartbeatRequest:
|
HeartbeatRequest:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
@@ -486,7 +491,11 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
maxLength: 256
|
maxLength: 256
|
||||||
StoredCheckResultEvent:
|
StoredCheckResultEvent:
|
||||||
description: Stored event returned by `GET /events/query`. Includes the resolved `agent_id` and server-side `received_at`.
|
description: |
|
||||||
|
Stored status-change event returned by `GET /events/query`. The server persists
|
||||||
|
only events that change `(status, exit_code)` versus the previous observed value
|
||||||
|
for the same `(agent_id, check_id)`. Repeated identical results are not stored.
|
||||||
|
Includes the resolved `agent_id` and server-side `received_at`.
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/components/schemas/CheckResultEvent"
|
- $ref: "#/components/schemas/CheckResultEvent"
|
||||||
- type: object
|
- type: object
|
||||||
@@ -537,6 +546,9 @@ components:
|
|||||||
type: integer
|
type: integer
|
||||||
incident_key:
|
incident_key:
|
||||||
type: string
|
type: string
|
||||||
|
summary:
|
||||||
|
type: string
|
||||||
|
maxLength: 8192
|
||||||
Incident:
|
Incident:
|
||||||
type: object
|
type: object
|
||||||
required: [id, incident_key, state, severity, opened_at]
|
required: [id, incident_key, state, severity, opened_at]
|
||||||
|
|||||||
@@ -125,6 +125,21 @@ api "/api/v1/agents/agent-stale" | grep -q '"status":"dead"' \
|
|||||||
api "/api/v1/agents/agent-dead" | grep -q '"status":"dead"' \
|
api "/api/v1/agents/agent-dead" | grep -q '"status":"dead"' \
|
||||||
|| fail "agent-dead did not transition to dead"
|
|| fail "agent-dead did not transition to dead"
|
||||||
|
|
||||||
|
log "asserting dead agents have open liveness incidents"
|
||||||
|
api "/api/v1/incidents?state=open&limit=500" | python3 -c "
|
||||||
|
import json,sys
|
||||||
|
d=json.load(sys.stdin)
|
||||||
|
want={'agent-stale','agent-dead'}
|
||||||
|
have={
|
||||||
|
i['agent_id']
|
||||||
|
for i in d['items']
|
||||||
|
if i['check_id']=='agent_liveness' and i['state']=='open' and i['severity']=='critical'
|
||||||
|
}
|
||||||
|
missing=want-have
|
||||||
|
print('liveness incidents:', sorted(have))
|
||||||
|
sys.exit(0 if not missing else 1)" \
|
||||||
|
|| fail "missing critical agent_liveness incident for dead agents"
|
||||||
|
|
||||||
# At least one agent must still be alive (agent-mixed keeps pushing).
|
# At least one agent must still be alive (agent-mixed keeps pushing).
|
||||||
api "/api/v1/agents/agent-mixed" | grep -q '"status":"alive"' \
|
api "/api/v1/agents/agent-mixed" | grep -q '"status":"alive"' \
|
||||||
|| fail "agent-mixed not alive"
|
|| fail "agent-mixed not alive"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
echo "ok: $(date -u +%FT%TZ)"
|
printf "ok"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ def upgrade() -> None:
|
|||||||
last_observed_at TIMESTAMPTZ NOT NULL,
|
last_observed_at TIMESTAMPTZ NOT NULL,
|
||||||
last_event_id UUID NOT NULL,
|
last_event_id UUID NOT NULL,
|
||||||
incident_key TEXT NOT NULL,
|
incident_key TEXT NOT NULL,
|
||||||
|
summary TEXT,
|
||||||
PRIMARY KEY (agent_id, check_id)
|
PRIMARY KEY (agent_id, check_id)
|
||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ async def list_checks(
|
|||||||
last_observed_at=to_utc(r.last_observed_at),
|
last_observed_at=to_utc(r.last_observed_at),
|
||||||
exit_code=r.exit_code,
|
exit_code=r.exit_code,
|
||||||
incident_key=r.incident_key,
|
incident_key=r.incident_key,
|
||||||
|
summary=r.summary,
|
||||||
)
|
)
|
||||||
for r in rows
|
for r in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class Check(Base):
|
|||||||
last_observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
last_observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
last_event_id: Mapped[UUID] = mapped_column(_uuid(), nullable=False)
|
last_event_id: Mapped[UUID] = mapped_column(_uuid(), nullable=False)
|
||||||
incident_key: Mapped[str] = mapped_column(Text, nullable=False)
|
incident_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
PrimaryKeyConstraint("agent_id", "check_id", name="checks_pkey"),
|
PrimaryKeyConstraint("agent_id", "check_id", name="checks_pkey"),
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ class CheckState(BaseModel):
|
|||||||
last_observed_at: datetime
|
last_observed_at: datetime
|
||||||
exit_code: int
|
exit_code: int
|
||||||
incident_key: str
|
incident_key: str
|
||||||
|
summary: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Incident(BaseModel):
|
class Incident(BaseModel):
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select, update
|
from sqlalchemy import delete, func, select, text
|
||||||
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
|
||||||
from .. import metrics
|
from .. import metrics
|
||||||
@@ -12,6 +14,138 @@ from ..settings import get_settings
|
|||||||
log = get_logger("monlet.detector")
|
log = get_logger("monlet.detector")
|
||||||
|
|
||||||
OUTBOX_TERMINAL_STATES = ("sent", "failed", "discarded")
|
OUTBOX_TERMINAL_STATES = ("sent", "failed", "discarded")
|
||||||
|
LIVENESS_CHECK_ID = "agent_liveness"
|
||||||
|
|
||||||
|
|
||||||
|
def _liveness_key(agent_id: str) -> str:
|
||||||
|
return f"agent:{agent_id}:liveness"
|
||||||
|
|
||||||
|
|
||||||
|
def _liveness_severity(status: str) -> str:
|
||||||
|
return "critical" if status == "dead" else "warning"
|
||||||
|
|
||||||
|
|
||||||
|
async def _enqueue_liveness_outbox(
|
||||||
|
session,
|
||||||
|
incident: Incident,
|
||||||
|
event_type: str,
|
||||||
|
agent: Agent,
|
||||||
|
status: str,
|
||||||
|
now: datetime,
|
||||||
|
summary: str,
|
||||||
|
severity: str,
|
||||||
|
) -> None:
|
||||||
|
notifiers = get_settings().enabled_notifiers
|
||||||
|
if not notifiers:
|
||||||
|
return
|
||||||
|
for name in notifiers:
|
||||||
|
session.add(
|
||||||
|
NotificationOutbox(
|
||||||
|
id=uuid4(),
|
||||||
|
incident_id=incident.id,
|
||||||
|
notifier=name,
|
||||||
|
event_type=event_type,
|
||||||
|
state="pending",
|
||||||
|
attempts=0,
|
||||||
|
next_attempt_at=now,
|
||||||
|
payload={
|
||||||
|
"incident_id": str(incident.id),
|
||||||
|
"agent_id": agent.agent_id,
|
||||||
|
"check_id": LIVENESS_CHECK_ID,
|
||||||
|
"status": status,
|
||||||
|
"severity": severity,
|
||||||
|
"incident_key": incident.incident_key,
|
||||||
|
"observed_at": now.isoformat(),
|
||||||
|
"opened_at": incident.opened_at.isoformat() if incident.opened_at else None,
|
||||||
|
"resolved_at": incident.resolved_at.isoformat()
|
||||||
|
if incident.resolved_at
|
||||||
|
else None,
|
||||||
|
"summary": summary,
|
||||||
|
"output": summary,
|
||||||
|
"event_type": event_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _open_liveness_incident(session, agent: Agent, status: str, now: datetime) -> None:
|
||||||
|
severity = _liveness_severity(status)
|
||||||
|
summary = f"agent is {status}"
|
||||||
|
incident_key = _liveness_key(agent.agent_id)
|
||||||
|
new_id = uuid4()
|
||||||
|
stmt = (
|
||||||
|
pg_insert(Incident)
|
||||||
|
.values(
|
||||||
|
id=new_id,
|
||||||
|
incident_key=incident_key,
|
||||||
|
agent_id=agent.agent_id,
|
||||||
|
check_id=LIVENESS_CHECK_ID,
|
||||||
|
state="open",
|
||||||
|
severity=severity,
|
||||||
|
opened_at=now,
|
||||||
|
summary=summary,
|
||||||
|
last_event_id=uuid4(),
|
||||||
|
)
|
||||||
|
.on_conflict_do_nothing(
|
||||||
|
index_elements=[Incident.incident_key],
|
||||||
|
index_where=text("state = 'open'"),
|
||||||
|
)
|
||||||
|
.returning(Incident.id)
|
||||||
|
)
|
||||||
|
inserted_id = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if inserted_id is not None:
|
||||||
|
await session.flush()
|
||||||
|
inc = (
|
||||||
|
await session.execute(select(Incident).where(Incident.id == inserted_id))
|
||||||
|
).scalar_one()
|
||||||
|
metrics.incidents_opened_total.inc()
|
||||||
|
await _enqueue_liveness_outbox(
|
||||||
|
session, inc, "firing", agent, status, now, summary=summary, severity=severity
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
inc = (
|
||||||
|
await session.execute(
|
||||||
|
select(Incident).where(
|
||||||
|
Incident.incident_key == incident_key,
|
||||||
|
Incident.state == "open",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
if status == "dead" and inc.severity != "critical":
|
||||||
|
inc.severity = "critical"
|
||||||
|
inc.summary = summary
|
||||||
|
inc.last_event_id = uuid4()
|
||||||
|
await _enqueue_liveness_outbox(
|
||||||
|
session, inc, "firing", agent, status, now, summary=summary, severity="critical"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_liveness_incident(session, agent: Agent, now: datetime) -> None:
|
||||||
|
inc = (
|
||||||
|
await session.execute(
|
||||||
|
select(Incident).where(
|
||||||
|
Incident.incident_key == _liveness_key(agent.agent_id),
|
||||||
|
Incident.state == "open",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if inc is None:
|
||||||
|
return
|
||||||
|
inc.state = "resolved"
|
||||||
|
inc.resolved_at = now
|
||||||
|
inc.last_event_id = uuid4()
|
||||||
|
metrics.incidents_resolved_total.inc()
|
||||||
|
await _enqueue_liveness_outbox(
|
||||||
|
session,
|
||||||
|
inc,
|
||||||
|
"resolved",
|
||||||
|
agent,
|
||||||
|
"ok",
|
||||||
|
now,
|
||||||
|
summary="agent is alive",
|
||||||
|
severity="ok",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _prune_history(session) -> None:
|
async def _prune_history(session) -> None:
|
||||||
@@ -44,25 +178,20 @@ async def _tick(sm: async_sessionmaker) -> None:
|
|||||||
stale_cut = now - timedelta(seconds=s.stale_after_sec)
|
stale_cut = now - timedelta(seconds=s.stale_after_sec)
|
||||||
dead_cut = now - timedelta(seconds=s.dead_after_sec)
|
dead_cut = now - timedelta(seconds=s.dead_after_sec)
|
||||||
async with sm() as session:
|
async with sm() as session:
|
||||||
await session.execute(
|
agents = (await session.execute(select(Agent))).scalars().all()
|
||||||
update(Agent)
|
for agent in agents:
|
||||||
.where(Agent.last_seen_at <= dead_cut)
|
if agent.last_seen_at <= dead_cut:
|
||||||
.where(Agent.status != "dead")
|
next_status = "dead"
|
||||||
.values(status="dead")
|
elif agent.last_seen_at <= stale_cut:
|
||||||
)
|
next_status = "stale"
|
||||||
await session.execute(
|
else:
|
||||||
update(Agent)
|
next_status = "alive"
|
||||||
.where(Agent.last_seen_at <= stale_cut)
|
if agent.status != next_status:
|
||||||
.where(Agent.last_seen_at > dead_cut)
|
agent.status = next_status
|
||||||
.where(Agent.status != "stale")
|
if next_status in ("stale", "dead"):
|
||||||
.values(status="stale")
|
await _open_liveness_incident(session, agent, next_status, now)
|
||||||
)
|
else:
|
||||||
await session.execute(
|
await _resolve_liveness_incident(session, agent, now)
|
||||||
update(Agent)
|
|
||||||
.where(Agent.last_seen_at > stale_cut)
|
|
||||||
.where(Agent.status != "alive")
|
|
||||||
.values(status="alive")
|
|
||||||
)
|
|
||||||
await _prune_history(session)
|
await _prune_history(session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -155,11 +155,24 @@ async def ingest_event(
|
|||||||
agent_id: str,
|
agent_id: str,
|
||||||
ev: CheckResultEvent,
|
ev: CheckResultEvent,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Return True if accepted, False if deduplicated."""
|
"""Return True if a new change row was written, False if not (duplicate/unchanged/late)."""
|
||||||
observed = to_utc(ev.observed_at)
|
observed = to_utc(ev.observed_at)
|
||||||
output = redact(ev.output)
|
output = redact(ev.output)
|
||||||
|
check_summary = (output or "")[:200] if output else None
|
||||||
incident_key = _incident_key(agent_id, ev)
|
incident_key = _incident_key(agent_id, ev)
|
||||||
|
|
||||||
|
chk_res = await session.execute(
|
||||||
|
select(Check).where(Check.agent_id == agent_id, Check.check_id == ev.check_id)
|
||||||
|
)
|
||||||
|
cur = chk_res.scalar_one_or_none()
|
||||||
|
is_late = cur is not None and cur.last_observed_at > observed
|
||||||
|
changed = cur is None or cur.status != ev.status or cur.exit_code != ev.exit_code
|
||||||
|
|
||||||
|
if is_late:
|
||||||
|
metrics.events_total.labels(result="late").inc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
if changed:
|
||||||
stmt = pg_insert(Event).values(
|
stmt = pg_insert(Event).values(
|
||||||
event_id=UUID(ev.event_id),
|
event_id=UUID(ev.event_id),
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
@@ -173,41 +186,48 @@ async def ingest_event(
|
|||||||
notifications_enabled=ev.notifications_enabled,
|
notifications_enabled=ev.notifications_enabled,
|
||||||
incident_key=incident_key,
|
incident_key=incident_key,
|
||||||
)
|
)
|
||||||
stmt = stmt.on_conflict_do_nothing(index_elements=[Event.event_id]).returning(Event.event_id)
|
stmt = stmt.on_conflict_do_nothing(index_elements=[Event.event_id]).returning(
|
||||||
|
Event.event_id
|
||||||
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
inserted = result.scalar_one_or_none()
|
inserted = result.scalar_one_or_none()
|
||||||
if inserted is None:
|
if inserted is None:
|
||||||
metrics.events_total.labels(result="deduplicated").inc()
|
metrics.events_total.labels(result="deduplicated").inc()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
metrics.events_total.labels(result="accepted").inc()
|
metrics.events_total.labels(result="accepted").inc()
|
||||||
|
|
||||||
chk_res = await session.execute(
|
|
||||||
select(Check).where(Check.agent_id == agent_id, Check.check_id == ev.check_id)
|
|
||||||
)
|
|
||||||
cur = chk_res.scalar_one_or_none()
|
|
||||||
is_late = cur is not None and cur.last_observed_at > observed
|
|
||||||
if is_late:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if cur is None:
|
|
||||||
session.add(
|
|
||||||
Check(
|
|
||||||
agent_id=agent_id,
|
|
||||||
check_id=ev.check_id,
|
|
||||||
status=ev.status,
|
|
||||||
exit_code=ev.exit_code,
|
|
||||||
last_observed_at=observed,
|
|
||||||
last_event_id=UUID(ev.event_id),
|
|
||||||
incident_key=incident_key,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
cur.status = ev.status
|
metrics.events_total.labels(result="unchanged").inc()
|
||||||
cur.exit_code = ev.exit_code
|
|
||||||
cur.last_observed_at = observed
|
# Upsert Check to be safe under concurrent first-write race for the same (agent_id, check_id).
|
||||||
cur.last_event_id = UUID(ev.event_id)
|
# When changed=False keep the existing last_event_id and incident_key — incident_key changes
|
||||||
cur.incident_key = incident_key
|
# are only meaningful at transitions and would otherwise desync from any open Incident.
|
||||||
|
check_values = {
|
||||||
|
"agent_id": agent_id,
|
||||||
|
"check_id": ev.check_id,
|
||||||
|
"status": ev.status,
|
||||||
|
"exit_code": ev.exit_code,
|
||||||
|
"last_observed_at": observed,
|
||||||
|
"last_event_id": UUID(ev.event_id),
|
||||||
|
"incident_key": incident_key,
|
||||||
|
"summary": check_summary,
|
||||||
|
}
|
||||||
|
update_set = {
|
||||||
|
"status": ev.status,
|
||||||
|
"exit_code": ev.exit_code,
|
||||||
|
"last_observed_at": observed,
|
||||||
|
"summary": check_summary,
|
||||||
|
}
|
||||||
|
if changed:
|
||||||
|
update_set["last_event_id"] = UUID(ev.event_id)
|
||||||
|
update_set["incident_key"] = incident_key
|
||||||
|
chk_stmt = pg_insert(Check).values(**check_values)
|
||||||
|
chk_stmt = chk_stmt.on_conflict_do_update(
|
||||||
|
index_elements=[Check.agent_id, Check.check_id], set_=update_set
|
||||||
|
)
|
||||||
|
await session.execute(chk_stmt)
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
return False
|
||||||
|
|
||||||
transition, inc = await _apply_incident(session, agent_id, ev, incident_key)
|
transition, inc = await _apply_incident(session, agent_id, ev, incident_key)
|
||||||
if transition and inc is not None and ev.notifications_enabled:
|
if transition and inc is not None and ev.notifications_enabled:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class Settings(BaseSettings):
|
|||||||
port: int = 8000
|
port: int = 8000
|
||||||
enable_detector: bool = True
|
enable_detector: bool = True
|
||||||
enable_notifier_worker: bool = True
|
enable_notifier_worker: bool = True
|
||||||
events_retention_max_rows: int = 100_000
|
events_retention_max_rows: int = 20_000
|
||||||
outbox_retention_max_rows: int = 50_000
|
outbox_retention_max_rows: int = 50_000
|
||||||
notifier_tick_sec: int = 5
|
notifier_tick_sec: int = 5
|
||||||
notifier_batch_size: int = 20
|
notifier_batch_size: int = 20
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from sqlalchemy import func, select, update
|
|||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
|
||||||
from monlet_server.models import Agent, Event, Incident, NotificationOutbox
|
from monlet_server.models import Agent, Event, Incident, NotificationOutbox
|
||||||
from monlet_server.services.detector import _tick
|
from monlet_server.services.detector import LIVENESS_CHECK_ID, _tick
|
||||||
from monlet_server.settings import reset_settings_cache
|
from monlet_server.settings import reset_settings_cache
|
||||||
|
|
||||||
from ._helpers import make_event, make_heartbeat
|
from ._helpers import make_event, make_heartbeat
|
||||||
@@ -39,6 +39,7 @@ async def test_detector_transitions(app_client, auth_headers, engine, session):
|
|||||||
|
|
||||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
await _tick(sm)
|
await _tick(sm)
|
||||||
|
session.expire_all()
|
||||||
|
|
||||||
statuses = {
|
statuses = {
|
||||||
r.agent_id: r.status for r in (await session.execute(select(Agent))).scalars().all()
|
r.agent_id: r.status for r in (await session.execute(select(Agent))).scalars().all()
|
||||||
@@ -47,6 +48,38 @@ async def test_detector_transitions(app_client, auth_headers, engine, session):
|
|||||||
assert statuses["agent-stale"] == "stale"
|
assert statuses["agent-stale"] == "stale"
|
||||||
assert statuses["agent-dead"] == "dead"
|
assert statuses["agent-dead"] == "dead"
|
||||||
|
|
||||||
|
incidents = (
|
||||||
|
(await session.execute(select(Incident).where(Incident.check_id == LIVENESS_CHECK_ID)))
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_agent = {i.agent_id: i for i in incidents}
|
||||||
|
assert "agent-alive" not in by_agent
|
||||||
|
assert by_agent["agent-stale"].state == "open"
|
||||||
|
assert by_agent["agent-stale"].severity == "warning"
|
||||||
|
assert by_agent["agent-stale"].summary == "agent is stale"
|
||||||
|
assert by_agent["agent-dead"].state == "open"
|
||||||
|
assert by_agent["agent-dead"].severity == "critical"
|
||||||
|
assert by_agent["agent-dead"].summary == "agent is dead"
|
||||||
|
|
||||||
|
await session.execute(
|
||||||
|
update(Agent).where(Agent.agent_id == "agent-dead").values(last_seen_at=datetime.now(UTC))
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await _tick(sm)
|
||||||
|
session.expire_all()
|
||||||
|
|
||||||
|
resolved = (
|
||||||
|
await session.execute(
|
||||||
|
select(Incident).where(
|
||||||
|
Incident.agent_id == "agent-dead",
|
||||||
|
Incident.check_id == LIVENESS_CHECK_ID,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
assert resolved.state == "resolved"
|
||||||
|
assert resolved.resolved_at is not None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_detector_prunes_bounded_history(
|
async def test_detector_prunes_bounded_history(
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from ._helpers import make_event, make_heartbeat
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_event_accepted_and_state(app_client, auth_headers, session):
|
async def test_event_accepted_and_state(app_client, auth_headers, session):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
||||||
ev = make_event(status="ok")
|
ev = make_event(status="ok", output="disk ok")
|
||||||
r = await app_client.post(
|
r = await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||||
)
|
)
|
||||||
@@ -19,6 +19,7 @@ async def test_event_accepted_and_state(app_client, auth_headers, session):
|
|||||||
assert n == 1
|
assert n == 1
|
||||||
chk = (await session.execute(select(Check))).scalar_one()
|
chk = (await session.execute(select(Check))).scalar_one()
|
||||||
assert chk.status == "ok"
|
assert chk.status == "ok"
|
||||||
|
assert chk.summary == ev["output"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -64,6 +65,53 @@ async def test_oversize_batch_400(app_client, auth_headers):
|
|||||||
assert r.status_code == 400
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repeated_same_status_not_stored(app_client, auth_headers, session):
|
||||||
|
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
||||||
|
evs = [make_event(status="ok") for _ in range(5)]
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers
|
||||||
|
)
|
||||||
|
assert r.status_code == 202
|
||||||
|
assert r.json() == {"accepted": 1, "deduplicated": 4}
|
||||||
|
n = (await session.execute(select(func.count()).select_from(Event))).scalar_one()
|
||||||
|
assert n == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_transitions_stored(app_client, auth_headers, session):
|
||||||
|
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
||||||
|
seq = [
|
||||||
|
make_event(status="ok"),
|
||||||
|
make_event(status="warning", exit_code=1),
|
||||||
|
make_event(status="warning", exit_code=1),
|
||||||
|
make_event(status="critical", exit_code=2),
|
||||||
|
make_event(status="ok"),
|
||||||
|
]
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/events", json={"agent_id": "agent-1", "events": seq}, headers=auth_headers
|
||||||
|
)
|
||||||
|
assert r.status_code == 202
|
||||||
|
assert r.json() == {"accepted": 4, "deduplicated": 1}
|
||||||
|
n = (await session.execute(select(func.count()).select_from(Event))).scalar_one()
|
||||||
|
assert n == 4
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_same_status_different_exit_code_stored(app_client, auth_headers, session):
|
||||||
|
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
||||||
|
seq = [
|
||||||
|
make_event(status="warning", exit_code=1),
|
||||||
|
make_event(status="warning", exit_code=3),
|
||||||
|
]
|
||||||
|
r = await app_client.post(
|
||||||
|
"/api/v1/events", json={"agent_id": "agent-1", "events": seq}, headers=auth_headers
|
||||||
|
)
|
||||||
|
assert r.json() == {"accepted": 2, "deduplicated": 0}
|
||||||
|
n = (await session.execute(select(func.count()).select_from(Event))).scalar_one()
|
||||||
|
assert n == 2
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_late_event_does_not_move_state(app_client, auth_headers, session):
|
async def test_late_event_does_not_move_state(app_client, auth_headers, session):
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
@@ -71,7 +119,9 @@ async def test_late_event_does_not_move_state(app_client, auth_headers, session)
|
|||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
||||||
newer = datetime.now(UTC).replace(microsecond=0)
|
newer = datetime.now(UTC).replace(microsecond=0)
|
||||||
older = (newer - timedelta(minutes=5)).isoformat()
|
older = (newer - timedelta(minutes=5)).isoformat()
|
||||||
ev_now = make_event(status="critical", exit_code=2, observed_at=newer.isoformat())
|
ev_now = make_event(
|
||||||
|
status="critical", exit_code=2, observed_at=newer.isoformat(), output="current critical"
|
||||||
|
)
|
||||||
ev_old = make_event(status="ok", exit_code=0, observed_at=older)
|
ev_old = make_event(status="ok", exit_code=0, observed_at=older)
|
||||||
await app_client.post(
|
await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev_now]}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev_now]}, headers=auth_headers
|
||||||
@@ -81,5 +131,6 @@ async def test_late_event_does_not_move_state(app_client, auth_headers, session)
|
|||||||
)
|
)
|
||||||
chk = (await session.execute(select(Check))).scalar_one()
|
chk = (await session.execute(select(Check))).scalar_one()
|
||||||
assert chk.status == "critical"
|
assert chk.status == "critical"
|
||||||
|
assert chk.summary == ev_now["output"]
|
||||||
n = (await session.execute(select(func.count()).select_from(Event))).scalar_one()
|
n = (await session.execute(select(func.count()).select_from(Event))).scalar_one()
|
||||||
assert n == 2
|
assert n == 1
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ async def test_incident_key_too_long(app_client, auth_headers):
|
|||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_events_query_cursor_pagination(app_client, auth_headers):
|
async def test_events_query_cursor_pagination(app_client, auth_headers):
|
||||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
||||||
evs = [make_event() for _ in range(5)]
|
evs = [make_event(check_id=f"chk-{i}") for i in range(5)]
|
||||||
await app_client.post(
|
await app_client.post(
|
||||||
"/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers
|
"/api/v1/events", json={"agent_id": "agent-1", "events": evs}, headers=auth_headers
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,55 +1,14 @@
|
|||||||
import Link from "next/link";
|
import { AgentDetailBrowser } from "@/components/AgentDetailBrowser";
|
||||||
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
import { ApiError, api } from "@/lib/api";
|
||||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
import { withLivenessEvents } from "@/lib/check-groups";
|
||||||
import { ErrorPanel, Td, Th } from "@/components/DataPanel";
|
import { loadChecks } from "@/lib/loaders";
|
||||||
import { LabelChips } from "@/components/Labels";
|
|
||||||
import { DateTime } from "@/components/TimeZoneControls";
|
|
||||||
import { ApiError, type Agent, type CheckState, type StoredEvent, api } from "@/lib/api";
|
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type SortKey = "severity" | "check_id" | "last_seen";
|
type SortKey = "severity" | "check_id" | "last_seen";
|
||||||
type TabKey = "checks" | "events";
|
type TabKey = "checks" | "events";
|
||||||
|
|
||||||
type LoadResult =
|
|
||||||
| { kind: "ok"; agent: Agent; checks: CheckState[]; events: StoredEvent[] }
|
|
||||||
| { kind: "not_found" }
|
|
||||||
| { kind: "error"; error: unknown };
|
|
||||||
|
|
||||||
const severityRank: Record<CheckState["status"], number> = {
|
|
||||||
critical: 0,
|
|
||||||
warning: 1,
|
|
||||||
unknown: 2,
|
|
||||||
ok: 3,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function load(agentId: string, sort: SortKey, tab: TabKey): Promise<LoadResult> {
|
|
||||||
try {
|
|
||||||
const [agent, checks, events] = await Promise.all([
|
|
||||||
api.getAgent(agentId),
|
|
||||||
tab === "checks" ? loadChecks(agentId).then((items) => sortChecks(items, sort)) : [],
|
|
||||||
tab === "events" ? api.queryEvents({ agent_id: agentId, limit: 50 }).then((page) => page.items) : [],
|
|
||||||
]);
|
|
||||||
return { kind: "ok", agent, checks, events };
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ApiError && e.status === 404) return { kind: "not_found" };
|
|
||||||
return { kind: "error", error: e };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadChecks(agentId: string): Promise<CheckState[]> {
|
|
||||||
const checks: CheckState[] = [];
|
|
||||||
let cursor: string | undefined;
|
|
||||||
do {
|
|
||||||
const page = await api.listChecks({ agent_id: agentId, cursor, limit: 500 });
|
|
||||||
checks.push(...page.items);
|
|
||||||
cursor = page.next_cursor ?? undefined;
|
|
||||||
} while (cursor);
|
|
||||||
return checks;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSort(sort?: string): SortKey {
|
function normalizeSort(sort?: string): SortKey {
|
||||||
return sort === "check_id" || sort === "last_seen" ? sort : "severity";
|
return sort === "check_id" || sort === "last_seen" ? sort : "severity";
|
||||||
}
|
}
|
||||||
@@ -58,14 +17,6 @@ function normalizeTab(tab?: string): TabKey {
|
|||||||
return tab === "events" ? "events" : "checks";
|
return tab === "events" ? "events" : "checks";
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortChecks(checks: CheckState[], sort: SortKey): CheckState[] {
|
|
||||||
return [...checks].sort((a, b) => {
|
|
||||||
if (sort === "check_id") return a.check_id.localeCompare(b.check_id);
|
|
||||||
if (sort === "last_seen") return b.last_observed_at.localeCompare(a.last_observed_at);
|
|
||||||
return severityRank[a.status] - severityRank[b.status] || a.check_id.localeCompare(b.check_id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AgentDetailPage({
|
export default async function AgentDetailPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
@@ -77,163 +28,27 @@ export default async function AgentDetailPage({
|
|||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const sort = normalizeSort(sp.sort);
|
const sort = normalizeSort(sp.sort);
|
||||||
const tab = normalizeTab(sp.tab);
|
const tab = normalizeTab(sp.tab);
|
||||||
const r = await load(agent_id, sort, tab);
|
|
||||||
if (r.kind === "not_found") return <div className="text-sm text-neutral-400">Agent not found.</div>;
|
|
||||||
if (r.kind === "error") return <ErrorPanel error={r.error} />;
|
|
||||||
const { agent, checks, events } = r;
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<Link href="/agents" className="text-blue-300 hover:text-blue-200 text-sm">
|
|
||||||
← agents
|
|
||||||
</Link>
|
|
||||||
<h1 className="mt-2 text-lg font-semibold">{agent.agent_id}</h1>
|
|
||||||
</div>
|
|
||||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
|
||||||
<dt className="text-neutral-400">hostname</dt>
|
|
||||||
<dd>{agent.hostname}</dd>
|
|
||||||
<dt className="text-neutral-400">status</dt>
|
|
||||||
<dd className={statusBadgeClass(agent.status)}>{agent.status}</dd>
|
|
||||||
<dt className="text-neutral-400">last_seen_at</dt>
|
|
||||||
<dd>
|
|
||||||
<DateTime iso={agent.last_seen_at} />
|
|
||||||
</dd>
|
|
||||||
<dt className="text-neutral-400">features</dt>
|
|
||||||
<dd>
|
|
||||||
<AgentFeatures features={agent.features} />
|
|
||||||
</dd>
|
|
||||||
<dt className="text-neutral-400">labels</dt>
|
|
||||||
<dd>
|
|
||||||
<LabelChips labels={agent.labels} />
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
<TabNav agentId={agent.agent_id} current={tab} sort={sort} />
|
|
||||||
{tab === "checks" ? <ChecksTable agentId={agent.agent_id} checks={checks} sort={sort} /> : <EventsTable events={events} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabNav({ agentId, current, sort }: { agentId: string; current: TabKey; sort: SortKey }) {
|
let data;
|
||||||
const base = `/agents/${encodeURIComponent(agentId)}`;
|
try {
|
||||||
|
const [agent, checks, events] = await Promise.all([
|
||||||
|
api.getAgent(agent_id),
|
||||||
|
loadChecks(agent_id),
|
||||||
|
api.queryEvents({ agent_id, limit: 50 }).then((page) => page.items),
|
||||||
|
]);
|
||||||
|
data = { agent, checks, events: withLivenessEvents([agent], events, { agentId: agent_id }) };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
|
return <div className="text-sm text-neutral-400">Agent not found.</div>;
|
||||||
|
}
|
||||||
|
return <ErrorPanel error={e} />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 border-b border-neutral-800 text-sm">
|
<AgentDetailBrowser
|
||||||
<Link
|
key={`${agent_id}:${tab}:${sort}`}
|
||||||
href={`${base}?tab=checks&sort=${sort}`}
|
initialData={data}
|
||||||
className={`pb-2 ${current === "checks" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
|
sort={sort}
|
||||||
>
|
tab={tab}
|
||||||
Checks
|
/>
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href={`${base}?tab=events&sort=${sort}`}
|
|
||||||
className={`pb-2 ${current === "events" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
|
|
||||||
>
|
|
||||||
Events
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChecksTable({
|
|
||||||
agentId,
|
|
||||||
checks,
|
|
||||||
sort,
|
|
||||||
}: {
|
|
||||||
agentId: string;
|
|
||||||
checks: CheckState[];
|
|
||||||
sort: SortKey;
|
|
||||||
}) {
|
|
||||||
if (checks.length === 0) return <div className="text-sm text-neutral-500">No checks.</div>;
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<Th>
|
|
||||||
<SortLink agentId={agentId} sort="check_id" current={sort}>
|
|
||||||
check_id
|
|
||||||
</SortLink>
|
|
||||||
</Th>
|
|
||||||
<Th>
|
|
||||||
<SortLink agentId={agentId} sort="severity" current={sort}>
|
|
||||||
status
|
|
||||||
</SortLink>
|
|
||||||
</Th>
|
|
||||||
<Th>exit</Th>
|
|
||||||
<Th>
|
|
||||||
<SortLink agentId={agentId} sort="last_seen" current={sort}>
|
|
||||||
last_observed_at
|
|
||||||
</SortLink>
|
|
||||||
</Th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{checks.map((c) => (
|
|
||||||
<tr key={c.check_id}>
|
|
||||||
<Td>
|
|
||||||
<CheckEventsLink agentId={agentId} checkId={c.check_id} />
|
|
||||||
</Td>
|
|
||||||
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
|
|
||||||
<Td>{c.exit_code ?? "—"}</Td>
|
|
||||||
<Td>
|
|
||||||
<DateTime iso={c.last_observed_at} />
|
|
||||||
</Td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventsTable({ events }: { events: StoredEvent[] }) {
|
|
||||||
if (events.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
|
|
||||||
return (
|
|
||||||
<section>
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<Th>observed_at</Th>
|
|
||||||
<Th>check_id</Th>
|
|
||||||
<Th>status</Th>
|
|
||||||
<Th>duration_ms</Th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{events.map((e) => (
|
|
||||||
<tr key={e.event_id}>
|
|
||||||
<Td>
|
|
||||||
<DateTime iso={e.observed_at} />
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
|
|
||||||
</Td>
|
|
||||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
|
||||||
<Td>{e.duration_ms}</Td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortLink({
|
|
||||||
agentId,
|
|
||||||
sort,
|
|
||||||
current,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
agentId: string;
|
|
||||||
sort: SortKey;
|
|
||||||
current: SortKey;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={`/agents/${encodeURIComponent(agentId)}?tab=checks&sort=${sort}`}
|
|
||||||
className={sort === current ? "text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,51 +2,10 @@ import { redirect } from "next/navigation";
|
|||||||
|
|
||||||
import { AgentsBrowser } from "@/components/AgentsBrowser";
|
import { AgentsBrowser } from "@/components/AgentsBrowser";
|
||||||
import { ErrorPanel } from "@/components/DataPanel";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { api, type Agent, type CheckState } from "@/lib/api";
|
import { loadInventory } from "@/lib/loaders";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type CheckStatus = CheckState["status"];
|
|
||||||
type CheckCounts = Record<CheckStatus, number>;
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const [agents, checksByAgent] = await Promise.all([
|
|
||||||
loadAgents(),
|
|
||||||
loadCheckCounts(),
|
|
||||||
]);
|
|
||||||
return { agents, checksByAgent };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAgents(): Promise<Agent[]> {
|
|
||||||
const agents: Agent[] = [];
|
|
||||||
let cursor: string | undefined;
|
|
||||||
do {
|
|
||||||
const page = await api.listAgents({ cursor, limit: 500 });
|
|
||||||
agents.push(...page.items);
|
|
||||||
cursor = page.next_cursor ?? undefined;
|
|
||||||
} while (cursor);
|
|
||||||
return agents;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadCheckCounts(): Promise<Map<string, CheckCounts>> {
|
|
||||||
const byAgent = new Map<string, CheckCounts>();
|
|
||||||
let cursor: string | undefined;
|
|
||||||
do {
|
|
||||||
const page = await api.listChecks({ cursor, limit: 500 });
|
|
||||||
for (const c of page.items) {
|
|
||||||
const counts = byAgent.get(c.agent_id) ?? emptyCheckCounts();
|
|
||||||
counts[c.status] += 1;
|
|
||||||
byAgent.set(c.agent_id, counts);
|
|
||||||
}
|
|
||||||
cursor = page.next_cursor ?? undefined;
|
|
||||||
} while (cursor);
|
|
||||||
return byAgent;
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyCheckCounts(): CheckCounts {
|
|
||||||
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function AgentsPage({
|
export default async function AgentsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -55,17 +14,11 @@ export default async function AgentsPage({
|
|||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
if (Object.keys(sp).length > 0) redirect("/agents");
|
if (Object.keys(sp).length > 0) redirect("/agents");
|
||||||
|
|
||||||
let data: Awaited<ReturnType<typeof load>>;
|
let data;
|
||||||
try {
|
try {
|
||||||
data = await load();
|
data = await loadInventory();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return <ErrorPanel error={e} />;
|
return <ErrorPanel error={e} />;
|
||||||
}
|
}
|
||||||
|
return <AgentsBrowser initialData={data} />;
|
||||||
const rows = data.agents.map((agent) => ({
|
|
||||||
agent,
|
|
||||||
checks: data.checksByAgent.get(agent.agent_id) ?? emptyCheckCounts(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
return <AgentsBrowser rows={rows} />;
|
|
||||||
}
|
}
|
||||||
|
|||||||
41
web/src/app/api/poll/agent/route.ts
Normal file
41
web/src/app/api/poll/agent/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
web/src/app/api/poll/events/route.ts
Normal file
23
web/src/app/api/poll/events/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
web/src/app/api/poll/incidents/route.ts
Normal file
19
web/src/app/api/poll/incidents/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
web/src/app/api/poll/inventory/route.ts
Normal file
14
web/src/app/api/poll/inventory/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
web/src/app/api/poll/outbox/route.ts
Normal file
19
web/src/app/api/poll/outbox/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
web/src/app/api/poll/overview/route.ts
Normal file
14
web/src/app/api/poll/overview/route.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
web/src/app/checks/[check_id]/page.tsx
Normal file
51
web/src/app/checks/[check_id]/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,69 +1,15 @@
|
|||||||
import Link from "next/link";
|
import { ChecksBrowser } from "@/components/ChecksBrowser";
|
||||||
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
import { loadInventory } from "@/lib/loaders";
|
||||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
|
||||||
import { Pager } from "@/components/Pager";
|
|
||||||
import { DateTime } from "@/components/TimeZoneControls";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ChecksPage({
|
export default async function ChecksPage() {
|
||||||
searchParams,
|
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ cursor?: string }>;
|
|
||||||
}) {
|
|
||||||
const sp = await searchParams;
|
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
data = await api.listChecks({ cursor: sp.cursor });
|
data = await loadInventory();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return <ErrorPanel error={e} />;
|
return <ErrorPanel error={e} />;
|
||||||
}
|
}
|
||||||
return (
|
return <ChecksBrowser initialData={data} />;
|
||||||
<>
|
|
||||||
<PageHeader title="Checks" count={data.items.length} />
|
|
||||||
{data.items.length === 0 ? (
|
|
||||||
<EmptyPanel />
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<Th>agent_id</Th>
|
|
||||||
<Th>check_id</Th>
|
|
||||||
<Th>status</Th>
|
|
||||||
<Th>exit</Th>
|
|
||||||
<Th>last_observed_at</Th>
|
|
||||||
<Th>incident_key</Th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.items.map((c) => (
|
|
||||||
<tr key={`${c.agent_id}:${c.check_id}`}>
|
|
||||||
<Td>
|
|
||||||
<Link
|
|
||||||
href={`/agents/${encodeURIComponent(c.agent_id)}`}
|
|
||||||
className="text-blue-300 hover:text-blue-200"
|
|
||||||
>
|
|
||||||
{c.agent_id}
|
|
||||||
</Link>
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<CheckEventsLink agentId={c.agent_id} checkId={c.check_id} />
|
|
||||||
</Td>
|
|
||||||
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
|
|
||||||
<Td>{c.exit_code ?? "—"}</Td>
|
|
||||||
<Td>
|
|
||||||
<DateTime iso={c.last_observed_at} />
|
|
||||||
</Td>
|
|
||||||
<Td className="text-xs text-neutral-400">{c.incident_key ?? "—"}</Td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
<Pager basePath="/checks" cursor={sp.cursor} nextCursor={data.next_cursor} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import Link from "next/link";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
|
import { EventsBrowser } from "@/components/EventsBrowser";
|
||||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
import { loadEventsWithLiveness } from "@/lib/loaders";
|
||||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
|
||||||
import { Pager } from "@/components/Pager";
|
|
||||||
import { DateTime } from "@/components/TimeZoneControls";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -17,102 +12,21 @@ export default async function EventsPage({
|
|||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
data = await api.queryEvents({
|
data = await loadEventsWithLiveness({
|
||||||
agent_id: sp.agent_id,
|
agentId: sp.agent_id,
|
||||||
check_id: sp.check_id,
|
checkId: sp.check_id,
|
||||||
cursor: sp.cursor,
|
cursor: sp.cursor,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return <ErrorPanel error={e} />;
|
return <ErrorPanel error={e} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<EventsBrowser
|
||||||
<PageHeader title="Events" count={data.items.length} />
|
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}`}
|
||||||
<ActiveFilters agentId={sp.agent_id} checkId={sp.check_id} />
|
initialData={data}
|
||||||
{data.items.length === 0 ? (
|
|
||||||
<EmptyPanel />
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<Th>observed_at</Th>
|
|
||||||
<Th>received_at</Th>
|
|
||||||
<Th>agent_id</Th>
|
|
||||||
<Th>check_id</Th>
|
|
||||||
<Th>status</Th>
|
|
||||||
<Th>exit</Th>
|
|
||||||
<Th>duration_ms</Th>
|
|
||||||
<Th>output</Th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.items.map((e) => (
|
|
||||||
<tr key={e.event_id}>
|
|
||||||
<Td>
|
|
||||||
<DateTime iso={e.observed_at} />
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<DateTime iso={e.received_at} />
|
|
||||||
</Td>
|
|
||||||
<Td>{e.agent_id}</Td>
|
|
||||||
<Td>
|
|
||||||
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
|
|
||||||
</Td>
|
|
||||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
|
||||||
<Td>{e.exit_code}</Td>
|
|
||||||
<Td>{e.duration_ms}</Td>
|
|
||||||
<Td className="text-xs text-neutral-400 max-w-md truncate">{e.output ?? "—"}</Td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
<Pager
|
|
||||||
basePath="/events"
|
|
||||||
cursor={sp.cursor}
|
cursor={sp.cursor}
|
||||||
nextCursor={data.next_cursor}
|
agentId={sp.agent_id}
|
||||||
extra={{ agent_id: sp.agent_id, check_id: sp.check_id }}
|
checkId={sp.check_id}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ActiveFilters({ agentId, checkId }: { agentId?: string; checkId?: string }) {
|
|
||||||
if (!agentId && !checkId) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
|
|
||||||
{agentId ? (
|
|
||||||
<FilterChip label="agent_id" value={agentId} href={eventsHref({ check_id: checkId })} />
|
|
||||||
) : null}
|
|
||||||
{checkId ? (
|
|
||||||
<FilterChip label="check_id" value={checkId} href={eventsHref({ agent_id: agentId })} />
|
|
||||||
) : null}
|
|
||||||
<Link href="/events" className="text-neutral-500 hover:text-neutral-300">
|
|
||||||
clear
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FilterChip({ label, value, href }: { label: string; value: string; href: string }) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className="inline-flex max-w-xs items-center gap-1 rounded border border-neutral-800 bg-neutral-900 px-2 py-1 text-neutral-300 hover:border-neutral-600 hover:text-neutral-100"
|
|
||||||
title={`${label}=${value}`}
|
|
||||||
>
|
|
||||||
<span className="text-neutral-500">{label}=</span>
|
|
||||||
<span className="truncate">{value}</span>
|
|
||||||
<span className="text-neutral-600">x</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventsHref(params: { agent_id?: string; check_id?: string }): string {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
if (params.agent_id) qs.set("agent_id", params.agent_id);
|
|
||||||
if (params.check_id) qs.set("check_id", params.check_id);
|
|
||||||
const query = qs.toString();
|
|
||||||
return query ? `/events?${query}` : "/events";
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,89 +1,29 @@
|
|||||||
import Link from "next/link";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
|
import { IncidentsBrowser } from "@/components/IncidentsBrowser";
|
||||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
import { loadIncidents, loadInventory } from "@/lib/loaders";
|
||||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
|
||||||
import { Pager } from "@/components/Pager";
|
|
||||||
import { DateTime } from "@/components/TimeZoneControls";
|
|
||||||
import { api } from "@/lib/api";
|
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const STATES = ["all", "open", "resolved"] as const;
|
|
||||||
|
|
||||||
export default async function IncidentsPage({
|
export default async function IncidentsPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ cursor?: string; state?: string }>;
|
searchParams: Promise<{ state?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
|
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
|
||||||
let data;
|
let data;
|
||||||
|
let inventory;
|
||||||
try {
|
try {
|
||||||
data = await api.listIncidents({ state, cursor: sp.cursor });
|
[data, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return <ErrorPanel error={e} />;
|
return <ErrorPanel error={e} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<>
|
<IncidentsBrowser
|
||||||
<PageHeader title="Incidents" count={data.items.length} />
|
key={state ?? ""}
|
||||||
<div className="mb-3 flex gap-3 text-sm">
|
initialData={data}
|
||||||
{STATES.map((s) => {
|
initialInventory={inventory}
|
||||||
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`;
|
state={state}
|
||||||
const active = (s === "all" && !state) || s === state;
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={s}
|
|
||||||
href={href}
|
|
||||||
className={active ? "text-white underline" : "text-neutral-400 hover:text-white"}
|
|
||||||
>
|
|
||||||
{s}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{data.items.length === 0 ? (
|
|
||||||
<EmptyPanel />
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<Th>opened_at</Th>
|
|
||||||
<Th>state</Th>
|
|
||||||
<Th>severity</Th>
|
|
||||||
<Th>agent_id</Th>
|
|
||||||
<Th>check_id</Th>
|
|
||||||
<Th>resolved_at</Th>
|
|
||||||
<Th>summary</Th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.items.map((i) => (
|
|
||||||
<tr key={i.id}>
|
|
||||||
<Td>
|
|
||||||
<DateTime iso={i.opened_at} />
|
|
||||||
</Td>
|
|
||||||
<Td className={statusBadgeClass(i.state)}>{i.state}</Td>
|
|
||||||
<Td className={statusBadgeClass(i.severity)}>{i.severity}</Td>
|
|
||||||
<Td>{i.agent_id ?? "—"}</Td>
|
|
||||||
<Td>
|
|
||||||
<CheckEventsLink agentId={i.agent_id} checkId={i.check_id} />
|
|
||||||
</Td>
|
|
||||||
<Td>
|
|
||||||
<DateTime iso={i.resolved_at} />
|
|
||||||
</Td>
|
|
||||||
<Td className="text-neutral-400">{i.summary ?? "—"}</Td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
<Pager
|
|
||||||
basePath="/incidents"
|
|
||||||
cursor={sp.cursor}
|
|
||||||
nextCursor={data.next_cursor}
|
|
||||||
extra={{ state }}
|
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { AutoRefresh } from "@/components/AutoRefresh";
|
|
||||||
import { TimeZoneCarousel, TimeZoneProvider } from "@/components/TimeZoneControls";
|
import { TimeZoneCarousel, TimeZoneProvider } from "@/components/TimeZoneControls";
|
||||||
import { getUiTimeZone } from "@/lib/timezone";
|
import { getUiTimeZone } from "@/lib/timezone";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
@@ -19,16 +18,6 @@ const NAV = [
|
|||||||
{ href: "/outbox", label: "Outbox" },
|
{ href: "/outbox", label: "Outbox" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const DEFAULT_REFRESH_MS = 10_000;
|
|
||||||
|
|
||||||
function getRefreshIntervalMs(): number {
|
|
||||||
const raw = process.env.NEXT_PUBLIC_MONLET_REFRESH_MS?.trim();
|
|
||||||
if (!raw) return DEFAULT_REFRESH_MS;
|
|
||||||
|
|
||||||
const parsed = Number(raw);
|
|
||||||
return Number.isFinite(parsed) ? parsed : DEFAULT_REFRESH_MS;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Brand() {
|
function Brand() {
|
||||||
return (
|
return (
|
||||||
<Link href="/" aria-label="Monlet overview" className="group flex shrink-0 items-center gap-2">
|
<Link href="/" aria-label="Monlet overview" className="group flex shrink-0 items-center gap-2">
|
||||||
@@ -47,7 +36,6 @@ function Brand() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
const refreshIntervalMs = getRefreshIntervalMs();
|
|
||||||
const configuredTimeZone = getUiTimeZone();
|
const configuredTimeZone = getUiTimeZone();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,7 +54,6 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|||||||
<TimeZoneCarousel />
|
<TimeZoneCarousel />
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1 px-4 py-6">{children}</main>
|
<main className="flex-1 px-4 py-6">{children}</main>
|
||||||
{refreshIntervalMs > 0 && <AutoRefresh intervalMs={refreshIntervalMs} />}
|
|
||||||
</TimeZoneProvider>
|
</TimeZoneProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { Pager } from "@/components/Pager";
|
import { OutboxBrowser } from "@/components/OutboxBrowser";
|
||||||
import { DateTime } from "@/components/TimeZoneControls";
|
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -18,44 +16,5 @@ export default async function OutboxPage({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
return <ErrorPanel error={e} />;
|
return <ErrorPanel error={e} />;
|
||||||
}
|
}
|
||||||
return (
|
return <OutboxBrowser key={sp.cursor ?? ""} initialData={data} cursor={sp.cursor} />;
|
||||||
<>
|
|
||||||
<PageHeader title="Notification outbox" count={data.items.length} />
|
|
||||||
{data.items.length === 0 ? (
|
|
||||||
<EmptyPanel />
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<Th>updated_at</Th>
|
|
||||||
<Th>notifier</Th>
|
|
||||||
<Th>event</Th>
|
|
||||||
<Th>state</Th>
|
|
||||||
<Th>attempts</Th>
|
|
||||||
<Th>next_attempt_at</Th>
|
|
||||||
<Th>last_error</Th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.items.map((o) => (
|
|
||||||
<tr key={o.id}>
|
|
||||||
<Td>
|
|
||||||
<DateTime iso={o.updated_at} />
|
|
||||||
</Td>
|
|
||||||
<Td>{o.notifier}</Td>
|
|
||||||
<Td>{o.event_type}</Td>
|
|
||||||
<Td className={statusBadgeClass(o.state)}>{o.state}</Td>
|
|
||||||
<Td>{o.attempts}</Td>
|
|
||||||
<Td>
|
|
||||||
<DateTime iso={o.next_attempt_at} />
|
|
||||||
</Td>
|
|
||||||
<Td className="text-xs text-red-300">{o.last_error ?? "—"}</Td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
<Pager basePath="/outbox" cursor={sp.cursor} nextCursor={data.next_cursor} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +1,15 @@
|
|||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
import { ErrorPanel } from "@/components/DataPanel";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { api } from "@/lib/api";
|
import { OverviewDashboard } from "@/components/OverviewDashboard";
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
import { loadOverview } from "@/lib/loaders";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
async function load() {
|
|
||||||
const [agents, checks, incidents] = await Promise.all([
|
|
||||||
api.listAgents({ limit: 500 }),
|
|
||||||
api.listChecks({ limit: 500 }),
|
|
||||||
api.listIncidents({ state: "open", limit: 500 }),
|
|
||||||
]);
|
|
||||||
const tally = (xs: { status?: string }[]) =>
|
|
||||||
xs.reduce<Record<string, number>>((acc, x) => {
|
|
||||||
const k = x.status ?? "unknown";
|
|
||||||
acc[k] = (acc[k] ?? 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
return {
|
|
||||||
agents: tally(agents.items),
|
|
||||||
checks: tally(checks.items),
|
|
||||||
openIncidents: incidents.items.length,
|
|
||||||
totalAgents: agents.items.length,
|
|
||||||
totalChecks: checks.items.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function OverviewPage() {
|
export default async function OverviewPage() {
|
||||||
let data: Awaited<ReturnType<typeof load>>;
|
let data;
|
||||||
try {
|
try {
|
||||||
data = await load();
|
data = await loadOverview();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return <ErrorPanel error={e} />;
|
return <ErrorPanel error={e} />;
|
||||||
}
|
}
|
||||||
return (
|
return <OverviewDashboard initialData={data} />;
|
||||||
<div className="space-y-6">
|
|
||||||
<h1 className="text-lg font-semibold">Overview</h1>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<Card title="Agents" total={data.totalAgents} href="/agents" tally={data.agents} />
|
|
||||||
<Card title="Checks" total={data.totalChecks} href="/checks" tally={data.checks} />
|
|
||||||
<Card
|
|
||||||
title="Open incidents"
|
|
||||||
total={data.openIncidents}
|
|
||||||
href="/incidents"
|
|
||||||
tally={{}}
|
|
||||||
highlight={data.openIncidents > 0 ? "open" : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Card({
|
|
||||||
title,
|
|
||||||
total,
|
|
||||||
href,
|
|
||||||
tally,
|
|
||||||
highlight,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
total: number;
|
|
||||||
href: string;
|
|
||||||
tally: Record<string, number>;
|
|
||||||
highlight?: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Link href={href} className="block border border-neutral-800 hover:border-neutral-600 p-4">
|
|
||||||
<div className="text-xs uppercase tracking-wide text-neutral-400">{title}</div>
|
|
||||||
<div className={`mt-1 text-3xl ${highlight ? statusBadgeClass(highlight) : ""}`}>{total}</div>
|
|
||||||
<div className="mt-2 flex flex-wrap gap-3 text-xs">
|
|
||||||
{Object.entries(tally).map(([k, v]) => (
|
|
||||||
<span key={k} className={statusBadgeClass(k)}>
|
|
||||||
{k} {v}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
216
web/src/components/AgentDetailBrowser.tsx
Normal file
216
web/src/components/AgentDetailBrowser.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Agent } from "@/lib/api";
|
import type { components } from "@/lib/api-types";
|
||||||
|
|
||||||
|
type Agent = components["schemas"]["Agent"];
|
||||||
|
|
||||||
export function AgentFeatures({ features }: { features?: Agent["features"] }) {
|
export function AgentFeatures({ features }: { features?: Agent["features"] }) {
|
||||||
const enabled = enabledFeatures(features);
|
const enabled = enabledFeatures(features);
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ import { useMemo, useState } from "react";
|
|||||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||||
import { LabelChips } from "@/components/Labels";
|
import { LabelChips } from "@/components/Labels";
|
||||||
import { DateTime } from "@/components/TimeZoneControls";
|
import { DateTime } from "@/components/TimeZoneControls";
|
||||||
import type { Agent, CheckState } from "@/lib/api";
|
import type { components } from "@/lib/api-types";
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
import { statusBadgeClass } from "@/lib/format";
|
||||||
|
import { usePolling } from "@/lib/usePolling";
|
||||||
|
import type { InventoryData } from "@/lib/view-types";
|
||||||
|
|
||||||
|
type Agent = components["schemas"]["Agent"];
|
||||||
|
type CheckState = components["schemas"]["CheckState"];
|
||||||
type CheckStatus = CheckState["status"];
|
type CheckStatus = CheckState["status"];
|
||||||
type CheckCounts = Record<CheckStatus, number>;
|
type CheckCounts = Record<CheckStatus, number>;
|
||||||
type SortField = "host" | "status" | "last_seen_at";
|
type SortField = "host" | "status" | "last_seen_at";
|
||||||
@@ -20,10 +24,12 @@ const checkStatuses: CheckStatus[] = ["ok", "warning", "critical", "unknown"];
|
|||||||
const statusRank: Record<Agent["status"], number> = { alive: 0, stale: 1, dead: 2 };
|
const statusRank: Record<Agent["status"], number> = { alive: 0, stale: 1, dead: 2 };
|
||||||
const checkSortStatuses: CheckStatus[] = ["critical", "warning", "unknown", "ok"];
|
const checkSortStatuses: CheckStatus[] = ["critical", "warning", "unknown", "ok"];
|
||||||
|
|
||||||
export function AgentsBrowser({ rows: initialRows }: { rows: AgentRow[] }) {
|
export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
||||||
|
const { data } = usePolling(initialData, "/api/poll/inventory");
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [sort, setSort] = useState<SortField>("status");
|
const [sort, setSort] = useState<SortField>("status");
|
||||||
const [dir, setDir] = useState<SortDir>("desc");
|
const [dir, setDir] = useState<SortDir>("desc");
|
||||||
|
const initialRows = useMemo(() => rowsFromInventory(data), [data]);
|
||||||
|
|
||||||
const rows = useMemo(
|
const rows = useMemo(
|
||||||
() => sortAgents(filterAgents(initialRows, query), sort, dir),
|
() => sortAgents(filterAgents(initialRows, query), sort, dir),
|
||||||
@@ -239,6 +245,19 @@ function emptyCheckCounts(): CheckCounts {
|
|||||||
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
|
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rowsFromInventory(data: InventoryData): AgentRow[] {
|
||||||
|
const checksByAgent = new Map<string, CheckCounts>();
|
||||||
|
for (const c of data.checks) {
|
||||||
|
const counts = checksByAgent.get(c.agent_id) ?? emptyCheckCounts();
|
||||||
|
counts[c.status] += 1;
|
||||||
|
checksByAgent.set(c.agent_id, counts);
|
||||||
|
}
|
||||||
|
return data.agents.map((agent) => ({
|
||||||
|
agent,
|
||||||
|
checks: checksByAgent.get(agent.agent_id) ?? emptyCheckCounts(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
function shortStatus(status: CheckStatus): string {
|
function shortStatus(status: CheckStatus): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "ok":
|
case "ok":
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
212
web/src/components/CheckDetailBrowser.tsx
Normal file
212
web/src/components/CheckDetailBrowser.tsx
Normal 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;
|
||||||
|
}
|
||||||
18
web/src/components/CheckStatusSummary.tsx
Normal file
18
web/src/components/CheckStatusSummary.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
181
web/src/components/ChecksBrowser.tsx
Normal file
181
web/src/components/ChecksBrowser.tsx
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { ApiError } from "@/lib/api";
|
|
||||||
|
|
||||||
export function PageHeader({ title, count }: { title: string; count?: number }) {
|
export function PageHeader({ title, count }: { title: string; count?: number }) {
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 flex items-baseline gap-3">
|
<div className="mb-4 flex items-baseline gap-3">
|
||||||
@@ -11,7 +9,7 @@ export function PageHeader({ title, count }: { title: string; count?: number })
|
|||||||
|
|
||||||
export function ErrorPanel({ error }: { error: unknown }) {
|
export function ErrorPanel({ error }: { error: unknown }) {
|
||||||
const e = error instanceof Error ? error : new Error(String(error));
|
const e = error instanceof Error ? error : new Error(String(error));
|
||||||
const status = error instanceof ApiError ? error.status : "—";
|
const status = hasStatus(error) ? error.status : "—";
|
||||||
return (
|
return (
|
||||||
<div className="border border-red-700 bg-red-950/40 p-3 text-sm">
|
<div className="border border-red-700 bg-red-950/40 p-3 text-sm">
|
||||||
<div className="font-semibold text-red-300">API error ({String(status)})</div>
|
<div className="font-semibold text-red-300">API error ({String(status)})</div>
|
||||||
@@ -20,6 +18,10 @@ export function ErrorPanel({ error }: { error: unknown }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasStatus(error: unknown): error is { status: number } {
|
||||||
|
return typeof error === "object" && error !== null && "status" in error;
|
||||||
|
}
|
||||||
|
|
||||||
export function EmptyPanel({ label = "No data" }: { label?: string }) {
|
export function EmptyPanel({ label = "No data" }: { label?: string }) {
|
||||||
return <div className="text-sm text-neutral-500 italic">{label}.</div>;
|
return <div className="text-sm text-neutral-500 italic">{label}.</div>;
|
||||||
}
|
}
|
||||||
|
|||||||
136
web/src/components/EventsBrowser.tsx
Normal file
136
web/src/components/EventsBrowser.tsx
Normal 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";
|
||||||
|
}
|
||||||
276
web/src/components/IncidentsBrowser.tsx
Normal file
276
web/src/components/IncidentsBrowser.tsx
Normal 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";
|
||||||
|
}
|
||||||
68
web/src/components/OutboxBrowser.tsx
Normal file
68
web/src/components/OutboxBrowser.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
web/src/components/OverviewDashboard.tsx
Normal file
56
web/src/components/OverviewDashboard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -206,7 +206,10 @@ export interface components {
|
|||||||
};
|
};
|
||||||
HeartbeatRequest: {
|
HeartbeatRequest: {
|
||||||
agent_id: string;
|
agent_id: string;
|
||||||
/** Format: date-time */
|
/**
|
||||||
|
* Format: date-time
|
||||||
|
* @description UTC RFC3339 timestamp. Timezone is required; use `Z` or `+00:00`. Non-UTC offsets are rejected.
|
||||||
|
*/
|
||||||
observed_at: string;
|
observed_at: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
features: components["schemas"]["AgentFeatures"];
|
features: components["schemas"]["AgentFeatures"];
|
||||||
@@ -236,7 +239,10 @@ export interface components {
|
|||||||
/** @description UUIDv7 string (lower-case, with dashes). See ADR-0006. */
|
/** @description UUIDv7 string (lower-case, with dashes). See ADR-0006. */
|
||||||
event_id: string;
|
event_id: string;
|
||||||
check_id: string;
|
check_id: string;
|
||||||
/** Format: date-time */
|
/**
|
||||||
|
* Format: date-time
|
||||||
|
* @description UTC RFC3339 timestamp. Timezone is required; use `Z` or `+00:00`. Non-UTC offsets are rejected.
|
||||||
|
*/
|
||||||
observed_at: string;
|
observed_at: string;
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
status: "ok" | "warning" | "critical" | "unknown";
|
status: "ok" | "warning" | "critical" | "unknown";
|
||||||
@@ -286,6 +292,7 @@ export interface components {
|
|||||||
last_observed_at: string;
|
last_observed_at: string;
|
||||||
exit_code?: number;
|
exit_code?: number;
|
||||||
incident_key?: string;
|
incident_key?: string;
|
||||||
|
summary?: string;
|
||||||
};
|
};
|
||||||
Incident: {
|
Incident: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
131
web/src/lib/check-groups.ts
Normal file
131
web/src/lib/check-groups.ts
Normal 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
102
web/src/lib/loaders.ts
Normal 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;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
18
web/src/lib/poll-response.ts
Normal file
18
web/src/lib/poll-response.ts
Normal 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
88
web/src/lib/usePolling.ts
Normal 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
17
web/src/lib/view-types.ts
Normal 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;
|
||||||
|
};
|
||||||
@@ -19,7 +19,7 @@ const fixtures = {
|
|||||||
{
|
{
|
||||||
agent_id: "agent-2",
|
agent_id: "agent-2",
|
||||||
hostname: "host-2",
|
hostname: "host-2",
|
||||||
status: "stale",
|
status: "dead",
|
||||||
last_seen_at: now,
|
last_seen_at: now,
|
||||||
features: { push: true, metrics: false },
|
features: { push: true, metrics: false },
|
||||||
labels: {},
|
labels: {},
|
||||||
@@ -44,6 +44,7 @@ const fixtures = {
|
|||||||
exit_code: 0,
|
exit_code: 0,
|
||||||
last_observed_at: now,
|
last_observed_at: now,
|
||||||
incident_key: "agent-1:disk",
|
incident_key: "agent-1:disk",
|
||||||
|
summary: "disk ok",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
agent_id: "agent-1",
|
agent_id: "agent-1",
|
||||||
@@ -52,6 +53,7 @@ const fixtures = {
|
|||||||
exit_code: 1,
|
exit_code: 1,
|
||||||
last_observed_at: now,
|
last_observed_at: now,
|
||||||
incident_key: "agent-1:load",
|
incident_key: "agent-1:load",
|
||||||
|
summary: "load high",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
next_cursor: null,
|
next_cursor: null,
|
||||||
@@ -68,6 +70,27 @@ const fixtures = {
|
|||||||
opened_at: now,
|
opened_at: now,
|
||||||
summary: "load high",
|
summary: "load high",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "00000000-0000-0000-0000-000000000003",
|
||||||
|
incident_key: "agent:agent-2:liveness",
|
||||||
|
state: "open",
|
||||||
|
severity: "critical",
|
||||||
|
agent_id: "agent-2",
|
||||||
|
check_id: "agent_liveness",
|
||||||
|
opened_at: now,
|
||||||
|
summary: "agent is dead",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "00000000-0000-0000-0000-000000000004",
|
||||||
|
incident_key: "agent-2:disk",
|
||||||
|
state: "resolved",
|
||||||
|
severity: "critical",
|
||||||
|
agent_id: "agent-2",
|
||||||
|
check_id: "disk",
|
||||||
|
opened_at: now,
|
||||||
|
resolved_at: now,
|
||||||
|
summary: "disk recovered",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
next_cursor: null,
|
next_cursor: null,
|
||||||
},
|
},
|
||||||
@@ -82,6 +105,22 @@ const fixtures = {
|
|||||||
status: "ok",
|
status: "ok",
|
||||||
exit_code: 0,
|
exit_code: 0,
|
||||||
duration_ms: 12,
|
duration_ms: 12,
|
||||||
|
output: "disk ok",
|
||||||
|
output_truncated: false,
|
||||||
|
notifications_enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
event_id: "00000000-0000-7000-8000-000000000002",
|
||||||
|
agent_id: "agent-1",
|
||||||
|
check_id: "load",
|
||||||
|
observed_at: now,
|
||||||
|
received_at: now,
|
||||||
|
status: "warning",
|
||||||
|
exit_code: 1,
|
||||||
|
duration_ms: 34,
|
||||||
|
output: "load high",
|
||||||
|
output_truncated: false,
|
||||||
|
notifications_enabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
next_cursor: null,
|
next_cursor: null,
|
||||||
@@ -117,6 +156,29 @@ const server = createServer((req, res) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/api/v1/events/query" && body) {
|
||||||
|
const agentId = url.searchParams.get("agent_id");
|
||||||
|
const checkId = url.searchParams.get("check_id");
|
||||||
|
body = {
|
||||||
|
...body,
|
||||||
|
items: body.items.filter((item) => {
|
||||||
|
if (agentId && item.agent_id !== agentId) return false;
|
||||||
|
if (checkId && item.check_id !== checkId) return false;
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
next_cursor: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/v1/incidents" && body) {
|
||||||
|
const state = url.searchParams.get("state");
|
||||||
|
if (state) {
|
||||||
|
body = {
|
||||||
|
...body,
|
||||||
|
items: body.items.filter((item) => item.state === state),
|
||||||
|
next_cursor: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
res.setHeader("Content-Type", "application/json");
|
res.setHeader("Content-Type", "application/json");
|
||||||
if (!body) {
|
if (!body) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ test("agents list links to detail", async ({ page }) => {
|
|||||||
await expect(page.getByRole("link", { name: "Checks" }).last()).toBeVisible();
|
await expect(page.getByRole("link", { name: "Checks" }).last()).toBeVisible();
|
||||||
await expect(page.locator("tbody").first().locator("tr").first()).toContainText("warning");
|
await expect(page.locator("tbody").first().locator("tr").first()).toContainText("warning");
|
||||||
await expect(page.locator("thead").first()).not.toContainText("incident_key");
|
await expect(page.locator("thead").first()).not.toContainText("incident_key");
|
||||||
|
await expect(page.locator("thead").first()).not.toContainText("exit");
|
||||||
|
await expect(page.locator("tbody").first()).toContainText("load high");
|
||||||
await page.getByRole("link", { name: "check_id" }).click();
|
await page.getByRole("link", { name: "check_id" }).click();
|
||||||
await expect(page).toHaveURL(/sort=check_id/);
|
await expect(page).toHaveURL(/sort=check_id/);
|
||||||
await page.getByRole("link", { name: "Events" }).last().click();
|
await page.getByRole("link", { name: "Events" }).last().click();
|
||||||
@@ -49,18 +51,57 @@ test("agents list links to detail", async ({ page }) => {
|
|||||||
|
|
||||||
test("checks page lists rows", async ({ page }) => {
|
test("checks page lists rows", async ({ page }) => {
|
||||||
await page.goto("/checks");
|
await page.goto("/checks");
|
||||||
|
await expect(page.getByRole("heading", { name: "Checks" })).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/checks$/);
|
||||||
|
await expect(page.locator("thead").first()).not.toContainText("agent_id");
|
||||||
|
await expect(page.locator("thead").first()).not.toContainText("incident_key");
|
||||||
|
await expect(page.locator("thead").first()).not.toContainText("exit");
|
||||||
|
await expect(page.locator("thead").first()).toContainText("↓");
|
||||||
await expect(page.locator("body")).toContainText("disk");
|
await expect(page.locator("body")).toContainText("disk");
|
||||||
await expect(page.locator("body")).toContainText("load");
|
await expect(page.locator("body")).toContainText("load");
|
||||||
|
await expect(page.locator("body")).toContainText("agent_liveness");
|
||||||
|
await page.getByPlaceholder("search check").fill("lo");
|
||||||
|
await expect(page).toHaveURL(/\/checks$/);
|
||||||
|
await expect(page.locator("tbody")).toContainText("load");
|
||||||
|
await expect(page.locator("tbody")).not.toContainText("disk");
|
||||||
|
await page.getByRole("button", { name: "clear" }).click();
|
||||||
|
await page.getByRole("button", { name: /^CHECK/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/checks$/);
|
||||||
await page.getByRole("link", { name: "disk" }).click();
|
await page.getByRole("link", { name: "disk" }).click();
|
||||||
await expect(page).toHaveURL(/\/events\?agent_id=agent-1&check_id=disk$/);
|
await expect(page).toHaveURL(/\/checks\/disk$/);
|
||||||
|
await expect(page.getByRole("link", { name: "Agents" }).last()).toBeVisible();
|
||||||
|
await expect(page.locator("body")).toContainText("host-1");
|
||||||
|
await expect(page.locator("body")).toContainText("disk ok");
|
||||||
|
await page.getByRole("link", { name: "Events" }).last().click();
|
||||||
|
await expect(page).toHaveURL(/\/checks\/disk\?tab=events$/);
|
||||||
|
await expect(page.locator("body")).toContainText("12");
|
||||||
|
await expect(page.locator("thead").last()).not.toContainText("exit");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("incidents filter renders", async ({ page }) => {
|
test("incidents filter renders", async ({ page }) => {
|
||||||
await page.goto("/incidents");
|
await page.goto("/incidents");
|
||||||
await expect(page.locator("body")).toContainText("load high");
|
await expect(page.locator("body")).toContainText("load high");
|
||||||
|
await expect(page.locator("thead")).toContainText("HOST");
|
||||||
|
await expect(page.locator("thead")).not.toContainText("agent_id");
|
||||||
|
await expect(page.locator("thead")).toContainText("↓");
|
||||||
|
await expect(page.locator("tbody")).toContainText("host-1");
|
||||||
|
await expect(page.locator("tbody")).toContainText("agent_liveness");
|
||||||
|
await expect(page.locator("tbody")).toContainText("agent is dead");
|
||||||
|
await page.getByRole("link", { name: "agent_liveness" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/checks\/agent_liveness$/);
|
||||||
|
await expect(page.locator("body")).toContainText("host-2");
|
||||||
|
await page.goto("/incidents");
|
||||||
|
await page.getByRole("button", { name: /^HOST/ }).click();
|
||||||
|
await expect(page).toHaveURL(/\/incidents$/);
|
||||||
|
await expect(page.locator("thead")).toContainText("↑");
|
||||||
|
await page.getByRole("link", { name: "host-1" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/agents\/agent-1$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("events page", async ({ page }) => {
|
test("events page", async ({ page }) => {
|
||||||
|
await page.goto("/events");
|
||||||
|
await expect(page.locator("body")).toContainText("agent_liveness");
|
||||||
|
await expect(page.locator("body")).toContainText("agent is dead");
|
||||||
await page.goto("/events?agent_id=agent-1&check_id=disk");
|
await page.goto("/events?agent_id=agent-1&check_id=disk");
|
||||||
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "query" })).toHaveCount(0);
|
await expect(page.getByRole("button", { name: "query" })).toHaveCount(0);
|
||||||
|
|||||||
Reference in New Issue
Block a user