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_STALE_AFTER_SEC: "20"
|
||||||
MONLET_DEAD_AFTER_SEC: "40"
|
MONLET_DEAD_AFTER_SEC: "40"
|
||||||
MONLET_DETECTOR_TICK_SEC: "5"
|
MONLET_DETECTOR_TICK_SEC: "5"
|
||||||
MONLET_EVENTS_RETENTION_MAX_ROWS: "5000"
|
MONLET_EVENTS_RETENTION_MONTHS: "1"
|
||||||
MONLET_OUTBOX_RETENTION_MAX_ROWS: "1000"
|
MONLET_OUTBOX_RETENTION_MAX_ROWS: "1000"
|
||||||
MONLET_NOTIFIER_DEBUG_ENABLED: "true"
|
MONLET_NOTIFIER_DEBUG_ENABLED: "true"
|
||||||
MONLET_NOTIFIER_WEBHOOK_ENABLED: "true"
|
MONLET_NOTIFIER_WEBHOOK_ENABLED: "true"
|
||||||
@@ -56,6 +56,32 @@ services:
|
|||||||
agent-mixed:
|
agent-mixed:
|
||||||
condition: service_started
|
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:
|
agent-prometheus-owned:
|
||||||
image: monlet-showcase-agent
|
image: monlet-showcase-agent
|
||||||
environment:
|
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_DEAD_AFTER_SEC=300
|
||||||
MONLET_DETECTOR_TICK_SEC=15
|
MONLET_DETECTOR_TICK_SEC=15
|
||||||
MONLET_ENABLE_DETECTOR=true
|
MONLET_ENABLE_DETECTOR=true
|
||||||
MONLET_EVENTS_RETENTION_MAX_ROWS=100000
|
MONLET_EVENTS_RETENTION_MONTHS=36
|
||||||
MONLET_OUTBOX_RETENTION_MAX_ROWS=50000
|
MONLET_OUTBOX_RETENTION_MAX_ROWS=50000
|
||||||
|
|
||||||
MONLET_ENABLE_NOTIFIER_WORKER=true
|
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_DEAD_AFTER_SEC` | `300` | Dead threshold |
|
||||||
| `MONLET_DETECTOR_TICK_SEC` | `15` | Detector tick |
|
| `MONLET_DETECTOR_TICK_SEC` | `15` | Detector tick |
|
||||||
| `MONLET_ENABLE_DETECTOR` | `true` | Toggle detector task |
|
| `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_OUTBOX_RETENTION_MAX_ROWS` | `50000` | Maximum retained terminal outbox rows; `0` disables pruning |
|
||||||
| `MONLET_ENABLE_NOTIFIER_WORKER` | `true` | Toggle outbox worker |
|
| `MONLET_ENABLE_NOTIFIER_WORKER` | `true` | Toggle outbox worker |
|
||||||
| `MONLET_NOTIFIER_TICK_SEC` | `5` | Worker poll interval |
|
| `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);")
|
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(
|
op.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE events (
|
CREATE TABLE events (
|
||||||
event_id UUID PRIMARY KEY,
|
event_id UUID NOT NULL,
|
||||||
agent_id TEXT NOT NULL,
|
agent_id TEXT NOT NULL,
|
||||||
check_id TEXT NOT NULL,
|
check_id TEXT NOT NULL,
|
||||||
observed_at TIMESTAMPTZ NOT NULL,
|
observed_at TIMESTAMPTZ NOT NULL,
|
||||||
@@ -64,14 +66,30 @@ def upgrade() -> None:
|
|||||||
output TEXT,
|
output TEXT,
|
||||||
output_truncated BOOLEAN NOT NULL DEFAULT FALSE,
|
output_truncated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
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(
|
op.execute(
|
||||||
"CREATE INDEX events_agent_check_observed_idx ON events (agent_id, check_id, observed_at DESC);"
|
"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_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(
|
op.execute(
|
||||||
"""
|
"""
|
||||||
@@ -119,6 +137,7 @@ def upgrade() -> None:
|
|||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
op.execute("DROP TABLE IF EXISTS notification_outbox CASCADE;")
|
op.execute("DROP TABLE IF EXISTS notification_outbox CASCADE;")
|
||||||
op.execute("DROP TABLE IF EXISTS incidents 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 events CASCADE;")
|
||||||
op.execute("DROP TABLE IF EXISTS checks CASCADE;")
|
op.execute("DROP TABLE IF EXISTS checks CASCADE;")
|
||||||
op.execute("DROP TABLE IF EXISTS agents 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 .middleware.request_id import RequestIdMiddleware
|
||||||
from .services.detector import run_detector
|
from .services.detector import run_detector
|
||||||
from .services.notifier_worker import run_notifier_worker
|
from .services.notifier_worker import run_notifier_worker
|
||||||
|
from .services.partitioning import ensure_event_partitions, run_partition_maintenance
|
||||||
from .settings import get_settings
|
from .settings import get_settings
|
||||||
|
|
||||||
log = get_logger("monlet.main")
|
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
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
configure_logging(s.log_level)
|
configure_logging(s.log_level)
|
||||||
get_engine()
|
engine = get_engine()
|
||||||
sm = get_sessionmaker()
|
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()
|
stop_event = asyncio.Event()
|
||||||
tasks: list[asyncio.Task] = []
|
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:
|
if s.enable_detector:
|
||||||
tasks.append(asyncio.create_task(run_detector(sm, stop_event), name="detector"))
|
tasks.append(asyncio.create_task(run_detector(sm, stop_event), name="detector"))
|
||||||
if s.enable_notifier_worker:
|
if s.enable_notifier_worker:
|
||||||
|
|||||||
@@ -85,10 +85,11 @@ class Check(Base):
|
|||||||
class Event(Base):
|
class Event(Base):
|
||||||
__tablename__ = "events"
|
__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)
|
agent_id: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
check_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)
|
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(
|
received_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
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)
|
incident_key: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
PrimaryKeyConstraint("event_id", "received_at", name="events_pkey"),
|
||||||
CheckConstraint(
|
CheckConstraint(
|
||||||
"status IN ('ok','warning','critical','unknown')", name="events_status_chk"
|
"status IN ('ok','warning','critical','unknown')", name="events_status_chk"
|
||||||
),
|
),
|
||||||
@@ -114,9 +116,21 @@ class Event(Base):
|
|||||||
text("observed_at DESC"),
|
text("observed_at DESC"),
|
||||||
),
|
),
|
||||||
Index("events_observed_at_idx", 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):
|
class Incident(Base):
|
||||||
__tablename__ = "incidents"
|
__tablename__ = "incidents"
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from datetime import UTC, datetime, timedelta
|
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 import delete, func, select, text
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import async_sessionmaker
|
|||||||
|
|
||||||
from .. import metrics
|
from .. import metrics
|
||||||
from ..logging_config import get_logger
|
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
|
from ..settings import get_settings
|
||||||
|
|
||||||
log = get_logger("monlet.detector")
|
log = get_logger("monlet.detector")
|
||||||
@@ -25,6 +25,10 @@ def _liveness_severity(status: str) -> str:
|
|||||||
return "critical" if status == "dead" else "warning"
|
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(
|
async def _enqueue_liveness_outbox(
|
||||||
session,
|
session,
|
||||||
incident: Incident,
|
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)
|
severity = _liveness_severity(status)
|
||||||
summary = f"agent is {status}"
|
summary = f"agent is {status}"
|
||||||
incident_key = _liveness_key(agent.agent_id)
|
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,
|
severity=severity,
|
||||||
opened_at=now,
|
opened_at=now,
|
||||||
summary=summary,
|
summary=summary,
|
||||||
last_event_id=uuid4(),
|
last_event_id=event_id,
|
||||||
)
|
)
|
||||||
.on_conflict_do_nothing(
|
.on_conflict_do_nothing(
|
||||||
index_elements=[Incident.incident_key],
|
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":
|
if status == "dead" and inc.severity != "critical":
|
||||||
inc.severity = "critical"
|
inc.severity = "critical"
|
||||||
inc.summary = summary
|
inc.summary = summary
|
||||||
inc.last_event_id = uuid4()
|
inc.last_event_id = event_id
|
||||||
await _enqueue_liveness_outbox(
|
await _enqueue_liveness_outbox(
|
||||||
session, inc, "firing", agent, status, now, summary=summary, severity="critical"
|
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 = (
|
inc = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(Incident).where(
|
select(Incident).where(
|
||||||
@@ -134,7 +140,7 @@ async def _resolve_liveness_incident(session, agent: Agent, now: datetime) -> No
|
|||||||
return
|
return
|
||||||
inc.state = "resolved"
|
inc.state = "resolved"
|
||||||
inc.resolved_at = now
|
inc.resolved_at = now
|
||||||
inc.last_event_id = uuid4()
|
inc.last_event_id = event_id
|
||||||
metrics.incidents_resolved_total.inc()
|
metrics.incidents_resolved_total.inc()
|
||||||
await _enqueue_liveness_outbox(
|
await _enqueue_liveness_outbox(
|
||||||
session,
|
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:
|
async def _prune_history(session) -> None:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
if s.events_retention_max_rows > 0:
|
if s.event_dedup_retention_days > 0:
|
||||||
keep_events = (
|
cutoff = datetime.now(UTC) - timedelta(days=s.event_dedup_retention_days)
|
||||||
select(Event.event_id)
|
batch = max(s.event_dedup_prune_batch_size, 1)
|
||||||
.order_by(Event.received_at.desc(), Event.event_id.desc())
|
# Bounded DELETE: keep each tick's prune transaction short to avoid bloat and
|
||||||
.limit(s.events_retention_max_rows)
|
# 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:
|
if s.outbox_retention_max_rows > 0:
|
||||||
keep_outbox = (
|
keep_outbox = (
|
||||||
@@ -188,10 +262,7 @@ async def _tick(sm: async_sessionmaker) -> None:
|
|||||||
next_status = "alive"
|
next_status = "alive"
|
||||||
if agent.status != next_status:
|
if agent.status != next_status:
|
||||||
agent.status = next_status
|
agent.status = next_status
|
||||||
if next_status in ("stale", "dead"):
|
await _apply_liveness(session, agent, next_status, now)
|
||||||
await _open_liveness_incident(session, agent, next_status, now)
|
|
||||||
else:
|
|
||||||
await _resolve_liveness_incident(session, agent, now)
|
|
||||||
await _prune_history(session)
|
await _prune_history(session)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from .. import metrics
|
from .. import metrics
|
||||||
from ..logging_config import get_logger
|
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 ..redaction import redact
|
||||||
from ..schemas import CheckResultEvent, HeartbeatRequest
|
from ..schemas import CheckResultEvent, HeartbeatRequest
|
||||||
from ..settings import get_settings
|
from ..settings import get_settings
|
||||||
@@ -155,26 +155,45 @@ async def ingest_event(
|
|||||||
agent_id: str,
|
agent_id: str,
|
||||||
ev: CheckResultEvent,
|
ev: CheckResultEvent,
|
||||||
) -> bool:
|
) -> 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)
|
observed = to_utc(ev.observed_at)
|
||||||
output = redact(ev.output)
|
output = redact(ev.output)
|
||||||
check_summary = (output or "")[:200] if output else None
|
check_summary = (output or "")[:200] if output else None
|
||||||
incident_key = _incident_key(agent_id, ev)
|
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(
|
chk_res = await session.execute(
|
||||||
select(Check).where(Check.agent_id == agent_id, Check.check_id == ev.check_id)
|
select(Check).where(Check.agent_id == agent_id, Check.check_id == ev.check_id)
|
||||||
)
|
)
|
||||||
cur = chk_res.scalar_one_or_none()
|
cur = chk_res.scalar_one_or_none()
|
||||||
is_late = cur is not None and cur.last_observed_at > observed
|
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:
|
if is_late:
|
||||||
metrics.events_total.labels(result="late").inc()
|
metrics.events_total.labels(result="late").inc()
|
||||||
return False
|
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:
|
if changed:
|
||||||
stmt = pg_insert(Event).values(
|
stmt = pg_insert(Event).values(
|
||||||
event_id=UUID(ev.event_id),
|
event_id=event_uuid,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
check_id=ev.check_id,
|
check_id=ev.check_id,
|
||||||
observed_at=observed,
|
observed_at=observed,
|
||||||
@@ -186,14 +205,7 @@ async def ingest_event(
|
|||||||
notifications_enabled=ev.notifications_enabled,
|
notifications_enabled=ev.notifications_enabled,
|
||||||
incident_key=incident_key,
|
incident_key=incident_key,
|
||||||
)
|
)
|
||||||
stmt = stmt.on_conflict_do_nothing(index_elements=[Event.event_id]).returning(
|
await session.execute(stmt)
|
||||||
Event.event_id
|
|
||||||
)
|
|
||||||
result = await session.execute(stmt)
|
|
||||||
inserted = result.scalar_one_or_none()
|
|
||||||
if inserted is None:
|
|
||||||
metrics.events_total.labels(result="deduplicated").inc()
|
|
||||||
return False
|
|
||||||
metrics.events_total.labels(result="accepted").inc()
|
metrics.events_total.labels(result="accepted").inc()
|
||||||
else:
|
else:
|
||||||
metrics.events_total.labels(result="unchanged").inc()
|
metrics.events_total.labels(result="unchanged").inc()
|
||||||
@@ -218,7 +230,7 @@ async def ingest_event(
|
|||||||
"summary": check_summary,
|
"summary": check_summary,
|
||||||
}
|
}
|
||||||
if changed:
|
if changed:
|
||||||
update_set["last_event_id"] = UUID(ev.event_id)
|
update_set["last_event_id"] = event_uuid
|
||||||
update_set["incident_key"] = incident_key
|
update_set["incident_key"] = incident_key
|
||||||
chk_stmt = pg_insert(Check).values(**check_values)
|
chk_stmt = pg_insert(Check).values(**check_values)
|
||||||
chk_stmt = chk_stmt.on_conflict_do_update(
|
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
|
port: int = 8000
|
||||||
enable_detector: bool = True
|
enable_detector: bool = True
|
||||||
enable_notifier_worker: bool = True
|
enable_notifier_worker: bool = True
|
||||||
events_retention_max_rows: int = 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
|
outbox_retention_max_rows: int = 50_000
|
||||||
notifier_tick_sec: int = 5
|
notifier_tick_sec: int = 5
|
||||||
notifier_batch_size: int = 20
|
notifier_batch_size: int = 20
|
||||||
|
|||||||
@@ -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("script_location", os.path.join(SERVER_ROOT, "alembic"))
|
||||||
cfg.set_main_option("sqlalchemy.url", get_settings().sync_database_url)
|
cfg.set_main_option("sqlalchemy.url", get_settings().sync_database_url)
|
||||||
command.upgrade(cfg, "head")
|
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
|
yield
|
||||||
|
|
||||||
|
|
||||||
@@ -83,7 +97,7 @@ async def _truncate_tables(engine) -> AsyncIterator[None]:
|
|||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
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
|
@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())
|
eid = str(uuid4())
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
text(
|
text("INSERT INTO event_ingest_dedup (event_id) VALUES (:e)"),
|
||||||
"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')"
|
|
||||||
),
|
|
||||||
{"e": eid},
|
{"e": eid},
|
||||||
)
|
)
|
||||||
r = await conn.execute(
|
r = await conn.execute(
|
||||||
text(
|
text(
|
||||||
"INSERT INTO events (event_id, agent_id, check_id, observed_at, status, exit_code, duration_ms, incident_key)"
|
"INSERT INTO event_ingest_dedup (event_id) VALUES (:e) "
|
||||||
" VALUES (:e,'a','c', now(),'ok',0,1,'a:c') ON CONFLICT (event_id) DO NOTHING RETURNING event_id"
|
"ON CONFLICT (event_id) DO NOTHING RETURNING event_id"
|
||||||
),
|
),
|
||||||
{"e": eid},
|
{"e": eid},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import pytest
|
|||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.ext.asyncio import async_sessionmaker
|
from sqlalchemy.ext.asyncio import async_sessionmaker
|
||||||
|
|
||||||
from monlet_server.models import Agent, Event, Incident, NotificationOutbox
|
from monlet_server.models import Agent, Check, Event, Incident, NotificationOutbox
|
||||||
from monlet_server.services.detector import LIVENESS_CHECK_ID, _tick
|
from monlet_server.services.detector import LIVENESS_CHECK_ID, _tick
|
||||||
from monlet_server.settings import reset_settings_cache
|
from monlet_server.settings import reset_settings_cache
|
||||||
|
|
||||||
@@ -82,22 +82,75 @@ async def test_detector_transitions(app_client, auth_headers, engine, session):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_detector_prunes_bounded_history(
|
async def test_detector_owns_liveness_check_and_events(app_client, auth_headers, engine, session):
|
||||||
app_client, auth_headers, engine, session, monkeypatch
|
await app_client.post(
|
||||||
):
|
"/api/v1/heartbeat", json=make_heartbeat("agent-flap"), headers=auth_headers
|
||||||
monkeypatch.setenv("MONLET_EVENTS_RETENTION_MAX_ROWS", "2")
|
)
|
||||||
|
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")
|
monkeypatch.setenv("MONLET_OUTBOX_RETENTION_MAX_ROWS", "2")
|
||||||
reset_settings_cache()
|
reset_settings_cache()
|
||||||
try:
|
try:
|
||||||
await app_client.post(
|
await app_client.post(
|
||||||
"/api/v1/heartbeat", json=make_heartbeat("agent-prune"), headers=auth_headers
|
"/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)
|
critical = make_event(check_id="critical_prune", status="critical", exit_code=2)
|
||||||
await app_client.post(
|
await app_client.post(
|
||||||
"/api/v1/events",
|
"/api/v1/events",
|
||||||
@@ -129,7 +182,6 @@ async def test_detector_prunes_bounded_history(
|
|||||||
sm = async_sessionmaker(engine, expire_on_commit=False)
|
sm = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
await _tick(sm)
|
await _tick(sm)
|
||||||
|
|
||||||
assert (await session.execute(select(func.count()).select_from(Event))).scalar_one() == 2
|
|
||||||
terminal_outbox = (
|
terminal_outbox = (
|
||||||
await session.execute(
|
await session.execute(
|
||||||
select(func.count())
|
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 { AgentDetailBrowser } from "@/components/AgentDetailBrowser";
|
||||||
import { ErrorPanel } from "@/components/DataPanel";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { ApiError, api } from "@/lib/api";
|
import { ApiError, api } from "@/lib/api";
|
||||||
import { withLivenessEvents } from "@/lib/check-groups";
|
|
||||||
import { loadChecks } from "@/lib/loaders";
|
import { loadChecks } from "@/lib/loaders";
|
||||||
|
import { normalizePageSize } from "@/lib/pagination";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
type SortKey = "severity" | "check_id" | "last_seen";
|
type SortKey = "severity" | "check_id" | "last_seen";
|
||||||
type TabKey = "checks" | "events";
|
type TabKey = "checks" | "events";
|
||||||
|
type EventsPage = Awaited<ReturnType<typeof api.queryEvents>>;
|
||||||
|
|
||||||
function normalizeSort(sort?: string): SortKey {
|
function normalizeSort(sort?: string): SortKey {
|
||||||
return sort === "check_id" || sort === "last_seen" ? sort : "severity";
|
return sort === "check_id" || sort === "last_seen" ? sort : "severity";
|
||||||
@@ -21,22 +22,35 @@ export default async function AgentDetailPage({
|
|||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ agent_id: string }>;
|
params: Promise<{
|
||||||
searchParams: Promise<{ sort?: string; tab?: string }>;
|
agent_id: string;
|
||||||
|
}>;
|
||||||
|
searchParams: Promise<{
|
||||||
|
sort?: string;
|
||||||
|
tab?: string;
|
||||||
|
events_limit?: string;
|
||||||
|
cursor?: string;
|
||||||
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const { agent_id } = await params;
|
const { agent_id } = await params;
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const sort = normalizeSort(sp.sort);
|
const sort = normalizeSort(sp.sort);
|
||||||
const tab = normalizeTab(sp.tab);
|
const tab = normalizeTab(sp.tab);
|
||||||
|
const eventsLimit = normalizePageSize(sp.events_limit, 50);
|
||||||
|
const eventsCursor = sp.cursor;
|
||||||
|
|
||||||
let data;
|
let agent;
|
||||||
|
let checks;
|
||||||
|
let events: EventsPage = { items: [], next_cursor: null };
|
||||||
try {
|
try {
|
||||||
const [agent, checks, events] = await Promise.all([
|
[agent, checks] = await Promise.all([api.getAgent(agent_id), loadChecks(agent_id)]);
|
||||||
api.getAgent(agent_id),
|
if (tab === "events") {
|
||||||
loadChecks(agent_id),
|
events = await api.queryEvents({
|
||||||
api.queryEvents({ agent_id, limit: 50 }).then((page) => page.items),
|
agent_id,
|
||||||
]);
|
limit: eventsLimit,
|
||||||
data = { agent, checks, events: withLivenessEvents([agent], events, { agentId: agent_id }) };
|
cursor: eventsCursor,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 404) {
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
return <div className="text-sm text-neutral-400">Agent not found.</div>;
|
return <div className="text-sm text-neutral-400">Agent not found.</div>;
|
||||||
@@ -45,10 +59,13 @@ export default async function AgentDetailPage({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<AgentDetailBrowser
|
<AgentDetailBrowser
|
||||||
key={`${agent_id}:${tab}:${sort}`}
|
key={`${agent_id}:${tab}:${sort}:${eventsLimit}:${eventsCursor ?? ""}`}
|
||||||
initialData={data}
|
initialData={{ agent, checks }}
|
||||||
|
initialEvents={events}
|
||||||
sort={sort}
|
sort={sort}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
|
eventsLimit={eventsLimit}
|
||||||
|
eventsCursor={eventsCursor}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { ApiError, api } from "@/lib/api";
|
import { ApiError, api } from "@/lib/api";
|
||||||
import { withLivenessEvents } from "@/lib/check-groups";
|
|
||||||
import { loadChecks } from "@/lib/loaders";
|
import { loadChecks } from "@/lib/loaders";
|
||||||
import { jsonError } from "@/lib/poll-response";
|
import { jsonError } from "@/lib/poll-response";
|
||||||
|
|
||||||
@@ -19,16 +18,8 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [agent, checks, events] = await Promise.all([
|
const [agent, checks] = await Promise.all([api.getAgent(agentId), loadChecks(agentId)]);
|
||||||
api.getAgent(agentId),
|
return NextResponse.json({ agent, checks });
|
||||||
loadChecks(agentId),
|
|
||||||
api.queryEvents({ agent_id: agentId, limit: 50 }).then((page) => page.items),
|
|
||||||
]);
|
|
||||||
return NextResponse.json({
|
|
||||||
agent,
|
|
||||||
checks,
|
|
||||||
events: withLivenessEvents([agent], events, { agentId }),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiError && e.status === 404) {
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import { NextResponse } from "next/server";
|
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";
|
import { jsonError } from "@/lib/poll-response";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const limit = Number(url.searchParams.get("limit") ?? "100");
|
const limit = normalizePageSize(url.searchParams.get("limit"), 50);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await loadEventsWithLiveness({
|
const data = await loadEvents({
|
||||||
agentId: url.searchParams.get("agent_id") ?? undefined,
|
agentId: url.searchParams.get("agent_id") ?? undefined,
|
||||||
checkId: url.searchParams.get("check_id") ?? undefined,
|
checkId: url.searchParams.get("check_id") ?? undefined,
|
||||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||||
limit: Number.isFinite(limit) ? limit : 100,
|
limit,
|
||||||
});
|
});
|
||||||
return NextResponse.json(data);
|
return NextResponse.json(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ export async function GET(request: Request) {
|
|||||||
const state = stateParam === "open" || stateParam === "resolved" ? stateParam : undefined;
|
const state = stateParam === "open" || stateParam === "resolved" ? stateParam : undefined;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await loadIncidents(state);
|
const items = await loadIncidents(state);
|
||||||
return NextResponse.json(data);
|
return NextResponse.json({ items });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return jsonError(e);
|
return jsonError(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { api } from "@/lib/api";
|
import { loadOutboxAll } from "@/lib/loaders";
|
||||||
import { jsonError } from "@/lib/poll-response";
|
import { jsonError } from "@/lib/poll-response";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET() {
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await api.listOutbox({
|
const items = await loadOutboxAll();
|
||||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
return NextResponse.json({ items });
|
||||||
});
|
|
||||||
return NextResponse.json(data);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return jsonError(e);
|
return jsonError(e);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { CheckDetailBrowser } from "@/components/CheckDetailBrowser";
|
import { CheckDetailBrowser } from "@/components/CheckDetailBrowser";
|
||||||
import { ErrorPanel } from "@/components/DataPanel";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { withLivenessEvents } from "@/lib/check-groups";
|
|
||||||
import { loadInventory } from "@/lib/loaders";
|
import { loadInventory } from "@/lib/loaders";
|
||||||
|
import { normalizePageSize } from "@/lib/pagination";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -18,22 +18,24 @@ export default async function CheckDetailPage({
|
|||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ check_id: string }>;
|
params: Promise<{ check_id: string }>;
|
||||||
searchParams: Promise<{ tab?: string }>;
|
searchParams: Promise<{ tab?: string; events_limit?: string; cursor?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { check_id } = await params;
|
const { check_id } = await params;
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const tab = normalizeTab(sp.tab);
|
const tab = normalizeTab(sp.tab);
|
||||||
|
const eventsLimit = normalizePageSize(sp.events_limit, 50);
|
||||||
|
const eventsCursor = sp.cursor;
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
let events: EventsPage = { items: [], next_cursor: null };
|
let events: EventsPage = { items: [], next_cursor: null };
|
||||||
try {
|
try {
|
||||||
data = await loadInventory();
|
data = await loadInventory();
|
||||||
if (tab === "events") {
|
if (tab === "events") {
|
||||||
const rawEvents = await api.queryEvents({ check_id, limit: 50 });
|
events = await api.queryEvents({
|
||||||
events = {
|
check_id,
|
||||||
...rawEvents,
|
limit: eventsLimit,
|
||||||
items: withLivenessEvents(data.agents, rawEvents.items, { checkId: check_id }),
|
cursor: eventsCursor,
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return <ErrorPanel error={e} />;
|
return <ErrorPanel error={e} />;
|
||||||
@@ -41,11 +43,13 @@ export default async function CheckDetailPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<CheckDetailBrowser
|
<CheckDetailBrowser
|
||||||
key={`${check_id}:${tab}`}
|
key={`${check_id}:${tab}:${eventsLimit}:${eventsCursor ?? ""}`}
|
||||||
checkId={check_id}
|
checkId={check_id}
|
||||||
initialData={data}
|
initialData={data}
|
||||||
initialEvents={events}
|
initialEvents={events}
|
||||||
tab={tab}
|
tab={tab}
|
||||||
|
eventsLimit={eventsLimit}
|
||||||
|
eventsCursor={eventsCursor}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,41 @@
|
|||||||
import { ErrorPanel } from "@/components/DataPanel";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { EventsBrowser } from "@/components/EventsBrowser";
|
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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function EventsPage({
|
export default async function EventsPage({
|
||||||
searchParams,
|
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 sp = await searchParams;
|
||||||
|
const limit = normalizePageSize(sp.limit);
|
||||||
let data;
|
let data;
|
||||||
try {
|
try {
|
||||||
data = await loadEventsWithLiveness({
|
data = await loadEvents({
|
||||||
agentId: sp.agent_id,
|
agentId: sp.agent_id,
|
||||||
checkId: sp.check_id,
|
checkId: sp.check_id,
|
||||||
cursor: sp.cursor,
|
cursor: sp.cursor,
|
||||||
|
limit,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return <ErrorPanel error={e} />;
|
return <ErrorPanel error={e} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<EventsBrowser
|
<EventsBrowser
|
||||||
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}`}
|
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}:${limit}`}
|
||||||
initialData={data}
|
initialData={data}
|
||||||
cursor={sp.cursor}
|
cursor={sp.cursor}
|
||||||
agentId={sp.agent_id}
|
agentId={sp.agent_id}
|
||||||
checkId={sp.check_id}
|
checkId={sp.check_id}
|
||||||
|
limit={limit}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,17 +11,17 @@ export default async function IncidentsPage({
|
|||||||
}) {
|
}) {
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
|
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
|
||||||
let data;
|
let items;
|
||||||
let inventory;
|
let inventory;
|
||||||
try {
|
try {
|
||||||
[data, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
|
[items, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return <ErrorPanel error={e} />;
|
return <ErrorPanel error={e} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<IncidentsBrowser
|
<IncidentsBrowser
|
||||||
key={state ?? ""}
|
key={state ?? ""}
|
||||||
initialData={data}
|
initialItems={items}
|
||||||
initialInventory={inventory}
|
initialInventory={inventory}
|
||||||
state={state}
|
state={state}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
import { ErrorPanel } from "@/components/DataPanel";
|
import { ErrorPanel } from "@/components/DataPanel";
|
||||||
import { OutboxBrowser } from "@/components/OutboxBrowser";
|
import { OutboxBrowser } from "@/components/OutboxBrowser";
|
||||||
import { api } from "@/lib/api";
|
import { loadOutboxAll } from "@/lib/loaders";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function OutboxPage({
|
export default async function OutboxPage() {
|
||||||
searchParams,
|
let items;
|
||||||
}: {
|
|
||||||
searchParams: Promise<{ cursor?: string }>;
|
|
||||||
}) {
|
|
||||||
const sp = await searchParams;
|
|
||||||
let data;
|
|
||||||
try {
|
try {
|
||||||
data = await api.listOutbox({ cursor: sp.cursor });
|
items = await loadOutboxAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return <ErrorPanel error={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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 { AgentFeatures } from "@/components/AgentFeatures";
|
||||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
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 { LabelChips } from "@/components/Labels";
|
||||||
|
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||||
|
import { Pager } from "@/components/Pager";
|
||||||
import { DateTime } from "@/components/TimeZoneControls";
|
import { DateTime } from "@/components/TimeZoneControls";
|
||||||
import type { components } from "@/lib/api-types";
|
import type { components } from "@/lib/api-types";
|
||||||
import { withLivenessChecks } from "@/lib/check-groups";
|
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
import { statusBadgeClass } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
clampPage,
|
||||||
|
pageCountOf,
|
||||||
|
paginate,
|
||||||
|
type PageSize,
|
||||||
|
} from "@/lib/pagination";
|
||||||
import { usePolling } from "@/lib/usePolling";
|
import { usePolling } from "@/lib/usePolling";
|
||||||
|
|
||||||
type Agent = components["schemas"]["Agent"];
|
type Agent = components["schemas"]["Agent"];
|
||||||
type CheckState = components["schemas"]["CheckState"];
|
type CheckState = components["schemas"]["CheckState"];
|
||||||
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||||
|
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
|
||||||
type SortKey = "severity" | "check_id" | "last_seen";
|
type SortKey = "severity" | "check_id" | "last_seen";
|
||||||
type TabKey = "checks" | "events";
|
type TabKey = "checks" | "events";
|
||||||
type AgentDetailData = {
|
type AgentDetailData = { agent: Agent; checks: CheckState[] };
|
||||||
agent: Agent;
|
type AgentPollPayload = AgentDetailData & { events?: StoredEvent[] };
|
||||||
checks: CheckState[];
|
|
||||||
events: StoredEvent[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const severityRank: Record<CheckState["status"], number> = {
|
const severityRank: Record<CheckState["status"], number> = {
|
||||||
critical: 0,
|
critical: 0,
|
||||||
@@ -33,22 +42,24 @@ const severityRank: Record<CheckState["status"], number> = {
|
|||||||
|
|
||||||
export function AgentDetailBrowser({
|
export function AgentDetailBrowser({
|
||||||
initialData,
|
initialData,
|
||||||
|
initialEvents,
|
||||||
sort,
|
sort,
|
||||||
tab,
|
tab,
|
||||||
|
eventsLimit,
|
||||||
|
eventsCursor,
|
||||||
}: {
|
}: {
|
||||||
initialData: AgentDetailData;
|
initialData: AgentDetailData;
|
||||||
|
initialEvents: EventsPageData;
|
||||||
sort: SortKey;
|
sort: SortKey;
|
||||||
tab: TabKey;
|
tab: TabKey;
|
||||||
|
eventsLimit: PageSize;
|
||||||
|
eventsCursor?: string;
|
||||||
}) {
|
}) {
|
||||||
const { data } = usePolling(
|
const agentId = initialData.agent.agent_id;
|
||||||
initialData,
|
const pollUrl = `/api/poll/agent?agent_id=${encodeURIComponent(agentId)}`;
|
||||||
`/api/poll/agent?agent_id=${encodeURIComponent(initialData.agent.agent_id)}`,
|
const { data } = usePolling<AgentPollPayload>(initialData, pollUrl);
|
||||||
);
|
const agent = data.agent;
|
||||||
const { agent, events } = data;
|
const checks = useMemo(() => sortChecks(data.checks, sort), [data.checks, sort]);
|
||||||
const checks = useMemo(
|
|
||||||
() => sortChecks(withLivenessChecks([agent], data.checks), sort),
|
|
||||||
[agent, data.checks, sort],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -76,28 +87,32 @@ export function AgentDetailBrowser({
|
|||||||
<LabelChips labels={agent.labels} />
|
<LabelChips labels={agent.labels} />
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<TabNav agentId={agent.agent_id} current={tab} sort={sort} />
|
<DetailTabs
|
||||||
{tab === "checks" ? <ChecksTable agentId={agent.agent_id} checks={checks} sort={sort} /> : <EventsTable events={events} />}
|
current={tab}
|
||||||
</div>
|
tabs={[
|
||||||
);
|
{
|
||||||
}
|
key: "checks",
|
||||||
|
label: "Checks",
|
||||||
function TabNav({ agentId, current, sort }: { agentId: string; current: TabKey; sort: SortKey }) {
|
href: `/agents/${encodeURIComponent(agentId)}?tab=checks&sort=${sort}`,
|
||||||
const base = `/agents/${encodeURIComponent(agentId)}`;
|
},
|
||||||
return (
|
{
|
||||||
<div className="flex gap-4 border-b border-neutral-800 text-sm">
|
key: "events",
|
||||||
<Link
|
label: "Events",
|
||||||
href={`${base}?tab=checks&sort=${sort}`}
|
href: `/agents/${encodeURIComponent(agentId)}?tab=events&sort=${sort}&events_limit=${eventsLimit}`,
|
||||||
className={`pb-2 ${current === "checks" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
|
},
|
||||||
>
|
]}
|
||||||
Checks
|
/>
|
||||||
</Link>
|
{tab === "checks" ? (
|
||||||
<Link
|
<ChecksTable agentId={agentId} checks={checks} sort={sort} />
|
||||||
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"}`}
|
<EventsTable
|
||||||
>
|
agentId={agentId}
|
||||||
Events
|
sort={sort}
|
||||||
</Link>
|
eventsLimit={eventsLimit}
|
||||||
|
eventsCursor={eventsCursor}
|
||||||
|
initialEvents={initialEvents}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -111,9 +126,22 @@ function ChecksTable({
|
|||||||
checks: CheckState[];
|
checks: CheckState[];
|
||||||
sort: SortKey;
|
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 (
|
return (
|
||||||
<section>
|
<section>
|
||||||
|
<div className="mb-3 flex justify-end">
|
||||||
|
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||||
|
</div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -136,7 +164,7 @@ function ChecksTable({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{checks.map((c) => (
|
{visible.map((c) => (
|
||||||
<tr key={c.check_id}>
|
<tr key={c.check_id}>
|
||||||
<Td>
|
<Td>
|
||||||
<CheckEventsLink agentId={agentId} checkId={c.check_id} />
|
<CheckEventsLink agentId={agentId} checkId={c.check_id} />
|
||||||
@@ -145,19 +173,50 @@ function ChecksTable({
|
|||||||
<Td>
|
<Td>
|
||||||
<DateTime iso={c.last_observed_at} />
|
<DateTime iso={c.last_observed_at} />
|
||||||
</Td>
|
</Td>
|
||||||
<Td className="max-w-md truncate text-xs text-neutral-400">{c.summary ?? "—"}</Td>
|
<SummaryCell text={c.summary} />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventsTable({ events }: { events: StoredEvent[] }) {
|
function EventsTable({
|
||||||
if (events.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
|
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 (
|
return (
|
||||||
<section>
|
<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">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -168,7 +227,7 @@ function EventsTable({ events }: { events: StoredEvent[] }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{events.map((e) => (
|
{data.items.map((e) => (
|
||||||
<tr key={e.event_id}>
|
<tr key={e.event_id}>
|
||||||
<Td>
|
<Td>
|
||||||
<DateTime iso={e.observed_at} />
|
<DateTime iso={e.observed_at} />
|
||||||
@@ -182,6 +241,13 @@ function EventsTable({ events }: { events: StoredEvent[] }) {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
|
<Pager
|
||||||
|
basePath={`/agents/${encodeURIComponent(agentId)}`}
|
||||||
|
cursor={eventsCursor}
|
||||||
|
nextCursor={data.next_cursor}
|
||||||
|
extra={{ tab: "events", sort, events_limit: String(eventsLimit) }}
|
||||||
|
/>
|
||||||
</section>
|
</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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
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 { LabelChips } from "@/components/Labels";
|
||||||
|
import { ListToolbar } from "@/components/ListToolbar";
|
||||||
|
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
|
||||||
import { DateTime } from "@/components/TimeZoneControls";
|
import { DateTime } from "@/components/TimeZoneControls";
|
||||||
import type { components } from "@/lib/api-types";
|
import type { components } from "@/lib/api-types";
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
import { statusBadgeClass } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
clampPage,
|
||||||
|
pageCountOf,
|
||||||
|
paginate,
|
||||||
|
type PageSize,
|
||||||
|
} from "@/lib/pagination";
|
||||||
import { usePolling } from "@/lib/usePolling";
|
import { usePolling } from "@/lib/usePolling";
|
||||||
import type { InventoryData } from "@/lib/view-types";
|
import type { InventoryData } from "@/lib/view-types";
|
||||||
|
|
||||||
@@ -16,7 +27,6 @@ type CheckState = components["schemas"]["CheckState"];
|
|||||||
type CheckStatus = CheckState["status"];
|
type CheckStatus = CheckState["status"];
|
||||||
type CheckCounts = Record<CheckStatus, number>;
|
type CheckCounts = Record<CheckStatus, number>;
|
||||||
type SortField = "host" | "status" | "last_seen_at";
|
type SortField = "host" | "status" | "last_seen_at";
|
||||||
type SortDir = "asc" | "desc";
|
|
||||||
|
|
||||||
export type AgentRow = { agent: Agent; checks: CheckCounts };
|
export type AgentRow = { agent: Agent; checks: CheckCounts };
|
||||||
|
|
||||||
@@ -36,77 +46,75 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
|||||||
[dir, initialRows, query, sort],
|
[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) => {
|
const chooseSort = (field: SortField) => {
|
||||||
if (field === sort) {
|
if (field === sort) {
|
||||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
setSort(field);
|
setSort(field);
|
||||||
setDir(fieldDefaultDir(field));
|
setDir(fieldDefaultDir(field));
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 flex items-baseline gap-3">
|
<div className="mb-4 flex items-baseline gap-3">
|
||||||
<h1 className="text-lg font-semibold">Agents</h1>
|
<h1 className="text-lg font-semibold">Agents</h1>
|
||||||
<span className="text-sm text-neutral-400">{rows.length} items</span>
|
<span className="text-sm text-neutral-400">
|
||||||
|
{rows.length} of {initialRows.length} items
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 flex max-w-md gap-2">
|
<ListToolbar
|
||||||
<input
|
query={query}
|
||||||
type="search"
|
onQueryChange={onQueryChange}
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder="search agent"
|
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"
|
pageSize={pageSize}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
/>
|
/>
|
||||||
{query ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setQuery("")}
|
|
||||||
className="border border-neutral-900 px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-300"
|
|
||||||
>
|
|
||||||
clear
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div className="text-sm text-neutral-500 italic">No data.</div>
|
<EmptyPanel />
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<Th>
|
<Th>
|
||||||
<SortButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
||||||
HOST
|
HOST
|
||||||
</SortButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
<Th>
|
<Th>
|
||||||
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||||
STATUS
|
STATUS
|
||||||
</SortButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
<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
|
LAST_SEEN_AT
|
||||||
</SortButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
<Th>features</Th>
|
<Th>features</Th>
|
||||||
<Th>labels</Th>
|
<Th>labels</Th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map(({ agent: a, checks }) => (
|
{visible.map(({ agent: a, checks }) => (
|
||||||
<tr key={a.agent_id}>
|
<tr key={a.agent_id}>
|
||||||
<Td>
|
<Td>
|
||||||
<Link
|
<AgentLink agent={a} agentId={a.agent_id} />
|
||||||
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>
|
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<AgentStatus status={a.status} checks={checks} />
|
<AgentStatus status={a.status} checks={checks} />
|
||||||
@@ -125,50 +133,11 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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[] {
|
function filterAgents(rows: AgentRow[], query: string): AgentRow[] {
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = query.trim().toLowerCase();
|
||||||
if (!needle) return rows;
|
if (!needle) return rows;
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 { 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 { DateTime } from "@/components/TimeZoneControls";
|
||||||
import type { components } from "@/lib/api-types";
|
import type { components } from "@/lib/api-types";
|
||||||
import {
|
import { groupChecks, statusSeverity, type Agent, type CheckGroup } from "@/lib/check-groups";
|
||||||
groupChecks,
|
|
||||||
statusSeverity,
|
|
||||||
withLivenessChecks,
|
|
||||||
type Agent,
|
|
||||||
type CheckGroup,
|
|
||||||
} from "@/lib/check-groups";
|
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
import { statusBadgeClass } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
clampPage,
|
||||||
|
pageCountOf,
|
||||||
|
paginate,
|
||||||
|
type PageSize,
|
||||||
|
} from "@/lib/pagination";
|
||||||
import { usePolling } from "@/lib/usePolling";
|
import { usePolling } from "@/lib/usePolling";
|
||||||
import type { InventoryData } from "@/lib/view-types";
|
import type { InventoryData } from "@/lib/view-types";
|
||||||
|
|
||||||
@@ -27,21 +34,24 @@ export function CheckDetailBrowser({
|
|||||||
initialData,
|
initialData,
|
||||||
initialEvents,
|
initialEvents,
|
||||||
tab,
|
tab,
|
||||||
|
eventsLimit,
|
||||||
|
eventsCursor,
|
||||||
}: {
|
}: {
|
||||||
checkId: string;
|
checkId: string;
|
||||||
initialData: InventoryData;
|
initialData: InventoryData;
|
||||||
initialEvents: EventsPageData;
|
initialEvents: EventsPageData;
|
||||||
tab: TabKey;
|
tab: TabKey;
|
||||||
|
eventsLimit: PageSize;
|
||||||
|
eventsCursor?: string;
|
||||||
}) {
|
}) {
|
||||||
const { data } = usePolling(initialData, "/api/poll/inventory");
|
const { data } = usePolling(initialData, "/api/poll/inventory");
|
||||||
const agentsById = useMemo(() => new Map(data.agents.map((agent) => [agent.agent_id, agent])), [data.agents]);
|
const agentsById = useMemo(
|
||||||
const checks = useMemo(
|
() => new Map(data.agents.map((agent) => [agent.agent_id, agent])),
|
||||||
() => withLivenessChecks(data.agents, data.checks),
|
[data.agents],
|
||||||
[data.agents, data.checks],
|
|
||||||
);
|
);
|
||||||
const group = useMemo(
|
const group = useMemo(
|
||||||
() => groupChecks(checks, agentsById).find((item) => item.checkId === checkId),
|
() => groupChecks(data.checks, agentsById).find((item) => item.checkId === checkId),
|
||||||
[agentsById, checkId, checks],
|
[agentsById, checkId, data.checks],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!group) {
|
if (!group) {
|
||||||
@@ -76,44 +86,62 @@ export function CheckDetailBrowser({
|
|||||||
<DateTime iso={group.lastObservedAt} />
|
<DateTime iso={group.lastObservedAt} />
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</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" ? (
|
{tab === "agents" ? (
|
||||||
<AgentsForCheck group={group} />
|
<AgentsForCheck group={group} />
|
||||||
) : (
|
) : (
|
||||||
<EventsForCheck checkId={checkId} initialData={initialEvents} agentsById={agentsById} />
|
<EventsForCheck
|
||||||
|
checkId={checkId}
|
||||||
|
initialData={initialEvents}
|
||||||
|
agentsById={agentsById}
|
||||||
|
eventsLimit={eventsLimit}
|
||||||
|
eventsCursor={eventsCursor}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 }) {
|
function AgentsForCheck({ group }: { group: CheckGroup }) {
|
||||||
const rows = [...group.checks].sort(
|
const rows = useMemo(
|
||||||
|
() =>
|
||||||
|
[...group.checks].sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
statusSeverity[b.check.status] - statusSeverity[a.check.status] ||
|
statusSeverity[b.check.status] - statusSeverity[a.check.status] ||
|
||||||
hostName(a).localeCompare(hostName(b)),
|
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 (
|
return (
|
||||||
|
<section>
|
||||||
|
<div className="mb-3 flex justify-end">
|
||||||
|
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||||
|
</div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -124,28 +152,22 @@ function AgentsForCheck({ group }: { group: CheckGroup }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map(({ check, agent }) => (
|
{visible.map(({ check, agent }) => (
|
||||||
<tr key={check.agent_id}>
|
<tr key={check.agent_id}>
|
||||||
<Td>
|
<Td>
|
||||||
<Link
|
<AgentLink agent={agent} agentId={check.agent_id} />
|
||||||
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>
|
||||||
<Td className={statusBadgeClass(check.status)}>{check.status}</Td>
|
<Td className={statusBadgeClass(check.status)}>{check.status}</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<DateTime iso={check.last_observed_at} />
|
<DateTime iso={check.last_observed_at} />
|
||||||
</Td>
|
</Td>
|
||||||
<Td className="max-w-md truncate text-xs text-neutral-400">{check.summary ?? "—"}</Td>
|
<SummaryCell text={check.summary} />
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||||
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,19 +175,40 @@ function EventsForCheck({
|
|||||||
checkId,
|
checkId,
|
||||||
initialData,
|
initialData,
|
||||||
agentsById,
|
agentsById,
|
||||||
|
eventsLimit,
|
||||||
|
eventsCursor,
|
||||||
}: {
|
}: {
|
||||||
checkId: string;
|
checkId: string;
|
||||||
initialData: EventsPageData;
|
initialData: EventsPageData;
|
||||||
agentsById: Map<string, Agent>;
|
agentsById: Map<string, Agent>;
|
||||||
|
eventsLimit: PageSize;
|
||||||
|
eventsCursor?: string;
|
||||||
}) {
|
}) {
|
||||||
const { data } = usePolling(
|
const router = useRouter();
|
||||||
initialData,
|
const pollUrl = useMemo(() => {
|
||||||
`/api/poll/events?check_id=${encodeURIComponent(checkId)}&limit=50`,
|
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 (
|
return (
|
||||||
|
<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">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -189,21 +232,24 @@ function EventsForCheck({
|
|||||||
<DateTime iso={event.received_at} />
|
<DateTime iso={event.received_at} />
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>
|
||||||
<Link
|
<AgentLink agent={agent} agentId={event.agent_id} />
|
||||||
href={`/agents/${encodeURIComponent(event.agent_id)}`}
|
|
||||||
className="text-blue-300 hover:text-blue-200"
|
|
||||||
>
|
|
||||||
{agent?.hostname || event.agent_id}
|
|
||||||
</Link>
|
|
||||||
</Td>
|
</Td>
|
||||||
<Td className={statusBadgeClass(event.status)}>{event.status}</Td>
|
<Td className={statusBadgeClass(event.status)}>{event.status}</Td>
|
||||||
<Td>{event.duration_ms}</Td>
|
<Td>{event.duration_ms}</Td>
|
||||||
<Td className="max-w-md truncate text-xs text-neutral-400">{event.output ?? "—"}</Td>
|
<SummaryCell text={event.output} />
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 { useMemo, useState } from "react";
|
||||||
|
|
||||||
import { CheckStatusSummary } from "@/components/CheckStatusSummary";
|
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 { DateTime } from "@/components/TimeZoneControls";
|
||||||
import {
|
import {
|
||||||
compareStatusCounts,
|
compareStatusCounts,
|
||||||
groupChecks,
|
groupChecks,
|
||||||
type CheckGroup,
|
type CheckGroup,
|
||||||
} from "@/lib/check-groups";
|
} from "@/lib/check-groups";
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
clampPage,
|
||||||
|
pageCountOf,
|
||||||
|
paginate,
|
||||||
|
type PageSize,
|
||||||
|
} from "@/lib/pagination";
|
||||||
import { usePolling } from "@/lib/usePolling";
|
import { usePolling } from "@/lib/usePolling";
|
||||||
import type { InventoryData } from "@/lib/view-types";
|
import type { InventoryData } from "@/lib/view-types";
|
||||||
|
|
||||||
type SortField = "check" | "status" | "last_observed_at";
|
type SortField = "check" | "status" | "last_observed_at";
|
||||||
type SortDir = "asc" | "desc";
|
|
||||||
|
|
||||||
export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
|
export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
|
||||||
const { data } = usePolling(initialData, "/api/poll/inventory");
|
const { data } = usePolling(initialData, "/api/poll/inventory");
|
||||||
@@ -29,65 +39,71 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
|
|||||||
[dir, groups, query, sort],
|
[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) => {
|
const chooseSort = (field: SortField) => {
|
||||||
if (field === sort) {
|
if (field === sort) {
|
||||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
setSort(field);
|
setSort(field);
|
||||||
setDir(fieldDefaultDir(field));
|
setDir(fieldDefaultDir(field));
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-4 flex items-baseline gap-3">
|
<div className="mb-4 flex items-baseline gap-3">
|
||||||
<h1 className="text-lg font-semibold">Checks</h1>
|
<h1 className="text-lg font-semibold">Checks</h1>
|
||||||
<span className="text-sm text-neutral-400">{rows.length} items</span>
|
<span className="text-sm text-neutral-400">
|
||||||
|
{rows.length} of {groups.length} items
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4 flex max-w-md gap-2">
|
<ListToolbar
|
||||||
<input
|
query={query}
|
||||||
type="search"
|
onQueryChange={onQueryChange}
|
||||||
value={query}
|
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
|
||||||
placeholder="search check"
|
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"
|
pageSize={pageSize}
|
||||||
|
onPageSizeChange={onPageSizeChange}
|
||||||
/>
|
/>
|
||||||
{query ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setQuery("")}
|
|
||||||
className="border border-neutral-900 px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-300"
|
|
||||||
>
|
|
||||||
clear
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
<div className="text-sm text-neutral-500 italic">No data.</div>
|
<EmptyPanel />
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<Th>
|
<Th>
|
||||||
<SortButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
<SortHeaderButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
||||||
CHECK
|
CHECK
|
||||||
</SortButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
<Th>
|
<Th>
|
||||||
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||||
STATUS
|
STATUS
|
||||||
</SortButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
<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
|
LAST_OBSERVED_AT
|
||||||
</SortButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
<Th>AGENTS</Th>
|
<Th>AGENTS</Th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row) => (
|
{visible.map((row) => (
|
||||||
<tr key={row.checkId}>
|
<tr key={row.checkId}>
|
||||||
<Td>
|
<Td>
|
||||||
<Link
|
<Link
|
||||||
@@ -109,48 +125,11 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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[] {
|
function filterChecks(rows: CheckGroup[], query: string): CheckGroup[] {
|
||||||
const needle = query.trim().toLowerCase();
|
const needle = query.trim().toLowerCase();
|
||||||
if (!needle) return rows;
|
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>
|
<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";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
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 { Pager } from "@/components/Pager";
|
||||||
import { DateTime } from "@/components/TimeZoneControls";
|
import { DateTime } from "@/components/TimeZoneControls";
|
||||||
import type { components } from "@/lib/api-types";
|
import type { components } from "@/lib/api-types";
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
import { statusBadgeClass } from "@/lib/format";
|
||||||
|
import { type PageSize } from "@/lib/pagination";
|
||||||
import { usePolling } from "@/lib/usePolling";
|
import { usePolling } from "@/lib/usePolling";
|
||||||
|
|
||||||
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||||
@@ -19,19 +22,32 @@ export function EventsBrowser({
|
|||||||
cursor,
|
cursor,
|
||||||
agentId,
|
agentId,
|
||||||
checkId,
|
checkId,
|
||||||
|
limit,
|
||||||
}: {
|
}: {
|
||||||
initialData: EventsPageData;
|
initialData: EventsPageData;
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
checkId?: 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 { data } = usePolling(initialData, url);
|
||||||
|
|
||||||
|
const onLimitChange = (next: PageSize) => {
|
||||||
|
router.push(eventsHref({ agent_id: agentId, check_id: checkId, limit: next }));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Events" count={data.items.length} />
|
<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 ? (
|
{data.items.length === 0 ? (
|
||||||
<EmptyPanel />
|
<EmptyPanel />
|
||||||
) : (
|
) : (
|
||||||
@@ -62,7 +78,7 @@ export function EventsBrowser({
|
|||||||
</Td>
|
</Td>
|
||||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
||||||
<Td>{e.duration_ms}</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -72,24 +88,43 @@ export function EventsBrowser({
|
|||||||
basePath="/events"
|
basePath="/events"
|
||||||
cursor={cursor}
|
cursor={cursor}
|
||||||
nextCursor={data.next_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 }) {
|
function ActiveFilters({
|
||||||
if (!agentId && !checkId) return null;
|
agentId,
|
||||||
|
checkId,
|
||||||
|
limit,
|
||||||
|
}: {
|
||||||
|
agentId?: string;
|
||||||
|
checkId?: string;
|
||||||
|
limit: PageSize;
|
||||||
|
}) {
|
||||||
|
if (!agentId && !checkId) return <div />;
|
||||||
|
|
||||||
return (
|
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 ? (
|
{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}
|
) : null}
|
||||||
{checkId ? (
|
{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}
|
) : 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
|
clear
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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();
|
const qs = new URLSearchParams();
|
||||||
if (params.agent_id) qs.set("agent_id", params.agent_id);
|
if (params.agent_id) qs.set("agent_id", params.agent_id);
|
||||||
if (params.check_id) qs.set("check_id", params.check_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();
|
const query = qs.toString();
|
||||||
return query ? `/events?${query}` : "/events";
|
return query ? `/events?${query}` : "/events";
|
||||||
}
|
}
|
||||||
@@ -122,15 +162,17 @@ function eventsPollUrl({
|
|||||||
cursor,
|
cursor,
|
||||||
agentId,
|
agentId,
|
||||||
checkId,
|
checkId,
|
||||||
|
limit,
|
||||||
}: {
|
}: {
|
||||||
cursor?: string;
|
cursor?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
checkId?: string;
|
checkId?: string;
|
||||||
|
limit: PageSize;
|
||||||
}) {
|
}) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (cursor) qs.set("cursor", cursor);
|
if (cursor) qs.set("cursor", cursor);
|
||||||
if (agentId) qs.set("agent_id", agentId);
|
if (agentId) qs.set("agent_id", agentId);
|
||||||
if (checkId) qs.set("check_id", checkId);
|
if (checkId) qs.set("check_id", checkId);
|
||||||
const query = qs.toString();
|
qs.set("limit", String(limit));
|
||||||
return query ? `/api/poll/events?${query}` : "/api/poll/events";
|
return `/api/poll/events?${qs.toString()}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,22 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
import { AgentLink } from "@/components/AgentLink";
|
||||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
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 { DateTime } from "@/components/TimeZoneControls";
|
||||||
import type { components } from "@/lib/api-types";
|
import type { components } from "@/lib/api-types";
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
import { statusBadgeClass } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
clampPage,
|
||||||
|
pageCountOf,
|
||||||
|
paginate,
|
||||||
|
type PageSize,
|
||||||
|
} from "@/lib/pagination";
|
||||||
import { usePolling } from "@/lib/usePolling";
|
import { usePolling } from "@/lib/usePolling";
|
||||||
import type { InventoryData } from "@/lib/view-types";
|
import type { InventoryData } from "@/lib/view-types";
|
||||||
|
|
||||||
@@ -15,49 +26,68 @@ const STATES = ["all", "open", "resolved"] as const;
|
|||||||
|
|
||||||
type Incident = components["schemas"]["Incident"];
|
type Incident = components["schemas"]["Incident"];
|
||||||
type Agent = components["schemas"]["Agent"];
|
type Agent = components["schemas"]["Agent"];
|
||||||
type IncidentsPageData = { items: Incident[] };
|
type IncidentsData = { items: Incident[] };
|
||||||
type SortField = "status" | "host" | "check" | "opened_at" | "resolved_at";
|
type SortField = "status" | "host" | "check" | "opened_at" | "resolved_at";
|
||||||
type SortDir = "asc" | "desc";
|
|
||||||
|
|
||||||
const stateRank: Record<Incident["state"], number> = { resolved: 0, open: 1 };
|
const stateRank: Record<Incident["state"], number> = { open: 0, resolved: 1 };
|
||||||
const severityRank: Record<Incident["severity"], number> = { unknown: 1, warning: 2, critical: 3 };
|
const severityRank: Record<Incident["severity"], number> = {
|
||||||
|
critical: 0,
|
||||||
|
warning: 1,
|
||||||
|
unknown: 2,
|
||||||
|
};
|
||||||
|
|
||||||
export function IncidentsBrowser({
|
export function IncidentsBrowser({
|
||||||
initialData,
|
initialItems,
|
||||||
initialInventory,
|
initialInventory,
|
||||||
state,
|
state,
|
||||||
}: {
|
}: {
|
||||||
initialData: IncidentsPageData;
|
initialItems: Incident[];
|
||||||
initialInventory: InventoryData;
|
initialInventory: InventoryData;
|
||||||
state?: "open" | "resolved";
|
state?: "open" | "resolved";
|
||||||
}) {
|
}) {
|
||||||
const url = useMemo(() => incidentsPollUrl({ state }), [state]);
|
const url = useMemo(
|
||||||
const { data } = usePolling(initialData, url);
|
() => (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 { data: inventory } = usePolling(initialInventory, "/api/poll/inventory");
|
||||||
const [sort, setSort] = useState<SortField>("status");
|
|
||||||
const [dir, setDir] = useState<SortDir>("desc");
|
|
||||||
const agentsById = useMemo(
|
const agentsById = useMemo(
|
||||||
() => new Map(inventory.agents.map((agent) => [agent.agent_id, agent])),
|
() => new Map(inventory.agents.map((agent) => [agent.agent_id, agent])),
|
||||||
[inventory.agents],
|
[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),
|
() => sortIncidents(data.items, agentsById, sort, dir),
|
||||||
[agentsById, data.items, dir, sort],
|
[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) => {
|
const chooseSort = (field: SortField) => {
|
||||||
if (field === sort) {
|
if (field === sort) {
|
||||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
||||||
return;
|
} else {
|
||||||
}
|
|
||||||
setSort(field);
|
setSort(field);
|
||||||
setDir(fieldDefaultDir(field));
|
setDir(fieldDefaultDir(field));
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pageCount = pageCountOf(sorted.length, pageSize);
|
||||||
|
const currentPage = clampPage(page, pageCount);
|
||||||
|
const visible = paginate(sorted, currentPage, pageSize);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Incidents" count={rows.length} />
|
<PageHeader title="Incidents" count={sorted.length} />
|
||||||
<div className="mb-3 flex gap-3 text-sm">
|
<div className="mb-3 flex items-center justify-between gap-3 text-sm">
|
||||||
|
<div className="flex gap-3">
|
||||||
{STATES.map((s) => {
|
{STATES.map((s) => {
|
||||||
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`;
|
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`;
|
||||||
const active = (s === "all" && !state) || s === state;
|
const active = (s === "all" && !state) || s === state;
|
||||||
@@ -72,47 +102,54 @@ export function IncidentsBrowser({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{rows.length === 0 ? (
|
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||||
|
</div>
|
||||||
|
{sorted.length === 0 ? (
|
||||||
<EmptyPanel />
|
<EmptyPanel />
|
||||||
) : (
|
) : (
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<Th>
|
<Th>
|
||||||
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||||
STATUS
|
STATUS
|
||||||
</SortButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
<Th>
|
<Th>
|
||||||
<SortButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
||||||
HOST
|
HOST
|
||||||
</SortButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
<Th>
|
<Th>
|
||||||
<SortButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
<SortHeaderButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
||||||
CHECK
|
CHECK
|
||||||
</SortButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
<Th>
|
<Th>
|
||||||
<SortButton field="opened_at" current={sort} dir={dir} onClick={chooseSort}>
|
<SortHeaderButton field="opened_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||||
OPENED_AT
|
OPENED_AT
|
||||||
</SortButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
<Th>
|
<Th>
|
||||||
<SortButton field="resolved_at" current={sort} dir={dir} onClick={chooseSort}>
|
<SortHeaderButton field="resolved_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||||
RESOLVED_AT
|
RESOLVED_AT
|
||||||
</SortButton>
|
</SortHeaderButton>
|
||||||
</Th>
|
</Th>
|
||||||
<Th>SUMMARY</Th>
|
<Th>SUMMARY</Th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map(({ incident, agent }) => (
|
{visible.map((incident) => (
|
||||||
<IncidentRow key={incident.id} incident={incident} agent={agent} />
|
<IncidentRow
|
||||||
|
key={incident.id}
|
||||||
|
incident={incident}
|
||||||
|
agent={agentsById.get(incidentAgentId(incident))}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
<span className={statusBadgeClass(incident.severity)}>{incident.severity}</span>
|
||||||
</div>
|
</div>
|
||||||
</Td>
|
</Td>
|
||||||
<Td>
|
<Td>{agentId ? <AgentLink agent={agent} agentId={agentId} /> : "—"}</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>
|
<Td>
|
||||||
<IncidentCheckLink agentId={agentId} checkId={checkId} />
|
<IncidentCheckLink agentId={agentId} checkId={checkId} />
|
||||||
</Td>
|
</Td>
|
||||||
@@ -152,7 +175,7 @@ function IncidentRow({ incident, agent }: { incident: Incident; agent?: Agent })
|
|||||||
<Td>
|
<Td>
|
||||||
<DateTime iso={incident.resolved_at} />
|
<DateTime iso={incident.resolved_at} />
|
||||||
</Td>
|
</Td>
|
||||||
<Td className="max-w-md truncate text-xs text-neutral-400">{incident.summary ?? "—"}</Td>
|
<SummaryCell text={incident.summary} />
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -172,97 +195,6 @@ function IncidentCheckLink({ agentId, checkId }: { agentId: string; checkId: str
|
|||||||
return agentId ? <CheckEventsLink agentId={agentId} checkId={checkId} /> : <span>{checkId}</span>;
|
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 {
|
function incidentAgentId(incident: Incident): string {
|
||||||
return incident.agent_id ?? "";
|
return incident.agent_id ?? "";
|
||||||
}
|
}
|
||||||
@@ -271,6 +203,56 @@ function incidentCheckId(incident: Incident): string {
|
|||||||
return incident.check_id ?? "";
|
return incident.check_id ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function incidentsPollUrl({ state }: { state?: string }) {
|
function fieldDefaultDir(field: SortField): SortDir {
|
||||||
return state ? `/api/poll/incidents?state=${encodeURIComponent(state)}` : "/api/poll/incidents";
|
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";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { ClientPager } from "@/components/ClientPager";
|
||||||
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||||
import { Pager } from "@/components/Pager";
|
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||||
import { DateTime } from "@/components/TimeZoneControls";
|
import { DateTime } from "@/components/TimeZoneControls";
|
||||||
import type { components } from "@/lib/api-types";
|
import type { components } from "@/lib/api-types";
|
||||||
import { statusBadgeClass } from "@/lib/format";
|
import { statusBadgeClass } from "@/lib/format";
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
|
clampPage,
|
||||||
|
pageCountOf,
|
||||||
|
paginate,
|
||||||
|
type PageSize,
|
||||||
|
} from "@/lib/pagination";
|
||||||
import { usePolling } from "@/lib/usePolling";
|
import { usePolling } from "@/lib/usePolling";
|
||||||
|
|
||||||
type OutboxItem = components["schemas"]["NotificationOutboxItem"];
|
type OutboxItem = components["schemas"]["NotificationOutboxItem"];
|
||||||
type OutboxPageData = { items: OutboxItem[]; next_cursor?: string | null };
|
type OutboxData = { items: OutboxItem[] };
|
||||||
|
|
||||||
export function OutboxBrowser({
|
export function OutboxBrowser({ initialItems }: { initialItems: OutboxItem[] }) {
|
||||||
initialData,
|
const { data } = usePolling<OutboxData>({ items: initialItems }, "/api/poll/outbox");
|
||||||
cursor,
|
|
||||||
}: {
|
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||||
initialData: OutboxPageData;
|
const [page, setPage] = useState(1);
|
||||||
cursor?: string;
|
const onPageSizeChange = (next: PageSize) => {
|
||||||
}) {
|
setPageSize(next);
|
||||||
const url = useMemo(() => {
|
setPage(1);
|
||||||
if (!cursor) return "/api/poll/outbox";
|
};
|
||||||
return `/api/poll/outbox?cursor=${encodeURIComponent(cursor)}`;
|
|
||||||
}, [cursor]);
|
const pageCount = pageCountOf(data.items.length, pageSize);
|
||||||
const { data } = usePolling(initialData, url);
|
const currentPage = clampPage(page, pageCount);
|
||||||
|
const visible = paginate(data.items, currentPage, pageSize);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader title="Notification outbox" count={data.items.length} />
|
<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 ? (
|
{data.items.length === 0 ? (
|
||||||
<EmptyPanel />
|
<EmptyPanel />
|
||||||
) : (
|
) : (
|
||||||
@@ -44,7 +56,7 @@ export function OutboxBrowser({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.items.map((o) => (
|
{visible.map((o) => (
|
||||||
<tr key={o.id}>
|
<tr key={o.id}>
|
||||||
<Td>
|
<Td>
|
||||||
<DateTime iso={o.updated_at} />
|
<DateTime iso={o.updated_at} />
|
||||||
@@ -62,7 +74,7 @@ export function OutboxBrowser({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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 Agent = components["schemas"]["Agent"];
|
||||||
export type CheckState = components["schemas"]["CheckState"];
|
export type CheckState = components["schemas"]["CheckState"];
|
||||||
export type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
|
||||||
export type CheckStatus = CheckState["status"];
|
export type CheckStatus = CheckState["status"];
|
||||||
export type CheckCounts = Record<CheckStatus, number>;
|
export type CheckCounts = Record<CheckStatus, number>;
|
||||||
|
|
||||||
@@ -44,67 +43,6 @@ export function groupChecks(checks: CheckState[], agentsById: Map<string, Agent>
|
|||||||
return [...byCheck.values()];
|
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 {
|
export function emptyCheckCounts(): CheckCounts {
|
||||||
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
|
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import "server-only";
|
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 type { components } from "@/lib/api-types";
|
||||||
import { LIVENESS_CHECK_ID, withLivenessChecks, withLivenessEvents } from "@/lib/check-groups";
|
|
||||||
import type { InventoryData, OverviewData } from "@/lib/view-types";
|
import type { InventoryData, OverviewData } from "@/lib/view-types";
|
||||||
|
|
||||||
type Incident = components["schemas"]["Incident"];
|
type Incident = components["schemas"]["Incident"];
|
||||||
@@ -31,10 +30,10 @@ export async function loadChecks(agentId?: string): Promise<CheckState[]> {
|
|||||||
|
|
||||||
export async function loadInventory(): Promise<InventoryData> {
|
export async function loadInventory(): Promise<InventoryData> {
|
||||||
const [agents, checks] = await Promise.all([loadAgents(), loadChecks()]);
|
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,
|
agentId,
|
||||||
checkId,
|
checkId,
|
||||||
cursor,
|
cursor,
|
||||||
@@ -45,28 +44,10 @@ export async function loadEventsWithLiveness({
|
|||||||
cursor?: string;
|
cursor?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
} = {}): Promise<{ items: StoredEvent[]; next_cursor?: string | null }> {
|
} = {}): Promise<{ items: StoredEvent[]; next_cursor?: string | null }> {
|
||||||
const data = await api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, limit });
|
return api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, limit });
|
||||||
if (cursor || (checkId && checkId !== LIVENESS_CHECK_ID)) return data;
|
|
||||||
|
|
||||||
const agents = agentId ? await loadOptionalAgent(agentId) : await loadAgents();
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
items: withLivenessEvents(agents, data.items, { agentId, checkId, cursor }),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadOptionalAgent(agentId: string): Promise<Agent[]> {
|
export async function loadIncidents(state?: "open" | "resolved"): Promise<Incident[]> {
|
||||||
try {
|
|
||||||
return [await api.getAgent(agentId)];
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof ApiError && e.status === 404) return [];
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadIncidents(
|
|
||||||
state?: "open" | "resolved",
|
|
||||||
): Promise<{ items: Incident[] }> {
|
|
||||||
const items: Incident[] = [];
|
const items: Incident[] = [];
|
||||||
let cursor: string | undefined;
|
let cursor: string | undefined;
|
||||||
do {
|
do {
|
||||||
@@ -74,25 +55,46 @@ export async function loadIncidents(
|
|||||||
items.push(...page.items);
|
items.push(...page.items);
|
||||||
cursor = page.next_cursor ?? undefined;
|
cursor = page.next_cursor ?? undefined;
|
||||||
} while (cursor);
|
} 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> {
|
export async function loadOverview(): Promise<OverviewData> {
|
||||||
const [agents, checks, incidents] = await Promise.all([
|
const [agents, checks, openIncidents] = await Promise.all([
|
||||||
loadAgents(),
|
loadAgents(),
|
||||||
loadChecks(),
|
loadChecks(),
|
||||||
api.listIncidents({ state: "open", limit: 500 }),
|
countOpenIncidents(),
|
||||||
]);
|
]);
|
||||||
const checksWithLiveness = withLivenessChecks(agents, checks);
|
|
||||||
return {
|
return {
|
||||||
agents: tally(agents),
|
agents: tally(agents),
|
||||||
checks: tally(checksWithLiveness),
|
checks: tally(checks),
|
||||||
openIncidents: incidents.items.length,
|
openIncidents,
|
||||||
totalAgents: agents.length,
|
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> {
|
function tally(xs: { status?: string }[]): Record<string, number> {
|
||||||
return xs.reduce<Record<string, number>>((acc, x) => {
|
return xs.reduce<Record<string, number>>((acc, x) => {
|
||||||
const k = x.status ?? "unknown";
|
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",
|
incident_key: "agent-1:load",
|
||||||
summary: "load high",
|
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,
|
next_cursor: null,
|
||||||
},
|
},
|
||||||
@@ -122,6 +140,19 @@ const fixtures = {
|
|||||||
output_truncated: false,
|
output_truncated: false,
|
||||||
notifications_enabled: true,
|
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,
|
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("asc");
|
||||||
await expect(page.locator("thead")).not.toContainText("desc");
|
await expect(page.locator("thead")).not.toContainText("desc");
|
||||||
await expect(page.locator("thead")).toContainText("↓");
|
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.locator("body")).toContainText("warn 1");
|
||||||
await expect(page.getByRole("button", { name: /Timezone: default/ })).toBeVisible();
|
await expect(page.getByRole("button", { name: /Timezone: default/ })).toBeVisible();
|
||||||
await page.getByRole("button", { name: /Timezone: default/ }).click();
|
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("host-1");
|
||||||
await expect(page.locator("body")).toContainText("disk ok");
|
await expect(page.locator("body")).toContainText("disk ok");
|
||||||
await page.getByRole("link", { name: "Events" }).last().click();
|
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("body")).toContainText("12");
|
||||||
await expect(page.locator("thead").last()).not.toContainText("exit");
|
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("body")).toContainText("load high");
|
||||||
await expect(page.locator("thead")).toContainText("HOST");
|
await expect(page.locator("thead")).toContainText("HOST");
|
||||||
await expect(page.locator("thead")).not.toContainText("agent_id");
|
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("host-1");
|
||||||
await expect(page.locator("tbody")).toContainText("agent_liveness");
|
await expect(page.locator("tbody")).toContainText("agent_liveness");
|
||||||
await expect(page.locator("tbody")).toContainText("agent is dead");
|
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).toHaveURL(/\/checks\/agent_liveness$/);
|
||||||
await expect(page.locator("body")).toContainText("host-2");
|
await expect(page.locator("body")).toContainText("host-2");
|
||||||
await page.goto("/incidents");
|
await page.goto("/incidents");
|
||||||
await page.getByRole("button", { name: /^HOST/ }).click();
|
await page.getByRole("link", { name: "host-1" }).first().click();
|
||||||
await expect(page).toHaveURL(/\/incidents$/);
|
|
||||||
await expect(page.locator("thead")).toContainText("↑");
|
|
||||||
await page.getByRole("link", { name: "host-1" }).click();
|
|
||||||
await expect(page).toHaveURL(/\/agents\/agent-1$/);
|
await expect(page).toHaveURL(/\/agents\/agent-1$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user