refactor web pagination and add partitioned events, flap/timeout showcase agents
This commit is contained in:
@@ -9,7 +9,7 @@ services:
|
||||
MONLET_STALE_AFTER_SEC: "20"
|
||||
MONLET_DEAD_AFTER_SEC: "40"
|
||||
MONLET_DETECTOR_TICK_SEC: "5"
|
||||
MONLET_EVENTS_RETENTION_MAX_ROWS: "5000"
|
||||
MONLET_EVENTS_RETENTION_MONTHS: "1"
|
||||
MONLET_OUTBOX_RETENTION_MAX_ROWS: "1000"
|
||||
MONLET_NOTIFIER_DEBUG_ENABLED: "true"
|
||||
MONLET_NOTIFIER_WEBHOOK_ENABLED: "true"
|
||||
@@ -56,6 +56,32 @@ services:
|
||||
agent-mixed:
|
||||
condition: service_started
|
||||
|
||||
agent-flap:
|
||||
image: monlet-showcase-agent
|
||||
environment:
|
||||
MONLET_CONFIG: /etc/monlet/agent.toml
|
||||
volumes:
|
||||
- ./showcase/agents/flap.toml:/etc/monlet/agent.toml:ro
|
||||
- ./showcase/checks:/opt/monlet/checks:ro
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
agent-mixed:
|
||||
condition: service_started
|
||||
|
||||
agent-timeout:
|
||||
image: monlet-showcase-agent
|
||||
environment:
|
||||
MONLET_CONFIG: /etc/monlet/agent.toml
|
||||
volumes:
|
||||
- ./showcase/agents/timeout.toml:/etc/monlet/agent.toml:ro
|
||||
- ./showcase/checks:/opt/monlet/checks:ro
|
||||
depends_on:
|
||||
server:
|
||||
condition: service_healthy
|
||||
agent-mixed:
|
||||
condition: service_started
|
||||
|
||||
agent-prometheus-owned:
|
||||
image: monlet-showcase-agent
|
||||
environment:
|
||||
|
||||
25
deploy/showcase/agents/flap.toml
Normal file
25
deploy/showcase/agents/flap.toml
Normal file
@@ -0,0 +1,25 @@
|
||||
hostname = "agent-flap"
|
||||
state_dir = "/var/lib/monlet-agent"
|
||||
|
||||
[labels]
|
||||
scenario = "flapping"
|
||||
role = "showcase"
|
||||
|
||||
[server]
|
||||
enabled = true
|
||||
url = "http://server:8000"
|
||||
token = "monlet-dev-token"
|
||||
heartbeat_interval = "5s"
|
||||
batch_interval = "3s"
|
||||
|
||||
[metrics]
|
||||
enabled = false
|
||||
listen = "127.0.0.1:9465"
|
||||
|
||||
[[checks]]
|
||||
id = "flap"
|
||||
name = "Flapping check (oscillates ok/critical)"
|
||||
command = "/opt/monlet/checks/flap.sh"
|
||||
interval = "10s"
|
||||
timeout = "3s"
|
||||
dedupe_key = "agent-flap:flap"
|
||||
33
deploy/showcase/agents/timeout.toml
Normal file
33
deploy/showcase/agents/timeout.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
hostname = "agent-timeout"
|
||||
state_dir = "/var/lib/monlet-agent"
|
||||
|
||||
[labels]
|
||||
scenario = "timeout"
|
||||
role = "showcase"
|
||||
|
||||
[server]
|
||||
enabled = true
|
||||
url = "http://server:8000"
|
||||
token = "monlet-dev-token"
|
||||
heartbeat_interval = "5s"
|
||||
batch_interval = "3s"
|
||||
|
||||
[metrics]
|
||||
enabled = false
|
||||
listen = "127.0.0.1:9465"
|
||||
|
||||
[[checks]]
|
||||
id = "ok_probe"
|
||||
name = "Healthy probe"
|
||||
command = "/opt/monlet/checks/exit_0.sh"
|
||||
interval = "10s"
|
||||
timeout = "3s"
|
||||
dedupe_key = "agent-timeout:ok_probe"
|
||||
|
||||
[[checks]]
|
||||
id = "slow_probe"
|
||||
name = "Probe that exceeds the timeout"
|
||||
command = "/opt/monlet/checks/sleep_long.sh"
|
||||
interval = "10s"
|
||||
timeout = "2s"
|
||||
dedupe_key = "agent-timeout:slow_probe"
|
||||
9
deploy/showcase/checks/flap.sh
Executable file
9
deploy/showcase/checks/flap.sh
Executable file
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# Alternates between ok and critical every ~30 seconds to demonstrate flapping.
|
||||
slot=$(( $(date +%s) / 30 % 2 ))
|
||||
if [ "$slot" -eq 0 ]; then
|
||||
echo "critical: flap window down"
|
||||
exit 2
|
||||
fi
|
||||
echo "ok: flap window up"
|
||||
exit 0
|
||||
5
deploy/showcase/checks/sleep_long.sh
Executable file
5
deploy/showcase/checks/sleep_long.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Sleeps longer than the configured timeout to demonstrate timeout handling.
|
||||
sleep 30
|
||||
echo "should never reach here"
|
||||
exit 0
|
||||
@@ -7,7 +7,7 @@ MONLET_STALE_AFTER_SEC=90
|
||||
MONLET_DEAD_AFTER_SEC=300
|
||||
MONLET_DETECTOR_TICK_SEC=15
|
||||
MONLET_ENABLE_DETECTOR=true
|
||||
MONLET_EVENTS_RETENTION_MAX_ROWS=100000
|
||||
MONLET_EVENTS_RETENTION_MONTHS=36
|
||||
MONLET_OUTBOX_RETENTION_MAX_ROWS=50000
|
||||
|
||||
MONLET_ENABLE_NOTIFIER_WORKER=true
|
||||
|
||||
@@ -39,7 +39,10 @@ UV_PROJECT_ENVIRONMENT=.venv UV_CACHE_DIR=../.cache/uv uv run fastapi dev monlet
|
||||
| `MONLET_DEAD_AFTER_SEC` | `300` | Dead threshold |
|
||||
| `MONLET_DETECTOR_TICK_SEC` | `15` | Detector tick |
|
||||
| `MONLET_ENABLE_DETECTOR` | `true` | Toggle detector task |
|
||||
| `MONLET_EVENTS_RETENTION_MAX_ROWS` | `100000` | Maximum retained check-result events; `0` disables pruning |
|
||||
| `MONLET_EVENTS_RETENTION_MONTHS` | `36` | Months of `events` partitions to keep; `0` disables drop |
|
||||
| `MONLET_EVENTS_FUTURE_PARTITIONS` | `3` | Future month partitions to pre-create |
|
||||
| `MONLET_EVENT_DEDUP_RETENTION_DAYS` | `30` | Idempotency window for `event_id` |
|
||||
| `MONLET_PARTITION_MAINTENANCE_INTERVAL_SEC` | `3600` | Partition create/drop worker interval |
|
||||
| `MONLET_OUTBOX_RETENTION_MAX_ROWS` | `50000` | Maximum retained terminal outbox rows; `0` disables pruning |
|
||||
| `MONLET_ENABLE_NOTIFIER_WORKER` | `true` | Toggle outbox worker |
|
||||
| `MONLET_NOTIFIER_TICK_SEC` | `5` | Worker poll interval |
|
||||
|
||||
@@ -50,10 +50,12 @@ def upgrade() -> None:
|
||||
)
|
||||
op.execute("CREATE INDEX checks_status_idx ON checks (status);")
|
||||
|
||||
# events: partitioned by RANGE(received_at). Partition key is part of PK per Postgres.
|
||||
# Global idempotency by event_id is enforced via event_ingest_dedup, not this table.
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE events (
|
||||
event_id UUID PRIMARY KEY,
|
||||
event_id UUID NOT NULL,
|
||||
agent_id TEXT NOT NULL,
|
||||
check_id TEXT NOT NULL,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
@@ -64,14 +66,30 @@ def upgrade() -> None:
|
||||
output TEXT,
|
||||
output_truncated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
incident_key TEXT NOT NULL
|
||||
);
|
||||
incident_key TEXT NOT NULL,
|
||||
PRIMARY KEY (event_id, received_at)
|
||||
) PARTITION BY RANGE (received_at);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX events_agent_check_observed_idx ON events (agent_id, check_id, observed_at DESC);"
|
||||
)
|
||||
op.execute("CREATE INDEX events_observed_at_idx ON events (observed_at DESC);")
|
||||
op.execute("CREATE INDEX events_received_at_idx ON events (received_at DESC);")
|
||||
|
||||
# Global idempotency table: server enforces event_id uniqueness here, not on the
|
||||
# partitioned events table. Pruned by retention TTL (event_dedup_retention_days).
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE event_ingest_dedup (
|
||||
event_id UUID PRIMARY KEY,
|
||||
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"CREATE INDEX event_ingest_dedup_received_at_idx ON event_ingest_dedup (received_at);"
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
@@ -119,6 +137,7 @@ def upgrade() -> None:
|
||||
def downgrade() -> None:
|
||||
op.execute("DROP TABLE IF EXISTS notification_outbox CASCADE;")
|
||||
op.execute("DROP TABLE IF EXISTS incidents CASCADE;")
|
||||
op.execute("DROP TABLE IF EXISTS event_ingest_dedup CASCADE;")
|
||||
op.execute("DROP TABLE IF EXISTS events CASCADE;")
|
||||
op.execute("DROP TABLE IF EXISTS checks CASCADE;")
|
||||
op.execute("DROP TABLE IF EXISTS agents CASCADE;")
|
||||
|
||||
@@ -14,19 +14,43 @@ from .middleware.body_limit import BodyLimitMiddleware
|
||||
from .middleware.request_id import RequestIdMiddleware
|
||||
from .services.detector import run_detector
|
||||
from .services.notifier_worker import run_notifier_worker
|
||||
from .services.partitioning import ensure_event_partitions, run_partition_maintenance
|
||||
from .settings import get_settings
|
||||
|
||||
log = get_logger("monlet.main")
|
||||
|
||||
|
||||
async def _ensure_partitions_with_retry(engine, attempts: int = 6, base_delay: float = 1.0) -> None:
|
||||
last_exc: Exception | None = None
|
||||
for i in range(attempts):
|
||||
try:
|
||||
await ensure_event_partitions(engine)
|
||||
return
|
||||
except Exception as exc:
|
||||
last_exc = exc
|
||||
log.warning("ensure_event_partitions_retry", attempt=i + 1, error=str(exc))
|
||||
await asyncio.sleep(min(base_delay * (2**i), 10.0))
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
s = get_settings()
|
||||
configure_logging(s.log_level)
|
||||
get_engine()
|
||||
engine = get_engine()
|
||||
sm = get_sessionmaker()
|
||||
# Mandatory: current month partition must exist before accepting writes.
|
||||
# Retry briefly so a transient DB unavailability at startup (compose race)
|
||||
# doesn't crash-loop the server.
|
||||
await _ensure_partitions_with_retry(engine)
|
||||
stop_event = asyncio.Event()
|
||||
tasks: list[asyncio.Task] = []
|
||||
# Partition maintenance is independent of detector — must run even when detector is off,
|
||||
# otherwise events_future_partitions runs out and ingest fails.
|
||||
tasks.append(
|
||||
asyncio.create_task(run_partition_maintenance(sm, stop_event), name="partition_maintenance")
|
||||
)
|
||||
if s.enable_detector:
|
||||
tasks.append(asyncio.create_task(run_detector(sm, stop_event), name="detector"))
|
||||
if s.enable_notifier_worker:
|
||||
|
||||
@@ -85,10 +85,11 @@ class Check(Base):
|
||||
class Event(Base):
|
||||
__tablename__ = "events"
|
||||
|
||||
event_id: Mapped[UUID] = mapped_column(_uuid(), primary_key=True)
|
||||
event_id: Mapped[UUID] = mapped_column(_uuid(), nullable=False)
|
||||
agent_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
check_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
# Partition key (RANGE) on Postgres; composite PK = (event_id, received_at).
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
@@ -103,6 +104,7 @@ class Event(Base):
|
||||
incident_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("event_id", "received_at", name="events_pkey"),
|
||||
CheckConstraint(
|
||||
"status IN ('ok','warning','critical','unknown')", name="events_status_chk"
|
||||
),
|
||||
@@ -114,9 +116,21 @@ class Event(Base):
|
||||
text("observed_at DESC"),
|
||||
),
|
||||
Index("events_observed_at_idx", text("observed_at DESC")),
|
||||
Index("events_received_at_idx", text("received_at DESC")),
|
||||
)
|
||||
|
||||
|
||||
class EventIngestDedup(Base):
|
||||
__tablename__ = "event_ingest_dedup"
|
||||
|
||||
event_id: Mapped[UUID] = mapped_column(_uuid(), primary_key=True)
|
||||
received_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
__table_args__ = (Index("event_ingest_dedup_received_at_idx", "received_at"),)
|
||||
|
||||
|
||||
class Incident(Base):
|
||||
__tablename__ = "incidents"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import uuid4
|
||||
from uuid import UUID, uuid4, uuid7
|
||||
|
||||
from sqlalchemy import delete, func, select, text
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from .. import metrics
|
||||
from ..logging_config import get_logger
|
||||
from ..models import Agent, Event, Incident, NotificationOutbox
|
||||
from ..models import Agent, Check, Event, EventIngestDedup, Incident, NotificationOutbox
|
||||
from ..settings import get_settings
|
||||
|
||||
log = get_logger("monlet.detector")
|
||||
@@ -25,6 +25,10 @@ def _liveness_severity(status: str) -> str:
|
||||
return "critical" if status == "dead" else "warning"
|
||||
|
||||
|
||||
_LIVENESS_CHECK_STATUS = {"alive": "ok", "stale": "warning", "dead": "critical"}
|
||||
_LIVENESS_EXIT_CODE = {"ok": 0, "warning": 1, "critical": 2}
|
||||
|
||||
|
||||
async def _enqueue_liveness_outbox(
|
||||
session,
|
||||
incident: Incident,
|
||||
@@ -68,7 +72,9 @@ async def _enqueue_liveness_outbox(
|
||||
)
|
||||
|
||||
|
||||
async def _open_liveness_incident(session, agent: Agent, status: str, now: datetime) -> None:
|
||||
async def _open_liveness_incident(
|
||||
session, agent: Agent, status: str, now: datetime, event_id: UUID
|
||||
) -> None:
|
||||
severity = _liveness_severity(status)
|
||||
summary = f"agent is {status}"
|
||||
incident_key = _liveness_key(agent.agent_id)
|
||||
@@ -84,7 +90,7 @@ async def _open_liveness_incident(session, agent: Agent, status: str, now: datet
|
||||
severity=severity,
|
||||
opened_at=now,
|
||||
summary=summary,
|
||||
last_event_id=uuid4(),
|
||||
last_event_id=event_id,
|
||||
)
|
||||
.on_conflict_do_nothing(
|
||||
index_elements=[Incident.incident_key],
|
||||
@@ -115,13 +121,13 @@ async def _open_liveness_incident(session, agent: Agent, status: str, now: datet
|
||||
if status == "dead" and inc.severity != "critical":
|
||||
inc.severity = "critical"
|
||||
inc.summary = summary
|
||||
inc.last_event_id = uuid4()
|
||||
inc.last_event_id = event_id
|
||||
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:
|
||||
async def _resolve_liveness_incident(session, agent: Agent, now: datetime, event_id: UUID) -> None:
|
||||
inc = (
|
||||
await session.execute(
|
||||
select(Incident).where(
|
||||
@@ -134,7 +140,7 @@ async def _resolve_liveness_incident(session, agent: Agent, now: datetime) -> No
|
||||
return
|
||||
inc.state = "resolved"
|
||||
inc.resolved_at = now
|
||||
inc.last_event_id = uuid4()
|
||||
inc.last_event_id = event_id
|
||||
metrics.incidents_resolved_total.inc()
|
||||
await _enqueue_liveness_outbox(
|
||||
session,
|
||||
@@ -148,15 +154,83 @@ async def _resolve_liveness_incident(session, agent: Agent, now: datetime) -> No
|
||||
)
|
||||
|
||||
|
||||
async def _apply_liveness(session, agent: Agent, next_status: str, now: datetime) -> None:
|
||||
"""Server-owned liveness check: maintain Check + Event-on-transition + Incident lifecycle."""
|
||||
liveness_status = _LIVENESS_CHECK_STATUS[next_status]
|
||||
exit_code = _LIVENESS_EXIT_CODE[liveness_status]
|
||||
summary = f"agent is {next_status}"
|
||||
incident_key = _liveness_key(agent.agent_id)
|
||||
|
||||
cur = (
|
||||
await session.execute(
|
||||
select(Check).where(
|
||||
Check.agent_id == agent.agent_id, Check.check_id == LIVENESS_CHECK_ID
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
changed = cur is None or cur.status != liveness_status
|
||||
|
||||
# Liveness events flow through /events/query which validates event_id as UUIDv7.
|
||||
event_id = cur.last_event_id if cur and not changed else uuid7()
|
||||
if changed:
|
||||
session.add(
|
||||
Event(
|
||||
event_id=event_id,
|
||||
agent_id=agent.agent_id,
|
||||
check_id=LIVENESS_CHECK_ID,
|
||||
observed_at=now,
|
||||
status=liveness_status,
|
||||
exit_code=exit_code,
|
||||
duration_ms=0,
|
||||
output=summary,
|
||||
output_truncated=False,
|
||||
notifications_enabled=True,
|
||||
incident_key=incident_key,
|
||||
)
|
||||
)
|
||||
|
||||
if cur is None:
|
||||
session.add(
|
||||
Check(
|
||||
agent_id=agent.agent_id,
|
||||
check_id=LIVENESS_CHECK_ID,
|
||||
status=liveness_status,
|
||||
exit_code=exit_code,
|
||||
last_observed_at=now,
|
||||
last_event_id=event_id,
|
||||
incident_key=incident_key,
|
||||
summary=summary,
|
||||
)
|
||||
)
|
||||
else:
|
||||
cur.last_observed_at = now
|
||||
cur.summary = summary
|
||||
if changed:
|
||||
cur.status = liveness_status
|
||||
cur.exit_code = exit_code
|
||||
cur.last_event_id = event_id
|
||||
cur.incident_key = incident_key
|
||||
|
||||
if next_status in ("stale", "dead"):
|
||||
await _open_liveness_incident(session, agent, next_status, now, event_id)
|
||||
else:
|
||||
await _resolve_liveness_incident(session, agent, now, event_id)
|
||||
|
||||
|
||||
async def _prune_history(session) -> None:
|
||||
s = get_settings()
|
||||
if s.events_retention_max_rows > 0:
|
||||
keep_events = (
|
||||
select(Event.event_id)
|
||||
.order_by(Event.received_at.desc(), Event.event_id.desc())
|
||||
.limit(s.events_retention_max_rows)
|
||||
if s.event_dedup_retention_days > 0:
|
||||
cutoff = datetime.now(UTC) - timedelta(days=s.event_dedup_retention_days)
|
||||
batch = max(s.event_dedup_prune_batch_size, 1)
|
||||
# Bounded DELETE: keep each tick's prune transaction short to avoid bloat and
|
||||
# long-running locks on a large dedup table.
|
||||
sub = (
|
||||
select(EventIngestDedup.event_id)
|
||||
.where(EventIngestDedup.received_at < cutoff)
|
||||
.limit(batch)
|
||||
.scalar_subquery()
|
||||
)
|
||||
await session.execute(delete(Event).where(Event.event_id.not_in(keep_events)))
|
||||
await session.execute(delete(EventIngestDedup).where(EventIngestDedup.event_id.in_(sub)))
|
||||
|
||||
if s.outbox_retention_max_rows > 0:
|
||||
keep_outbox = (
|
||||
@@ -188,10 +262,7 @@ async def _tick(sm: async_sessionmaker) -> None:
|
||||
next_status = "alive"
|
||||
if agent.status != next_status:
|
||||
agent.status = next_status
|
||||
if next_status in ("stale", "dead"):
|
||||
await _open_liveness_incident(session, agent, next_status, now)
|
||||
else:
|
||||
await _resolve_liveness_incident(session, agent, now)
|
||||
await _apply_liveness(session, agent, next_status, now)
|
||||
await _prune_history(session)
|
||||
await session.commit()
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from .. import metrics
|
||||
from ..logging_config import get_logger
|
||||
from ..models import Agent, Check, Event, Incident, NotificationOutbox
|
||||
from ..models import Agent, Check, Event, EventIngestDedup, Incident, NotificationOutbox
|
||||
from ..redaction import redact
|
||||
from ..schemas import CheckResultEvent, HeartbeatRequest
|
||||
from ..settings import get_settings
|
||||
@@ -155,26 +155,45 @@ async def ingest_event(
|
||||
agent_id: str,
|
||||
ev: CheckResultEvent,
|
||||
) -> bool:
|
||||
"""Return True if a new change row was written, False if not (duplicate/unchanged/late)."""
|
||||
"""Return True if a new change row was written, False if not (duplicate/unchanged/late).
|
||||
|
||||
Idempotency is enforced first via the standalone event_ingest_dedup table — a
|
||||
duplicate event_id never touches checks/incidents/outbox/events. The full pipeline
|
||||
(dedup + events + check + incident + outbox) commits as a single transaction by
|
||||
the caller, so a partial failure cannot leave a dedup row without its side-effects.
|
||||
"""
|
||||
observed = to_utc(ev.observed_at)
|
||||
output = redact(ev.output)
|
||||
check_summary = (output or "")[:200] if output else None
|
||||
incident_key = _incident_key(agent_id, ev)
|
||||
event_uuid = UUID(ev.event_id)
|
||||
|
||||
# Order matters: late check before dedup insert so that repeated late events stay
|
||||
# observable as `late`, not silently collapsed into `deduplicated`.
|
||||
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
|
||||
|
||||
dedup_stmt = (
|
||||
pg_insert(EventIngestDedup)
|
||||
.values(event_id=event_uuid, received_at=datetime.now(UTC))
|
||||
.on_conflict_do_nothing(index_elements=[EventIngestDedup.event_id])
|
||||
.returning(EventIngestDedup.event_id)
|
||||
)
|
||||
if (await session.execute(dedup_stmt)).scalar_one_or_none() is None:
|
||||
metrics.events_total.labels(result="deduplicated").inc()
|
||||
return False
|
||||
|
||||
changed = cur is None or cur.status != ev.status or cur.exit_code != ev.exit_code
|
||||
|
||||
if changed:
|
||||
stmt = pg_insert(Event).values(
|
||||
event_id=UUID(ev.event_id),
|
||||
event_id=event_uuid,
|
||||
agent_id=agent_id,
|
||||
check_id=ev.check_id,
|
||||
observed_at=observed,
|
||||
@@ -186,14 +205,7 @@ async def ingest_event(
|
||||
notifications_enabled=ev.notifications_enabled,
|
||||
incident_key=incident_key,
|
||||
)
|
||||
stmt = stmt.on_conflict_do_nothing(index_elements=[Event.event_id]).returning(
|
||||
Event.event_id
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
inserted = result.scalar_one_or_none()
|
||||
if inserted is None:
|
||||
metrics.events_total.labels(result="deduplicated").inc()
|
||||
return False
|
||||
await session.execute(stmt)
|
||||
metrics.events_total.labels(result="accepted").inc()
|
||||
else:
|
||||
metrics.events_total.labels(result="unchanged").inc()
|
||||
@@ -218,7 +230,7 @@ async def ingest_event(
|
||||
"summary": check_summary,
|
||||
}
|
||||
if changed:
|
||||
update_set["last_event_id"] = UUID(ev.event_id)
|
||||
update_set["last_event_id"] = event_uuid
|
||||
update_set["incident_key"] = incident_key
|
||||
chk_stmt = pg_insert(Check).values(**check_values)
|
||||
chk_stmt = chk_stmt.on_conflict_do_update(
|
||||
|
||||
143
server/monlet_server/services/partitioning.py
Normal file
143
server/monlet_server/services/partitioning.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Monthly partition maintenance for `events` (PARTITION BY RANGE(received_at)).
|
||||
|
||||
Idempotent: creates current + N future month partitions and drops old ones beyond
|
||||
the retention window. Names are derived from year/month, so they cannot inject SQL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker
|
||||
|
||||
from ..logging_config import get_logger
|
||||
from ..settings import get_settings
|
||||
|
||||
log = get_logger("monlet.partitioning")
|
||||
|
||||
|
||||
def _month_start(d: date) -> date:
|
||||
return date(d.year, d.month, 1)
|
||||
|
||||
|
||||
def _next_month(d: date) -> date:
|
||||
year = d.year + (1 if d.month == 12 else 0)
|
||||
month = 1 if d.month == 12 else d.month + 1
|
||||
return date(year, month, 1)
|
||||
|
||||
|
||||
def _shift_months(d: date, n: int) -> date:
|
||||
y, m = d.year, d.month
|
||||
total = y * 12 + (m - 1) + n
|
||||
return date(total // 12, (total % 12) + 1, 1)
|
||||
|
||||
|
||||
def _partition_name(d: date) -> str:
|
||||
return f"events_p{d.year:04d}_{d.month:02d}"
|
||||
|
||||
|
||||
async def ensure_event_partitions(engine_or_sm: AsyncEngine | async_sessionmaker) -> None:
|
||||
"""Create current + N future month partitions for events. Idempotent."""
|
||||
s = get_settings()
|
||||
today = _month_start(datetime.now(UTC).date())
|
||||
months = [_shift_months(today, i) for i in range(0, max(s.events_future_partitions, 0) + 1)]
|
||||
|
||||
statements: list[str] = []
|
||||
for start in months:
|
||||
end = _next_month(start)
|
||||
# Explicit UTC qualifier — Postgres otherwise interprets bare date literals via
|
||||
# the session TimeZone, which would shift partition bounds on non-UTC servers.
|
||||
statements.append(
|
||||
f"CREATE TABLE IF NOT EXISTS {_partition_name(start)} "
|
||||
f"PARTITION OF events FOR VALUES FROM ('{start.isoformat()} UTC') "
|
||||
f"TO ('{end.isoformat()} UTC');"
|
||||
)
|
||||
|
||||
async with _connect(engine_or_sm) as conn:
|
||||
for stmt in statements:
|
||||
await conn.execute(text(stmt))
|
||||
|
||||
|
||||
async def drop_old_event_partitions(engine_or_sm: AsyncEngine | async_sessionmaker) -> None:
|
||||
"""Drop month partitions older than events_retention_months.
|
||||
|
||||
Each DROP runs in its own transaction so the ACCESS EXCLUSIVE lock on parent
|
||||
`events` is held only for one partition at a time and ingest is not blocked
|
||||
by a long maintenance batch.
|
||||
"""
|
||||
s = get_settings()
|
||||
if s.events_retention_months <= 0:
|
||||
return
|
||||
cutoff = _shift_months(_month_start(datetime.now(UTC).date()), -s.events_retention_months)
|
||||
|
||||
engine = _resolve_engine(engine_or_sm)
|
||||
async with engine.connect() as conn:
|
||||
rows = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT c.relname
|
||||
FROM pg_inherits i
|
||||
JOIN pg_class c ON c.oid = i.inhrelid
|
||||
JOIN pg_class p ON p.oid = i.inhparent
|
||||
WHERE p.relname = 'events'
|
||||
"""
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
to_drop: list[str] = []
|
||||
for (name,) in rows:
|
||||
if not name.startswith("events_p"):
|
||||
continue
|
||||
try:
|
||||
y = int(name[8:12])
|
||||
m = int(name[13:15])
|
||||
except ValueError:
|
||||
continue
|
||||
if date(y, m, 1) < cutoff:
|
||||
to_drop.append(name)
|
||||
|
||||
for name in to_drop:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(text(f"DROP TABLE IF EXISTS {name};"))
|
||||
log.info("event_partition_dropped", name=name)
|
||||
|
||||
|
||||
def _resolve_engine(engine_or_sm: AsyncEngine | async_sessionmaker) -> AsyncEngine:
|
||||
if isinstance(engine_or_sm, async_sessionmaker):
|
||||
return engine_or_sm.kw["bind"]
|
||||
return engine_or_sm
|
||||
|
||||
|
||||
def _connect(engine_or_sm):
|
||||
"""Return an async ctx for a transactional connection from either engine or sessionmaker."""
|
||||
return _resolve_engine(engine_or_sm).begin()
|
||||
|
||||
|
||||
async def run_partition_maintenance(sm: async_sessionmaker, stop_event: asyncio.Event) -> None:
|
||||
"""Independent worker — runs even when MONLET_ENABLE_DETECTOR=false."""
|
||||
interval = max(get_settings().partition_maintenance_interval_sec, 60)
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
await ensure_event_partitions(sm)
|
||||
await drop_old_event_partitions(sm)
|
||||
except Exception as exc:
|
||||
log.warning("partition_maintenance_failed", error=str(exc))
|
||||
try:
|
||||
await asyncio.wait_for(stop_event.wait(), timeout=interval)
|
||||
except TimeoutError:
|
||||
continue
|
||||
|
||||
|
||||
# Re-export helpers for tests.
|
||||
__all__ = [
|
||||
"run_partition_maintenance",
|
||||
"ensure_event_partitions",
|
||||
"drop_old_event_partitions",
|
||||
"_partition_name",
|
||||
"_shift_months",
|
||||
"_month_start",
|
||||
]
|
||||
@@ -20,7 +20,11 @@ class Settings(BaseSettings):
|
||||
port: int = 8000
|
||||
enable_detector: bool = True
|
||||
enable_notifier_worker: bool = True
|
||||
events_retention_max_rows: int = 20_000
|
||||
events_retention_months: int = 36
|
||||
events_future_partitions: int = 3
|
||||
event_dedup_retention_days: int = 30
|
||||
event_dedup_prune_batch_size: int = 5000
|
||||
partition_maintenance_interval_sec: int = 3600
|
||||
outbox_retention_max_rows: int = 50_000
|
||||
notifier_tick_sec: int = 5
|
||||
notifier_batch_size: int = 20
|
||||
|
||||
@@ -58,6 +58,20 @@ def _apply_migrations(database_url: str) -> Iterator[None]:
|
||||
cfg.set_main_option("script_location", os.path.join(SERVER_ROOT, "alembic"))
|
||||
cfg.set_main_option("sqlalchemy.url", get_settings().sync_database_url)
|
||||
command.upgrade(cfg, "head")
|
||||
|
||||
# Partitions for `events` are created at runtime, not by the migration.
|
||||
async def _bootstrap() -> None:
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from monlet_server.services.partitioning import ensure_event_partitions
|
||||
|
||||
eng = create_async_engine(database_url, future=True)
|
||||
try:
|
||||
await ensure_event_partitions(eng)
|
||||
finally:
|
||||
await eng.dispose()
|
||||
|
||||
asyncio.run(_bootstrap())
|
||||
yield
|
||||
|
||||
|
||||
@@ -83,7 +97,7 @@ async def _truncate_tables(engine) -> AsyncIterator[None]:
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"TRUNCATE TABLE notification_outbox, incidents, events, checks, agents RESTART IDENTITY CASCADE"
|
||||
"TRUNCATE TABLE notification_outbox, incidents, events, event_ingest_dedup, checks, agents RESTART IDENTITY CASCADE"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -9,26 +9,18 @@ from monlet_server.models import Incident
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_id_conflict_do_nothing(engine):
|
||||
async def test_event_id_dedup_conflict_do_nothing(engine):
|
||||
"""Global idempotency is enforced via event_ingest_dedup, not the partitioned events table."""
|
||||
eid = str(uuid4())
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO agents (agent_id, hostname, features, status, last_seen_at) "
|
||||
"VALUES ('a','h',jsonb_build_object('push', true, 'metrics', false),'alive', now())"
|
||||
)
|
||||
)
|
||||
await conn.execute(
|
||||
text(
|
||||
"INSERT INTO events (event_id, agent_id, check_id, observed_at, status, exit_code, duration_ms, incident_key)"
|
||||
" VALUES (:e,'a','c', now(),'ok',0,1,'a:c')"
|
||||
),
|
||||
text("INSERT INTO event_ingest_dedup (event_id) VALUES (:e)"),
|
||||
{"e": eid},
|
||||
)
|
||||
r = await conn.execute(
|
||||
text(
|
||||
"INSERT INTO events (event_id, agent_id, check_id, observed_at, status, exit_code, duration_ms, incident_key)"
|
||||
" VALUES (:e,'a','c', now(),'ok',0,1,'a:c') ON CONFLICT (event_id) DO NOTHING RETURNING event_id"
|
||||
"INSERT INTO event_ingest_dedup (event_id) VALUES (:e) "
|
||||
"ON CONFLICT (event_id) DO NOTHING RETURNING event_id"
|
||||
),
|
||||
{"e": eid},
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ import pytest
|
||||
from sqlalchemy import func, select, update
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from monlet_server.models import Agent, Event, Incident, NotificationOutbox
|
||||
from monlet_server.models import Agent, Check, Event, Incident, NotificationOutbox
|
||||
from monlet_server.services.detector import LIVENESS_CHECK_ID, _tick
|
||||
from monlet_server.settings import reset_settings_cache
|
||||
|
||||
@@ -82,22 +82,75 @@ async def test_detector_transitions(app_client, auth_headers, engine, session):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detector_prunes_bounded_history(
|
||||
app_client, auth_headers, engine, session, monkeypatch
|
||||
):
|
||||
monkeypatch.setenv("MONLET_EVENTS_RETENTION_MAX_ROWS", "2")
|
||||
async def test_detector_owns_liveness_check_and_events(app_client, auth_headers, engine, session):
|
||||
await app_client.post(
|
||||
"/api/v1/heartbeat", json=make_heartbeat("agent-flap"), headers=auth_headers
|
||||
)
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
# Tick 1: alive → Check exists with status=ok, one event.
|
||||
await _tick(sm)
|
||||
session.expire_all()
|
||||
check = (
|
||||
await session.execute(
|
||||
select(Check).where(Check.agent_id == "agent-flap", Check.check_id == LIVENESS_CHECK_ID)
|
||||
)
|
||||
).scalar_one()
|
||||
assert check.status == "ok"
|
||||
n_events = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Event)
|
||||
.where(Event.agent_id == "agent-flap", Event.check_id == LIVENESS_CHECK_ID)
|
||||
)
|
||||
).scalar_one()
|
||||
assert n_events == 1
|
||||
|
||||
# Tick 2: still alive → no new event.
|
||||
await _tick(sm)
|
||||
session.expire_all()
|
||||
n_events = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Event)
|
||||
.where(Event.agent_id == "agent-flap", Event.check_id == LIVENESS_CHECK_ID)
|
||||
)
|
||||
).scalar_one()
|
||||
assert n_events == 1
|
||||
|
||||
# Push agent into dead → transition event written, Check updates.
|
||||
await session.execute(
|
||||
update(Agent)
|
||||
.where(Agent.agent_id == "agent-flap")
|
||||
.values(last_seen_at=datetime.now(UTC) - timedelta(minutes=10))
|
||||
)
|
||||
await session.commit()
|
||||
await _tick(sm)
|
||||
session.expire_all()
|
||||
check = (
|
||||
await session.execute(
|
||||
select(Check).where(Check.agent_id == "agent-flap", Check.check_id == LIVENESS_CHECK_ID)
|
||||
)
|
||||
).scalar_one()
|
||||
assert check.status == "critical"
|
||||
n_events = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
.select_from(Event)
|
||||
.where(Event.agent_id == "agent-flap", Event.check_id == LIVENESS_CHECK_ID)
|
||||
)
|
||||
).scalar_one()
|
||||
assert n_events == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detector_prunes_outbox(app_client, auth_headers, engine, session, monkeypatch):
|
||||
monkeypatch.setenv("MONLET_OUTBOX_RETENTION_MAX_ROWS", "2")
|
||||
reset_settings_cache()
|
||||
try:
|
||||
await app_client.post(
|
||||
"/api/v1/heartbeat", json=make_heartbeat("agent-prune"), headers=auth_headers
|
||||
)
|
||||
events = [make_event(check_id="prune_check") for _ in range(5)]
|
||||
await app_client.post(
|
||||
"/api/v1/events",
|
||||
json={"agent_id": "agent-prune", "events": events},
|
||||
headers=auth_headers,
|
||||
)
|
||||
critical = make_event(check_id="critical_prune", status="critical", exit_code=2)
|
||||
await app_client.post(
|
||||
"/api/v1/events",
|
||||
@@ -129,7 +182,6 @@ async def test_detector_prunes_bounded_history(
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
await _tick(sm)
|
||||
|
||||
assert (await session.execute(select(func.count()).select_from(Event))).scalar_one() == 2
|
||||
terminal_outbox = (
|
||||
await session.execute(
|
||||
select(func.count())
|
||||
|
||||
125
server/tests/test_partitioning.py
Normal file
125
server/tests/test_partitioning.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import text
|
||||
|
||||
from monlet_server.services.partitioning import (
|
||||
_month_start,
|
||||
_partition_name,
|
||||
_shift_months,
|
||||
drop_old_event_partitions,
|
||||
ensure_event_partitions,
|
||||
)
|
||||
from monlet_server.settings import reset_settings_cache
|
||||
|
||||
|
||||
async def _partitions(engine) -> set[str]:
|
||||
async with engine.begin() as conn:
|
||||
rows = (
|
||||
await conn.execute(
|
||||
text(
|
||||
"SELECT c.relname FROM pg_inherits i "
|
||||
"JOIN pg_class c ON c.oid=i.inhrelid "
|
||||
"JOIN pg_class p ON p.oid=i.inhparent "
|
||||
"WHERE p.relname='events'"
|
||||
)
|
||||
)
|
||||
).all()
|
||||
return {r[0] for r in rows}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_creates_current_and_future_partitions(engine, monkeypatch):
|
||||
monkeypatch.setenv("MONLET_EVENTS_FUTURE_PARTITIONS", "2")
|
||||
reset_settings_cache()
|
||||
try:
|
||||
await ensure_event_partitions(engine)
|
||||
today = _month_start(datetime.now(UTC).date())
|
||||
for i in range(0, 3):
|
||||
assert _partition_name(_shift_months(today, i)) in await _partitions(engine)
|
||||
finally:
|
||||
reset_settings_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drop_old_partitions_uses_retention(engine, monkeypatch):
|
||||
monkeypatch.setenv("MONLET_EVENTS_RETENTION_MONTHS", "1")
|
||||
reset_settings_cache()
|
||||
try:
|
||||
# Create an old partition directly.
|
||||
old = date(2020, 1, 1)
|
||||
name = _partition_name(old)
|
||||
end = _shift_months(old, 1)
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text(
|
||||
f"CREATE TABLE IF NOT EXISTS {name} PARTITION OF events "
|
||||
f"FOR VALUES FROM ('{old.isoformat()} UTC') TO ('{end.isoformat()} UTC');"
|
||||
)
|
||||
)
|
||||
assert name in await _partitions(engine)
|
||||
await drop_old_event_partitions(engine)
|
||||
assert name not in await _partitions(engine)
|
||||
finally:
|
||||
reset_settings_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dedup_prevents_replay_via_ingest(app_client, auth_headers, session):
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from monlet_server.models import Event
|
||||
|
||||
from ._helpers import make_event, make_heartbeat
|
||||
|
||||
await app_client.post("/api/v1/heartbeat", json=make_heartbeat(), headers=auth_headers)
|
||||
ev = make_event(status="critical", exit_code=2)
|
||||
|
||||
r1 = await app_client.post(
|
||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||
)
|
||||
assert r1.json() == {"accepted": 1, "deduplicated": 0}
|
||||
|
||||
# Replay the exact same event_id long after — dedup must block ingestion entirely,
|
||||
# never touching checks/incidents/outbox/events.
|
||||
r2 = await app_client.post(
|
||||
"/api/v1/events", json={"agent_id": "agent-1", "events": [ev]}, headers=auth_headers
|
||||
)
|
||||
assert r2.json() == {"accepted": 0, "deduplicated": 1}
|
||||
|
||||
n = (await session.execute(select(func.count()).select_from(Event))).scalar_one()
|
||||
assert n == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_late_dedup_does_not_keep_replay_doors_open(engine, monkeypatch):
|
||||
"""Dedup retention TTL must be at least documented agent spool window."""
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||
|
||||
from monlet_server.models import EventIngestDedup
|
||||
from monlet_server.services.detector import _prune_history
|
||||
|
||||
monkeypatch.setenv("MONLET_EVENT_DEDUP_RETENTION_DAYS", "1")
|
||||
reset_settings_cache()
|
||||
try:
|
||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with sm() as s:
|
||||
old = datetime.now(UTC) - timedelta(days=5)
|
||||
s.add(EventIngestDedup(event_id=uuid4(), received_at=old))
|
||||
s.add(EventIngestDedup(event_id=uuid4(), received_at=datetime.now(UTC)))
|
||||
await s.commit()
|
||||
|
||||
async with sm() as s:
|
||||
await _prune_history(s)
|
||||
await s.commit()
|
||||
|
||||
async with sm() as s:
|
||||
n = (await s.execute(select(func.count()).select_from(EventIngestDedup))).scalar_one()
|
||||
assert n == 1
|
||||
finally:
|
||||
reset_settings_cache()
|
||||
@@ -1,13 +1,14 @@
|
||||
import { AgentDetailBrowser } from "@/components/AgentDetailBrowser";
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { withLivenessEvents } from "@/lib/check-groups";
|
||||
import { loadChecks } from "@/lib/loaders";
|
||||
import { normalizePageSize } from "@/lib/pagination";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SortKey = "severity" | "check_id" | "last_seen";
|
||||
type TabKey = "checks" | "events";
|
||||
type EventsPage = Awaited<ReturnType<typeof api.queryEvents>>;
|
||||
|
||||
function normalizeSort(sort?: string): SortKey {
|
||||
return sort === "check_id" || sort === "last_seen" ? sort : "severity";
|
||||
@@ -21,22 +22,35 @@ export default async function AgentDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ agent_id: string }>;
|
||||
searchParams: Promise<{ sort?: string; tab?: string }>;
|
||||
params: Promise<{
|
||||
agent_id: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
sort?: string;
|
||||
tab?: string;
|
||||
events_limit?: string;
|
||||
cursor?: string;
|
||||
}>;
|
||||
}) {
|
||||
const { agent_id } = await params;
|
||||
const sp = await searchParams;
|
||||
const sort = normalizeSort(sp.sort);
|
||||
const tab = normalizeTab(sp.tab);
|
||||
const eventsLimit = normalizePageSize(sp.events_limit, 50);
|
||||
const eventsCursor = sp.cursor;
|
||||
|
||||
let data;
|
||||
let agent;
|
||||
let checks;
|
||||
let events: EventsPage = { items: [], next_cursor: null };
|
||||
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 }) };
|
||||
[agent, checks] = await Promise.all([api.getAgent(agent_id), loadChecks(agent_id)]);
|
||||
if (tab === "events") {
|
||||
events = await api.queryEvents({
|
||||
agent_id,
|
||||
limit: eventsLimit,
|
||||
cursor: eventsCursor,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
return <div className="text-sm text-neutral-400">Agent not found.</div>;
|
||||
@@ -45,10 +59,13 @@ export default async function AgentDetailPage({
|
||||
}
|
||||
return (
|
||||
<AgentDetailBrowser
|
||||
key={`${agent_id}:${tab}:${sort}`}
|
||||
initialData={data}
|
||||
key={`${agent_id}:${tab}:${sort}:${eventsLimit}:${eventsCursor ?? ""}`}
|
||||
initialData={{ agent, checks }}
|
||||
initialEvents={events}
|
||||
sort={sort}
|
||||
tab={tab}
|
||||
eventsLimit={eventsLimit}
|
||||
eventsCursor={eventsCursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -19,16 +18,8 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
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 }),
|
||||
});
|
||||
const [agent, checks] = await Promise.all([api.getAgent(agentId), loadChecks(agentId)]);
|
||||
return NextResponse.json({ agent, checks });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { loadEventsWithLiveness } from "@/lib/loaders";
|
||||
import { loadEvents } from "@/lib/loaders";
|
||||
import { normalizePageSize } from "@/lib/pagination";
|
||||
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");
|
||||
const limit = normalizePageSize(url.searchParams.get("limit"), 50);
|
||||
|
||||
try {
|
||||
const data = await loadEventsWithLiveness({
|
||||
const data = await loadEvents({
|
||||
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,
|
||||
limit,
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
|
||||
@@ -11,8 +11,8 @@ export async function GET(request: Request) {
|
||||
const state = stateParam === "open" || stateParam === "resolved" ? stateParam : undefined;
|
||||
|
||||
try {
|
||||
const data = await loadIncidents(state);
|
||||
return NextResponse.json(data);
|
||||
const items = await loadIncidents(state);
|
||||
return NextResponse.json({ items });
|
||||
} catch (e) {
|
||||
return jsonError(e);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { loadOutboxAll } 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);
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await api.listOutbox({
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
const items = await loadOutboxAll();
|
||||
return NextResponse.json({ items });
|
||||
} catch (e) {
|
||||
return jsonError(e);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
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";
|
||||
import { normalizePageSize } from "@/lib/pagination";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -18,22 +18,24 @@ export default async function CheckDetailPage({
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ check_id: string }>;
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
searchParams: Promise<{ tab?: string; events_limit?: string; cursor?: string }>;
|
||||
}) {
|
||||
const { check_id } = await params;
|
||||
const sp = await searchParams;
|
||||
const tab = normalizeTab(sp.tab);
|
||||
const eventsLimit = normalizePageSize(sp.events_limit, 50);
|
||||
const eventsCursor = sp.cursor;
|
||||
|
||||
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 }),
|
||||
};
|
||||
events = await api.queryEvents({
|
||||
check_id,
|
||||
limit: eventsLimit,
|
||||
cursor: eventsCursor,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
@@ -41,11 +43,13 @@ export default async function CheckDetailPage({
|
||||
|
||||
return (
|
||||
<CheckDetailBrowser
|
||||
key={`${check_id}:${tab}`}
|
||||
key={`${check_id}:${tab}:${eventsLimit}:${eventsCursor ?? ""}`}
|
||||
checkId={check_id}
|
||||
initialData={data}
|
||||
initialEvents={events}
|
||||
tab={tab}
|
||||
eventsLimit={eventsLimit}
|
||||
eventsCursor={eventsCursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { EventsBrowser } from "@/components/EventsBrowser";
|
||||
import { loadEventsWithLiveness } from "@/lib/loaders";
|
||||
import { loadEvents } from "@/lib/loaders";
|
||||
import { normalizePageSize } from "@/lib/pagination";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EventsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ cursor?: string; agent_id?: string; check_id?: string }>;
|
||||
searchParams: Promise<{
|
||||
cursor?: string;
|
||||
agent_id?: string;
|
||||
check_id?: string;
|
||||
limit?: string;
|
||||
}>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const limit = normalizePageSize(sp.limit);
|
||||
let data;
|
||||
try {
|
||||
data = await loadEventsWithLiveness({
|
||||
data = await loadEvents({
|
||||
agentId: sp.agent_id,
|
||||
checkId: sp.check_id,
|
||||
cursor: sp.cursor,
|
||||
limit,
|
||||
});
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<EventsBrowser
|
||||
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}`}
|
||||
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}:${limit}`}
|
||||
initialData={data}
|
||||
cursor={sp.cursor}
|
||||
agentId={sp.agent_id}
|
||||
checkId={sp.check_id}
|
||||
limit={limit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,17 +11,17 @@ export default async function IncidentsPage({
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
|
||||
let data;
|
||||
let items;
|
||||
let inventory;
|
||||
try {
|
||||
[data, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
|
||||
[items, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<IncidentsBrowser
|
||||
key={state ?? ""}
|
||||
initialData={data}
|
||||
initialItems={items}
|
||||
initialInventory={inventory}
|
||||
state={state}
|
||||
/>
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { OutboxBrowser } from "@/components/OutboxBrowser";
|
||||
import { api } from "@/lib/api";
|
||||
import { loadOutboxAll } from "@/lib/loaders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OutboxPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ cursor?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
let data;
|
||||
export default async function OutboxPage() {
|
||||
let items;
|
||||
try {
|
||||
data = await api.listOutbox({ cursor: sp.cursor });
|
||||
items = await loadOutboxAll();
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return <OutboxBrowser key={sp.cursor ?? ""} initialData={data} cursor={sp.cursor} />;
|
||||
return <OutboxBrowser initialItems={items} />;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { Td, Th } from "@/components/DataPanel";
|
||||
import { ClientPager } from "@/components/ClientPager";
|
||||
import { EmptyPanel, SummaryCell, Td, Th } from "@/components/DataPanel";
|
||||
import { DetailTabs } from "@/components/DetailTabs";
|
||||
import { LabelChips } from "@/components/Labels";
|
||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { withLivenessChecks } from "@/lib/check-groups";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
clampPage,
|
||||
pageCountOf,
|
||||
paginate,
|
||||
type PageSize,
|
||||
} from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
|
||||
type Agent = components["schemas"]["Agent"];
|
||||
type CheckState = components["schemas"]["CheckState"];
|
||||
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
|
||||
type SortKey = "severity" | "check_id" | "last_seen";
|
||||
type TabKey = "checks" | "events";
|
||||
type AgentDetailData = {
|
||||
agent: Agent;
|
||||
checks: CheckState[];
|
||||
events: StoredEvent[];
|
||||
};
|
||||
type AgentDetailData = { agent: Agent; checks: CheckState[] };
|
||||
type AgentPollPayload = AgentDetailData & { events?: StoredEvent[] };
|
||||
|
||||
const severityRank: Record<CheckState["status"], number> = {
|
||||
critical: 0,
|
||||
@@ -33,22 +42,24 @@ const severityRank: Record<CheckState["status"], number> = {
|
||||
|
||||
export function AgentDetailBrowser({
|
||||
initialData,
|
||||
initialEvents,
|
||||
sort,
|
||||
tab,
|
||||
eventsLimit,
|
||||
eventsCursor,
|
||||
}: {
|
||||
initialData: AgentDetailData;
|
||||
initialEvents: EventsPageData;
|
||||
sort: SortKey;
|
||||
tab: TabKey;
|
||||
eventsLimit: PageSize;
|
||||
eventsCursor?: string;
|
||||
}) {
|
||||
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],
|
||||
);
|
||||
const agentId = initialData.agent.agent_id;
|
||||
const pollUrl = `/api/poll/agent?agent_id=${encodeURIComponent(agentId)}`;
|
||||
const { data } = usePolling<AgentPollPayload>(initialData, pollUrl);
|
||||
const agent = data.agent;
|
||||
const checks = useMemo(() => sortChecks(data.checks, sort), [data.checks, sort]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -76,28 +87,32 @@ export function AgentDetailBrowser({
|
||||
<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>
|
||||
<DetailTabs
|
||||
current={tab}
|
||||
tabs={[
|
||||
{
|
||||
key: "checks",
|
||||
label: "Checks",
|
||||
href: `/agents/${encodeURIComponent(agentId)}?tab=checks&sort=${sort}`,
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
label: "Events",
|
||||
href: `/agents/${encodeURIComponent(agentId)}?tab=events&sort=${sort}&events_limit=${eventsLimit}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{tab === "checks" ? (
|
||||
<ChecksTable agentId={agentId} checks={checks} sort={sort} />
|
||||
) : (
|
||||
<EventsTable
|
||||
agentId={agentId}
|
||||
sort={sort}
|
||||
eventsLimit={eventsLimit}
|
||||
eventsCursor={eventsCursor}
|
||||
initialEvents={initialEvents}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -111,9 +126,22 @@ function ChecksTable({
|
||||
checks: CheckState[];
|
||||
sort: SortKey;
|
||||
}) {
|
||||
if (checks.length === 0) return <div className="text-sm text-neutral-500">No checks.</div>;
|
||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||
const [page, setPage] = useState(1);
|
||||
const onPageSizeChange = (next: PageSize) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
const pageCount = pageCountOf(checks.length, pageSize);
|
||||
const currentPage = clampPage(page, pageCount);
|
||||
const visible = paginate(checks, currentPage, pageSize);
|
||||
|
||||
if (checks.length === 0) return <EmptyPanel label="No checks" />;
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -136,7 +164,7 @@ function ChecksTable({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{checks.map((c) => (
|
||||
{visible.map((c) => (
|
||||
<tr key={c.check_id}>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={agentId} checkId={c.check_id} />
|
||||
@@ -145,43 +173,81 @@ function ChecksTable({
|
||||
<Td>
|
||||
<DateTime iso={c.last_observed_at} />
|
||||
</Td>
|
||||
<Td className="max-w-md truncate text-xs text-neutral-400">{c.summary ?? "—"}</Td>
|
||||
<SummaryCell text={c.summary} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsTable({ events }: { events: StoredEvent[] }) {
|
||||
if (events.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
|
||||
function EventsTable({
|
||||
agentId,
|
||||
sort,
|
||||
eventsLimit,
|
||||
eventsCursor,
|
||||
initialEvents,
|
||||
}: {
|
||||
agentId: string;
|
||||
sort: SortKey;
|
||||
eventsLimit: PageSize;
|
||||
eventsCursor?: string;
|
||||
initialEvents: EventsPageData;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pollUrl = useMemo(() => {
|
||||
const qs = new URLSearchParams({ agent_id: agentId, limit: String(eventsLimit) });
|
||||
if (eventsCursor) qs.set("cursor", eventsCursor);
|
||||
return `/api/poll/events?${qs.toString()}`;
|
||||
}, [agentId, eventsCursor, eventsLimit]);
|
||||
const { data } = usePolling(initialEvents, pollUrl);
|
||||
|
||||
const onLimitChange = (next: PageSize) => {
|
||||
const qs = new URLSearchParams({ tab: "events", sort, events_limit: String(next) });
|
||||
router.push(`/agents/${encodeURIComponent(agentId)}?${qs.toString()}`);
|
||||
};
|
||||
|
||||
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>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<PageSizeSelect value={eventsLimit} onChange={onLimitChange} />
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel label="No events" />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>observed_at</Th>
|
||||
<Th>check_id</Th>
|
||||
<Th>status</Th>
|
||||
<Th>duration_ms</Th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.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>
|
||||
)}
|
||||
<Pager
|
||||
basePath={`/agents/${encodeURIComponent(agentId)}`}
|
||||
cursor={eventsCursor}
|
||||
nextCursor={data.next_cursor}
|
||||
extra={{ tab: "events", sort, events_limit: String(eventsLimit) }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
19
web/src/components/AgentLink.tsx
Normal file
19
web/src/components/AgentLink.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import type { components } from "@/lib/api-types";
|
||||
|
||||
type Agent = components["schemas"]["Agent"];
|
||||
|
||||
export function AgentLink({ agent, agentId }: { agent?: Agent; agentId: string }) {
|
||||
const hostname = agent?.hostname || agentId;
|
||||
const showId = agent && agent.hostname && agent.hostname !== agentId;
|
||||
return (
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(agentId)}`}
|
||||
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
<span>{hostname}</span>
|
||||
{showId ? <span className="text-xs text-neutral-500">{agentId}</span> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||
import { AgentLink } from "@/components/AgentLink";
|
||||
import { ClientPager } from "@/components/ClientPager";
|
||||
import { EmptyPanel, Td, Th } from "@/components/DataPanel";
|
||||
import { LabelChips } from "@/components/Labels";
|
||||
import { ListToolbar } from "@/components/ListToolbar";
|
||||
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
clampPage,
|
||||
pageCountOf,
|
||||
paginate,
|
||||
type PageSize,
|
||||
} from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
import type { InventoryData } from "@/lib/view-types";
|
||||
|
||||
@@ -16,7 +27,6 @@ type CheckState = components["schemas"]["CheckState"];
|
||||
type CheckStatus = CheckState["status"];
|
||||
type CheckCounts = Record<CheckStatus, number>;
|
||||
type SortField = "host" | "status" | "last_seen_at";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
export type AgentRow = { agent: Agent; checks: CheckCounts };
|
||||
|
||||
@@ -36,77 +46,75 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
||||
[dir, initialRows, query, sort],
|
||||
);
|
||||
|
||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageCount = pageCountOf(rows.length, pageSize);
|
||||
const currentPage = clampPage(page, pageCount);
|
||||
const visible = paginate(rows, currentPage, pageSize);
|
||||
|
||||
const onQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
const onPageSizeChange = (next: PageSize) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
const chooseSort = (field: SortField) => {
|
||||
if (field === sort) {
|
||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
} else {
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
}
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-baseline gap-3">
|
||||
<h1 className="text-lg font-semibold">Agents</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 agent"
|
||||
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}
|
||||
<span className="text-sm text-neutral-400">
|
||||
{rows.length} of {initialRows.length} items
|
||||
</span>
|
||||
</div>
|
||||
<ListToolbar
|
||||
query={query}
|
||||
onQueryChange={onQueryChange}
|
||||
placeholder="search agent"
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 italic">No data.</div>
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>
|
||||
<SortButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
||||
HOST
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
STATUS
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="last_seen_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="last_seen_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
LAST_SEEN_AT
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>features</Th>
|
||||
<Th>labels</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ agent: a, checks }) => (
|
||||
{visible.map(({ agent: a, checks }) => (
|
||||
<tr key={a.agent_id}>
|
||||
<Td>
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(a.agent_id)}`}
|
||||
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
<span>{a.hostname}</span>
|
||||
{a.agent_id !== a.hostname ? (
|
||||
<span className="text-xs text-neutral-500">{a.agent_id}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<AgentLink agent={a} agentId={a.agent_id} />
|
||||
</Td>
|
||||
<Td>
|
||||
<AgentStatus status={a.status} checks={checks} />
|
||||
@@ -125,50 +133,11 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="text-left text-xs font-semibold uppercase tracking-wide text-neutral-400 border-b border-neutral-800 px-2 py-2">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<td className={`px-2 py-1.5 border-b border-neutral-900 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 filterAgents(rows: AgentRow[], query: string): AgentRow[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return rows;
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AgentLink } from "@/components/AgentLink";
|
||||
import { CheckStatusSummary } from "@/components/CheckStatusSummary";
|
||||
import { Td, Th } from "@/components/DataPanel";
|
||||
import { ClientPager } from "@/components/ClientPager";
|
||||
import { EmptyPanel, SummaryCell, Td, Th } from "@/components/DataPanel";
|
||||
import { DetailTabs } from "@/components/DetailTabs";
|
||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||
import { Pager } from "@/components/Pager";
|
||||
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 { groupChecks, statusSeverity, type Agent, type CheckGroup } from "@/lib/check-groups";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
clampPage,
|
||||
pageCountOf,
|
||||
paginate,
|
||||
type PageSize,
|
||||
} from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
import type { InventoryData } from "@/lib/view-types";
|
||||
|
||||
@@ -27,21 +34,24 @@ export function CheckDetailBrowser({
|
||||
initialData,
|
||||
initialEvents,
|
||||
tab,
|
||||
eventsLimit,
|
||||
eventsCursor,
|
||||
}: {
|
||||
checkId: string;
|
||||
initialData: InventoryData;
|
||||
initialEvents: EventsPageData;
|
||||
tab: TabKey;
|
||||
eventsLimit: PageSize;
|
||||
eventsCursor?: string;
|
||||
}) {
|
||||
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 agentsById = useMemo(
|
||||
() => new Map(data.agents.map((agent) => [agent.agent_id, agent])),
|
||||
[data.agents],
|
||||
);
|
||||
const group = useMemo(
|
||||
() => groupChecks(checks, agentsById).find((item) => item.checkId === checkId),
|
||||
[agentsById, checkId, checks],
|
||||
() => groupChecks(data.checks, agentsById).find((item) => item.checkId === checkId),
|
||||
[agentsById, checkId, data.checks],
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
@@ -76,76 +86,88 @@ export function CheckDetailBrowser({
|
||||
<DateTime iso={group.lastObservedAt} />
|
||||
</dd>
|
||||
</dl>
|
||||
<TabNav checkId={checkId} current={tab} />
|
||||
<DetailTabs
|
||||
current={tab}
|
||||
tabs={[
|
||||
{
|
||||
key: "agents",
|
||||
label: "Agents",
|
||||
href: `/checks/${encodeURIComponent(checkId)}?tab=agents`,
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
label: "Events",
|
||||
href: `/checks/${encodeURIComponent(checkId)}?tab=events&events_limit=${eventsLimit}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{tab === "agents" ? (
|
||||
<AgentsForCheck group={group} />
|
||||
) : (
|
||||
<EventsForCheck checkId={checkId} initialData={initialEvents} agentsById={agentsById} />
|
||||
<EventsForCheck
|
||||
checkId={checkId}
|
||||
initialData={initialEvents}
|
||||
agentsById={agentsById}
|
||||
eventsLimit={eventsLimit}
|
||||
eventsCursor={eventsCursor}
|
||||
/>
|
||||
)}
|
||||
</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)),
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
[...group.checks].sort(
|
||||
(a, b) =>
|
||||
statusSeverity[b.check.status] - statusSeverity[a.check.status] ||
|
||||
hostName(a).localeCompare(hostName(b)),
|
||||
),
|
||||
[group.checks],
|
||||
);
|
||||
|
||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||
const [page, setPage] = useState(1);
|
||||
const onPageSizeChange = (next: PageSize) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
const pageCount = pageCountOf(rows.length, pageSize);
|
||||
const currentPage = clampPage(page, pageCount);
|
||||
const visible = paginate(rows, currentPage, pageSize);
|
||||
|
||||
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>
|
||||
<section>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>HOST</Th>
|
||||
<Th>STATUS</Th>
|
||||
<Th>LAST_OBSERVED_AT</Th>
|
||||
<Th>SUMMARY</Th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map(({ check, agent }) => (
|
||||
<tr key={check.agent_id}>
|
||||
<Td>
|
||||
<AgentLink agent={agent} agentId={check.agent_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(check.status)}>{check.status}</Td>
|
||||
<Td>
|
||||
<DateTime iso={check.last_observed_at} />
|
||||
</Td>
|
||||
<SummaryCell text={check.summary} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,57 +175,81 @@ function EventsForCheck({
|
||||
checkId,
|
||||
initialData,
|
||||
agentsById,
|
||||
eventsLimit,
|
||||
eventsCursor,
|
||||
}: {
|
||||
checkId: string;
|
||||
initialData: EventsPageData;
|
||||
agentsById: Map<string, Agent>;
|
||||
eventsLimit: PageSize;
|
||||
eventsCursor?: string;
|
||||
}) {
|
||||
const { data } = usePolling(
|
||||
initialData,
|
||||
`/api/poll/events?check_id=${encodeURIComponent(checkId)}&limit=50`,
|
||||
);
|
||||
const router = useRouter();
|
||||
const pollUrl = useMemo(() => {
|
||||
const qs = new URLSearchParams({
|
||||
check_id: checkId,
|
||||
limit: String(eventsLimit),
|
||||
});
|
||||
if (eventsCursor) qs.set("cursor", eventsCursor);
|
||||
return `/api/poll/events?${qs.toString()}`;
|
||||
}, [checkId, eventsCursor, eventsLimit]);
|
||||
const { data } = usePolling(initialData, pollUrl);
|
||||
|
||||
if (data.items.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
|
||||
const onLimitChange = (next: PageSize) => {
|
||||
router.push(
|
||||
`/checks/${encodeURIComponent(checkId)}?tab=events&events_limit=${next}`,
|
||||
);
|
||||
};
|
||||
|
||||
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>
|
||||
<section>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<PageSizeSelect value={eventsLimit} onChange={onLimitChange} />
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel label="No events" />
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
<AgentLink agent={agent} agentId={event.agent_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(event.status)}>{event.status}</Td>
|
||||
<Td>{event.duration_ms}</Td>
|
||||
<SummaryCell text={event.output} />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager
|
||||
basePath={`/checks/${encodeURIComponent(checkId)}`}
|
||||
cursor={eventsCursor}
|
||||
nextCursor={data.next_cursor}
|
||||
extra={{ tab: "events", events_limit: String(eventsLimit) }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,17 +4,27 @@ import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { CheckStatusSummary } from "@/components/CheckStatusSummary";
|
||||
import { ClientPager } from "@/components/ClientPager";
|
||||
import { EmptyPanel, Td, Th } from "@/components/DataPanel";
|
||||
import { ListToolbar } from "@/components/ListToolbar";
|
||||
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import {
|
||||
compareStatusCounts,
|
||||
groupChecks,
|
||||
type CheckGroup,
|
||||
} from "@/lib/check-groups";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
clampPage,
|
||||
pageCountOf,
|
||||
paginate,
|
||||
type PageSize,
|
||||
} from "@/lib/pagination";
|
||||
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");
|
||||
@@ -29,65 +39,71 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
|
||||
[dir, groups, query, sort],
|
||||
);
|
||||
|
||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageCount = pageCountOf(rows.length, pageSize);
|
||||
const currentPage = clampPage(page, pageCount);
|
||||
const visible = paginate(rows, currentPage, pageSize);
|
||||
|
||||
const onQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
const onPageSizeChange = (next: PageSize) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
const chooseSort = (field: SortField) => {
|
||||
if (field === sort) {
|
||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
} else {
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
}
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
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}
|
||||
<span className="text-sm text-neutral-400">
|
||||
{rows.length} of {groups.length} items
|
||||
</span>
|
||||
</div>
|
||||
<ListToolbar
|
||||
query={query}
|
||||
onQueryChange={onQueryChange}
|
||||
placeholder="search check"
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 italic">No data.</div>
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>
|
||||
<SortButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
||||
CHECK
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
STATUS
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="last_observed_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="last_observed_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
LAST_OBSERVED_AT
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>AGENTS</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
{visible.map((row) => (
|
||||
<tr key={row.checkId}>
|
||||
<Td>
|
||||
<Link
|
||||
@@ -109,48 +125,11 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
39
web/src/components/ClientPager.tsx
Normal file
39
web/src/components/ClientPager.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
export function ClientPager({
|
||||
page,
|
||||
pageCount,
|
||||
onPageChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
const prev = () => onPageChange(Math.max(1, page - 1));
|
||||
const next = () => onPageChange(Math.min(pageCount, page + 1));
|
||||
const disabledPrev = page <= 1;
|
||||
const disabledNext = page >= pageCount;
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
disabled={disabledPrev}
|
||||
className="text-blue-300 hover:text-blue-200 disabled:cursor-default disabled:text-neutral-600"
|
||||
>
|
||||
← prev
|
||||
</button>
|
||||
<span className="text-neutral-500">
|
||||
page {page} of {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
disabled={disabledNext}
|
||||
className="text-blue-300 hover:text-blue-200 disabled:cursor-default disabled:text-neutral-600"
|
||||
>
|
||||
next →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,3 +39,7 @@ export function Td({ children, className = "" }: { children: React.ReactNode; cl
|
||||
<td className={`px-2 py-1.5 border-b border-neutral-900 align-top ${className}`}>{children}</td>
|
||||
);
|
||||
}
|
||||
|
||||
export function SummaryCell({ text }: { text?: string | null }) {
|
||||
return <Td className="max-w-md truncate text-xs text-neutral-400">{text ?? "—"}</Td>;
|
||||
}
|
||||
|
||||
23
web/src/components/DetailTabs.tsx
Normal file
23
web/src/components/DetailTabs.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export type DetailTab = { key: string; label: string; href: string };
|
||||
|
||||
export function DetailTabs({ tabs, current }: { tabs: DetailTab[]; current: string }) {
|
||||
return (
|
||||
<div className="flex gap-4 border-b border-neutral-800 text-sm">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.key}
|
||||
href={tab.href}
|
||||
className={`pb-2 ${
|
||||
current === tab.key
|
||||
? "border-b border-neutral-200 text-neutral-100"
|
||||
: "text-neutral-400 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
|
||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import { type PageSize } from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
|
||||
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||
@@ -19,19 +22,32 @@ export function EventsBrowser({
|
||||
cursor,
|
||||
agentId,
|
||||
checkId,
|
||||
limit,
|
||||
}: {
|
||||
initialData: EventsPageData;
|
||||
cursor?: string;
|
||||
agentId?: string;
|
||||
checkId?: string;
|
||||
limit: PageSize;
|
||||
}) {
|
||||
const url = useMemo(() => eventsPollUrl({ cursor, agentId, checkId }), [agentId, checkId, cursor]);
|
||||
const router = useRouter();
|
||||
const url = useMemo(
|
||||
() => eventsPollUrl({ cursor, agentId, checkId, limit }),
|
||||
[agentId, checkId, cursor, limit],
|
||||
);
|
||||
const { data } = usePolling(initialData, url);
|
||||
|
||||
const onLimitChange = (next: PageSize) => {
|
||||
router.push(eventsHref({ agent_id: agentId, check_id: checkId, limit: next }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Events" count={data.items.length} />
|
||||
<ActiveFilters agentId={agentId} checkId={checkId} />
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<ActiveFilters agentId={agentId} checkId={checkId} limit={limit} />
|
||||
<PageSizeSelect value={limit} onChange={onLimitChange} />
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
@@ -62,7 +78,7 @@ export function EventsBrowser({
|
||||
</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>
|
||||
<SummaryCell text={e.output} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -72,24 +88,43 @@ export function EventsBrowser({
|
||||
basePath="/events"
|
||||
cursor={cursor}
|
||||
nextCursor={data.next_cursor}
|
||||
extra={{ agent_id: agentId, check_id: checkId }}
|
||||
extra={{ agent_id: agentId, check_id: checkId, limit: String(limit) }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveFilters({ agentId, checkId }: { agentId?: string; checkId?: string }) {
|
||||
if (!agentId && !checkId) return null;
|
||||
function ActiveFilters({
|
||||
agentId,
|
||||
checkId,
|
||||
limit,
|
||||
}: {
|
||||
agentId?: string;
|
||||
checkId?: string;
|
||||
limit: PageSize;
|
||||
}) {
|
||||
if (!agentId && !checkId) return <div />;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{agentId ? (
|
||||
<FilterChip label="agent_id" value={agentId} href={eventsHref({ check_id: checkId })} />
|
||||
<FilterChip
|
||||
label="agent_id"
|
||||
value={agentId}
|
||||
href={eventsHref({ check_id: checkId, limit })}
|
||||
/>
|
||||
) : null}
|
||||
{checkId ? (
|
||||
<FilterChip label="check_id" value={checkId} href={eventsHref({ agent_id: agentId })} />
|
||||
<FilterChip
|
||||
label="check_id"
|
||||
value={checkId}
|
||||
href={eventsHref({ agent_id: agentId, limit })}
|
||||
/>
|
||||
) : null}
|
||||
<Link href="/events" className="text-neutral-500 hover:text-neutral-300">
|
||||
<Link
|
||||
href={eventsHref({ limit })}
|
||||
className="text-neutral-500 hover:text-neutral-300"
|
||||
>
|
||||
clear
|
||||
</Link>
|
||||
</div>
|
||||
@@ -110,10 +145,15 @@ function FilterChip({ label, value, href }: { label: string; value: string; href
|
||||
);
|
||||
}
|
||||
|
||||
function eventsHref(params: { agent_id?: string; check_id?: string }): string {
|
||||
function eventsHref(params: {
|
||||
agent_id?: string;
|
||||
check_id?: string;
|
||||
limit?: PageSize;
|
||||
}): 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);
|
||||
if (params.limit) qs.set("limit", String(params.limit));
|
||||
const query = qs.toString();
|
||||
return query ? `/events?${query}` : "/events";
|
||||
}
|
||||
@@ -122,15 +162,17 @@ function eventsPollUrl({
|
||||
cursor,
|
||||
agentId,
|
||||
checkId,
|
||||
limit,
|
||||
}: {
|
||||
cursor?: string;
|
||||
agentId?: string;
|
||||
checkId?: string;
|
||||
limit: PageSize;
|
||||
}) {
|
||||
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";
|
||||
qs.set("limit", String(limit));
|
||||
return `/api/poll/events?${qs.toString()}`;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,22 @@
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AgentLink } from "@/components/AgentLink";
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { ClientPager } from "@/components/ClientPager";
|
||||
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
|
||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
clampPage,
|
||||
pageCountOf,
|
||||
paginate,
|
||||
type PageSize,
|
||||
} from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
import type { InventoryData } from "@/lib/view-types";
|
||||
|
||||
@@ -15,104 +26,130 @@ const STATES = ["all", "open", "resolved"] as const;
|
||||
|
||||
type Incident = components["schemas"]["Incident"];
|
||||
type Agent = components["schemas"]["Agent"];
|
||||
type IncidentsPageData = { items: Incident[] };
|
||||
type IncidentsData = { 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 };
|
||||
const stateRank: Record<Incident["state"], number> = { open: 0, resolved: 1 };
|
||||
const severityRank: Record<Incident["severity"], number> = {
|
||||
critical: 0,
|
||||
warning: 1,
|
||||
unknown: 2,
|
||||
};
|
||||
|
||||
export function IncidentsBrowser({
|
||||
initialData,
|
||||
initialItems,
|
||||
initialInventory,
|
||||
state,
|
||||
}: {
|
||||
initialData: IncidentsPageData;
|
||||
initialItems: Incident[];
|
||||
initialInventory: InventoryData;
|
||||
state?: "open" | "resolved";
|
||||
}) {
|
||||
const url = useMemo(() => incidentsPollUrl({ state }), [state]);
|
||||
const { data } = usePolling(initialData, url);
|
||||
const url = useMemo(
|
||||
() => (state ? `/api/poll/incidents?state=${state}` : "/api/poll/incidents"),
|
||||
[state],
|
||||
);
|
||||
const { data } = usePolling<IncidentsData>({ items: initialItems }, 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(
|
||||
|
||||
const [sort, setSort] = useState<SortField>("status");
|
||||
const [dir, setDir] = useState<SortDir>("asc");
|
||||
const sorted = useMemo(
|
||||
() => sortIncidents(data.items, agentsById, sort, dir),
|
||||
[agentsById, data.items, dir, sort],
|
||||
);
|
||||
|
||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||
const [page, setPage] = useState(1);
|
||||
const onPageSizeChange = (next: PageSize) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
const chooseSort = (field: SortField) => {
|
||||
if (field === sort) {
|
||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
} else {
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
}
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const pageCount = pageCountOf(sorted.length, pageSize);
|
||||
const currentPage = clampPage(page, pageCount);
|
||||
const visible = paginate(sorted, currentPage, pageSize);
|
||||
|
||||
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>
|
||||
);
|
||||
})}
|
||||
<PageHeader title="Incidents" count={sorted.length} />
|
||||
<div className="mb-3 flex items-center justify-between gap-3 text-sm">
|
||||
<div className="flex gap-3">
|
||||
{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>
|
||||
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>
|
||||
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
STATUS
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
||||
HOST
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
||||
CHECK
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="opened_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="opened_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
OPENED_AT
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="resolved_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="resolved_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
RESOLVED_AT
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>SUMMARY</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ incident, agent }) => (
|
||||
<IncidentRow key={incident.id} incident={incident} agent={agent} />
|
||||
{visible.map((incident) => (
|
||||
<IncidentRow
|
||||
key={incident.id}
|
||||
incident={incident}
|
||||
agent={agentsById.get(incidentAgentId(incident))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -128,21 +165,7 @@ function IncidentRow({ incident, agent }: { incident: Incident; agent?: Agent })
|
||||
<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>{agentId ? <AgentLink agent={agent} agentId={agentId} /> : "—"}</Td>
|
||||
<Td>
|
||||
<IncidentCheckLink agentId={agentId} checkId={checkId} />
|
||||
</Td>
|
||||
@@ -152,7 +175,7 @@ function IncidentRow({ incident, agent }: { incident: Incident; agent?: Agent })
|
||||
<Td>
|
||||
<DateTime iso={incident.resolved_at} />
|
||||
</Td>
|
||||
<Td className="max-w-md truncate text-xs text-neutral-400">{incident.summary ?? "—"}</Td>
|
||||
<SummaryCell text={incident.summary} />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -172,97 +195,6 @@ function IncidentCheckLink({ agentId, checkId }: { agentId: string; checkId: str
|
||||
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 ?? "";
|
||||
}
|
||||
@@ -271,6 +203,56 @@ 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";
|
||||
function fieldDefaultDir(field: SortField): SortDir {
|
||||
switch (field) {
|
||||
case "status":
|
||||
return "asc";
|
||||
case "host":
|
||||
case "check":
|
||||
return "asc";
|
||||
case "opened_at":
|
||||
case "resolved_at":
|
||||
return "desc";
|
||||
}
|
||||
}
|
||||
|
||||
function sortIncidents(
|
||||
items: Incident[],
|
||||
agentsById: Map<string, Agent>,
|
||||
sort: SortField,
|
||||
dir: SortDir,
|
||||
): Incident[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const primary = compareIncidents(a, b, agentsById, sort);
|
||||
if (primary !== 0) return dir === "asc" ? primary : -primary;
|
||||
return (b.opened_at ?? "").localeCompare(a.opened_at ?? "");
|
||||
});
|
||||
}
|
||||
|
||||
function compareIncidents(
|
||||
a: Incident,
|
||||
b: Incident,
|
||||
agentsById: Map<string, Agent>,
|
||||
sort: SortField,
|
||||
): number {
|
||||
switch (sort) {
|
||||
case "status": {
|
||||
const s = stateRank[a.state] - stateRank[b.state];
|
||||
if (s !== 0) return s;
|
||||
return severityRank[a.severity] - severityRank[b.severity];
|
||||
}
|
||||
case "host":
|
||||
return hostFor(a, agentsById).localeCompare(hostFor(b, agentsById));
|
||||
case "check":
|
||||
return incidentCheckId(a).localeCompare(incidentCheckId(b));
|
||||
case "opened_at":
|
||||
return (a.opened_at ?? "").localeCompare(b.opened_at ?? "");
|
||||
case "resolved_at":
|
||||
return (a.resolved_at ?? "").localeCompare(b.resolved_at ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
function hostFor(incident: Incident, agentsById: Map<string, Agent>): string {
|
||||
const id = incidentAgentId(incident);
|
||||
return agentsById.get(id)?.hostname || id;
|
||||
}
|
||||
|
||||
42
web/src/components/ListToolbar.tsx
Normal file
42
web/src/components/ListToolbar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||
import { type PageSize } from "@/lib/pagination";
|
||||
|
||||
export function ListToolbar({
|
||||
query,
|
||||
onQueryChange,
|
||||
placeholder,
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
}: {
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
pageSize: PageSize;
|
||||
onPageSizeChange: (next: PageSize) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex max-w-md flex-1 gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
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={() => onQueryChange("")}
|
||||
className="border border-neutral-900 px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-300"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ClientPager } from "@/components/ClientPager";
|
||||
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
clampPage,
|
||||
pageCountOf,
|
||||
paginate,
|
||||
type PageSize,
|
||||
} from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
|
||||
type OutboxItem = components["schemas"]["NotificationOutboxItem"];
|
||||
type OutboxPageData = { items: OutboxItem[]; next_cursor?: string | null };
|
||||
type OutboxData = { items: OutboxItem[] };
|
||||
|
||||
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);
|
||||
export function OutboxBrowser({ initialItems }: { initialItems: OutboxItem[] }) {
|
||||
const { data } = usePolling<OutboxData>({ items: initialItems }, "/api/poll/outbox");
|
||||
|
||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||
const [page, setPage] = useState(1);
|
||||
const onPageSizeChange = (next: PageSize) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const pageCount = pageCountOf(data.items.length, pageSize);
|
||||
const currentPage = clampPage(page, pageCount);
|
||||
const visible = paginate(data.items, currentPage, pageSize);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Notification outbox" count={data.items.length} />
|
||||
<div className="mb-3 flex justify-end">
|
||||
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
@@ -44,7 +56,7 @@ export function OutboxBrowser({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((o) => (
|
||||
{visible.map((o) => (
|
||||
<tr key={o.id}>
|
||||
<Td>
|
||||
<DateTime iso={o.updated_at} />
|
||||
@@ -62,7 +74,7 @@ export function OutboxBrowser({
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager basePath="/outbox" cursor={cursor} nextCursor={data.next_cursor} />
|
||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
30
web/src/components/PageSizeSelect.tsx
Normal file
30
web/src/components/PageSizeSelect.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { PAGE_SIZE_OPTIONS, type PageSize } from "@/lib/pagination";
|
||||
|
||||
export function PageSizeSelect({
|
||||
value,
|
||||
onChange,
|
||||
options = PAGE_SIZE_OPTIONS,
|
||||
}: {
|
||||
value: PageSize;
|
||||
onChange: (size: PageSize) => void;
|
||||
options?: readonly number[];
|
||||
}) {
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2 text-xs text-neutral-400">
|
||||
<span>per page</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value) as PageSize)}
|
||||
className="border border-neutral-800 bg-neutral-950 px-2 py-1 text-sm text-neutral-100 outline-none focus:border-neutral-500"
|
||||
>
|
||||
{options.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
31
web/src/components/SortHeaderButton.tsx
Normal file
31
web/src/components/SortHeaderButton.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
export function SortHeaderButton<F extends string>({
|
||||
field,
|
||||
current,
|
||||
dir,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
field: F;
|
||||
current: F;
|
||||
dir: SortDir;
|
||||
onClick: (field: F) => void;
|
||||
children: 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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ 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>;
|
||||
|
||||
@@ -44,67 +43,6 @@ export function groupChecks(checks: CheckState[], agentsById: Map<string, Agent>
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { ApiError, api, type Agent, type CheckState, type StoredEvent } from "@/lib/api";
|
||||
import { api, type Agent, type CheckState, type OutboxItem, 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"];
|
||||
@@ -31,10 +30,10 @@ export async function loadChecks(agentId?: string): Promise<CheckState[]> {
|
||||
|
||||
export async function loadInventory(): Promise<InventoryData> {
|
||||
const [agents, checks] = await Promise.all([loadAgents(), loadChecks()]);
|
||||
return { agents, checks: withLivenessChecks(agents, checks) };
|
||||
return { agents, checks };
|
||||
}
|
||||
|
||||
export async function loadEventsWithLiveness({
|
||||
export async function loadEvents({
|
||||
agentId,
|
||||
checkId,
|
||||
cursor,
|
||||
@@ -45,28 +44,10 @@ export async function loadEventsWithLiveness({
|
||||
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 }),
|
||||
};
|
||||
return api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, limit });
|
||||
}
|
||||
|
||||
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[] }> {
|
||||
export async function loadIncidents(state?: "open" | "resolved"): Promise<Incident[]> {
|
||||
const items: Incident[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
@@ -74,25 +55,46 @@ export async function loadIncidents(
|
||||
items.push(...page.items);
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return { items };
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function loadOutboxAll(): Promise<OutboxItem[]> {
|
||||
const items: OutboxItem[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listOutbox({ 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([
|
||||
const [agents, checks, openIncidents] = await Promise.all([
|
||||
loadAgents(),
|
||||
loadChecks(),
|
||||
api.listIncidents({ state: "open", limit: 500 }),
|
||||
countOpenIncidents(),
|
||||
]);
|
||||
const checksWithLiveness = withLivenessChecks(agents, checks);
|
||||
return {
|
||||
agents: tally(agents),
|
||||
checks: tally(checksWithLiveness),
|
||||
openIncidents: incidents.items.length,
|
||||
checks: tally(checks),
|
||||
openIncidents,
|
||||
totalAgents: agents.length,
|
||||
totalChecks: checksWithLiveness.length,
|
||||
totalChecks: checks.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function countOpenIncidents(): Promise<number> {
|
||||
let total = 0;
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listIncidents({ state: "open", cursor, limit: 500 });
|
||||
total += page.items.length;
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return total;
|
||||
}
|
||||
|
||||
function tally(xs: { status?: string }[]): Record<string, number> {
|
||||
return xs.reduce<Record<string, number>>((acc, x) => {
|
||||
const k = x.status ?? "unknown";
|
||||
|
||||
23
web/src/lib/pagination.ts
Normal file
23
web/src/lib/pagination.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const PAGE_SIZE_OPTIONS = [10, 50, 100] as const;
|
||||
export type PageSize = (typeof PAGE_SIZE_OPTIONS)[number];
|
||||
export const DEFAULT_PAGE_SIZE: PageSize = 10;
|
||||
|
||||
export function pageCountOf(total: number, pageSize: number): number {
|
||||
return Math.max(1, Math.ceil(total / pageSize));
|
||||
}
|
||||
|
||||
export function clampPage(page: number, pageCount: number): number {
|
||||
if (page < 1) return 1;
|
||||
if (page > pageCount) return pageCount;
|
||||
return page;
|
||||
}
|
||||
|
||||
export function paginate<T>(items: T[], page: number, pageSize: number): T[] {
|
||||
const start = (page - 1) * pageSize;
|
||||
return items.slice(start, start + pageSize);
|
||||
}
|
||||
|
||||
export function normalizePageSize(value: unknown, fallback: PageSize = DEFAULT_PAGE_SIZE): PageSize {
|
||||
const n = Number(value);
|
||||
return (PAGE_SIZE_OPTIONS as readonly number[]).includes(n) ? (n as PageSize) : fallback;
|
||||
}
|
||||
@@ -55,6 +55,24 @@ const fixtures = {
|
||||
incident_key: "agent-1:load",
|
||||
summary: "load high",
|
||||
},
|
||||
{
|
||||
agent_id: "agent-1",
|
||||
check_id: "agent_liveness",
|
||||
status: "ok",
|
||||
exit_code: 0,
|
||||
last_observed_at: now,
|
||||
incident_key: "agent:agent-1:liveness",
|
||||
summary: "agent is alive",
|
||||
},
|
||||
{
|
||||
agent_id: "agent-2",
|
||||
check_id: "agent_liveness",
|
||||
status: "critical",
|
||||
exit_code: 2,
|
||||
last_observed_at: now,
|
||||
incident_key: "agent:agent-2:liveness",
|
||||
summary: "agent is dead",
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
},
|
||||
@@ -122,6 +140,19 @@ const fixtures = {
|
||||
output_truncated: false,
|
||||
notifications_enabled: true,
|
||||
},
|
||||
{
|
||||
event_id: "00000000-0000-7000-8000-000000000003",
|
||||
agent_id: "agent-2",
|
||||
check_id: "agent_liveness",
|
||||
observed_at: now,
|
||||
received_at: now,
|
||||
status: "critical",
|
||||
exit_code: 2,
|
||||
duration_ms: 0,
|
||||
output: "agent is dead",
|
||||
output_truncated: false,
|
||||
notifications_enabled: true,
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
},
|
||||
|
||||
@@ -17,7 +17,7 @@ test("agents list links to detail", async ({ page }) => {
|
||||
await expect(page.locator("thead")).not.toContainText("asc");
|
||||
await expect(page.locator("thead")).not.toContainText("desc");
|
||||
await expect(page.locator("thead")).toContainText("↓");
|
||||
await expect(page.locator("body")).toContainText("checks 2");
|
||||
await expect(page.locator("body")).toContainText("checks 3");
|
||||
await expect(page.locator("body")).toContainText("warn 1");
|
||||
await expect(page.getByRole("button", { name: /Timezone: default/ })).toBeVisible();
|
||||
await page.getByRole("button", { name: /Timezone: default/ }).click();
|
||||
@@ -73,7 +73,7 @@ test("checks page lists rows", async ({ page }) => {
|
||||
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).toHaveURL(/\/checks\/disk\?tab=events(?:&events_limit=\d+)?$/);
|
||||
await expect(page.locator("body")).toContainText("12");
|
||||
await expect(page.locator("thead").last()).not.toContainText("exit");
|
||||
});
|
||||
@@ -83,7 +83,6 @@ test("incidents filter renders", async ({ page }) => {
|
||||
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");
|
||||
@@ -91,10 +90,7 @@ test("incidents filter renders", async ({ page }) => {
|
||||
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 page.getByRole("link", { name: "host-1" }).first().click();
|
||||
await expect(page).toHaveURL(/\/agents\/agent-1$/);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user