refactor web pagination and add partitioned events, flap/timeout showcase agents

This commit is contained in:
Stanislav Rossovskii
2026-05-27 18:23:31 +04:00
parent d5f312f200
commit a2e88b4e76
46 changed files with 1600 additions and 742 deletions

View File

@@ -1,13 +1,14 @@
import { AgentDetailBrowser } from "@/components/AgentDetailBrowser";
import { ErrorPanel } from "@/components/DataPanel";
import { ApiError, api } from "@/lib/api";
import { withLivenessEvents } from "@/lib/check-groups";
import { loadChecks } from "@/lib/loaders";
import { normalizePageSize } from "@/lib/pagination";
export const dynamic = "force-dynamic";
type SortKey = "severity" | "check_id" | "last_seen";
type TabKey = "checks" | "events";
type EventsPage = Awaited<ReturnType<typeof api.queryEvents>>;
function normalizeSort(sort?: string): SortKey {
return sort === "check_id" || sort === "last_seen" ? sort : "severity";
@@ -21,22 +22,35 @@ export default async function AgentDetailPage({
params,
searchParams,
}: {
params: Promise<{ agent_id: string }>;
searchParams: Promise<{ sort?: string; tab?: string }>;
params: Promise<{
agent_id: string;
}>;
searchParams: Promise<{
sort?: string;
tab?: string;
events_limit?: string;
cursor?: string;
}>;
}) {
const { agent_id } = await params;
const sp = await searchParams;
const sort = normalizeSort(sp.sort);
const tab = normalizeTab(sp.tab);
const eventsLimit = normalizePageSize(sp.events_limit, 50);
const eventsCursor = sp.cursor;
let data;
let agent;
let checks;
let events: EventsPage = { items: [], next_cursor: null };
try {
const [agent, checks, events] = await Promise.all([
api.getAgent(agent_id),
loadChecks(agent_id),
api.queryEvents({ agent_id, limit: 50 }).then((page) => page.items),
]);
data = { agent, checks, events: withLivenessEvents([agent], events, { agentId: agent_id }) };
[agent, checks] = await Promise.all([api.getAgent(agent_id), loadChecks(agent_id)]);
if (tab === "events") {
events = await api.queryEvents({
agent_id,
limit: eventsLimit,
cursor: eventsCursor,
});
}
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
return <div className="text-sm text-neutral-400">Agent not found.</div>;
@@ -45,10 +59,13 @@ export default async function AgentDetailPage({
}
return (
<AgentDetailBrowser
key={`${agent_id}:${tab}:${sort}`}
initialData={data}
key={`${agent_id}:${tab}:${sort}:${eventsLimit}:${eventsCursor ?? ""}`}
initialData={{ agent, checks }}
initialEvents={events}
sort={sort}
tab={tab}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
/>
);
}

View File

@@ -1,7 +1,6 @@
import { NextResponse } from "next/server";
import { ApiError, api } from "@/lib/api";
import { withLivenessEvents } from "@/lib/check-groups";
import { loadChecks } from "@/lib/loaders";
import { jsonError } from "@/lib/poll-response";
@@ -19,16 +18,8 @@ export async function GET(request: Request) {
}
try {
const [agent, checks, events] = await Promise.all([
api.getAgent(agentId),
loadChecks(agentId),
api.queryEvents({ agent_id: agentId, limit: 50 }).then((page) => page.items),
]);
return NextResponse.json({
agent,
checks,
events: withLivenessEvents([agent], events, { agentId }),
});
const [agent, checks] = await Promise.all([api.getAgent(agentId), loadChecks(agentId)]);
return NextResponse.json({ agent, checks });
} catch (e) {
if (e instanceof ApiError && e.status === 404) {
return NextResponse.json(

View File

@@ -1,20 +1,21 @@
import { NextResponse } from "next/server";
import { loadEventsWithLiveness } from "@/lib/loaders";
import { loadEvents } from "@/lib/loaders";
import { normalizePageSize } from "@/lib/pagination";
import { jsonError } from "@/lib/poll-response";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const url = new URL(request.url);
const limit = Number(url.searchParams.get("limit") ?? "100");
const limit = normalizePageSize(url.searchParams.get("limit"), 50);
try {
const data = await loadEventsWithLiveness({
const data = await loadEvents({
agentId: url.searchParams.get("agent_id") ?? undefined,
checkId: url.searchParams.get("check_id") ?? undefined,
cursor: url.searchParams.get("cursor") ?? undefined,
limit: Number.isFinite(limit) ? limit : 100,
limit,
});
return NextResponse.json(data);
} catch (e) {

View File

@@ -11,8 +11,8 @@ export async function GET(request: Request) {
const state = stateParam === "open" || stateParam === "resolved" ? stateParam : undefined;
try {
const data = await loadIncidents(state);
return NextResponse.json(data);
const items = await loadIncidents(state);
return NextResponse.json({ items });
} catch (e) {
return jsonError(e);
}

View File

@@ -1,18 +1,14 @@
import { NextResponse } from "next/server";
import { api } from "@/lib/api";
import { loadOutboxAll } from "@/lib/loaders";
import { jsonError } from "@/lib/poll-response";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const url = new URL(request.url);
export async function GET() {
try {
const data = await api.listOutbox({
cursor: url.searchParams.get("cursor") ?? undefined,
});
return NextResponse.json(data);
const items = await loadOutboxAll();
return NextResponse.json({ items });
} catch (e) {
return jsonError(e);
}

View File

@@ -1,8 +1,8 @@
import { CheckDetailBrowser } from "@/components/CheckDetailBrowser";
import { ErrorPanel } from "@/components/DataPanel";
import { api } from "@/lib/api";
import { withLivenessEvents } from "@/lib/check-groups";
import { loadInventory } from "@/lib/loaders";
import { normalizePageSize } from "@/lib/pagination";
export const dynamic = "force-dynamic";
@@ -18,22 +18,24 @@ export default async function CheckDetailPage({
searchParams,
}: {
params: Promise<{ check_id: string }>;
searchParams: Promise<{ tab?: string }>;
searchParams: Promise<{ tab?: string; events_limit?: string; cursor?: string }>;
}) {
const { check_id } = await params;
const sp = await searchParams;
const tab = normalizeTab(sp.tab);
const eventsLimit = normalizePageSize(sp.events_limit, 50);
const eventsCursor = sp.cursor;
let data;
let events: EventsPage = { items: [], next_cursor: null };
try {
data = await loadInventory();
if (tab === "events") {
const rawEvents = await api.queryEvents({ check_id, limit: 50 });
events = {
...rawEvents,
items: withLivenessEvents(data.agents, rawEvents.items, { checkId: check_id }),
};
events = await api.queryEvents({
check_id,
limit: eventsLimit,
cursor: eventsCursor,
});
}
} catch (e) {
return <ErrorPanel error={e} />;
@@ -41,11 +43,13 @@ export default async function CheckDetailPage({
return (
<CheckDetailBrowser
key={`${check_id}:${tab}`}
key={`${check_id}:${tab}:${eventsLimit}:${eventsCursor ?? ""}`}
checkId={check_id}
initialData={data}
initialEvents={events}
tab={tab}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
/>
);
}

View File

@@ -1,32 +1,41 @@
import { ErrorPanel } from "@/components/DataPanel";
import { EventsBrowser } from "@/components/EventsBrowser";
import { loadEventsWithLiveness } from "@/lib/loaders";
import { loadEvents } from "@/lib/loaders";
import { normalizePageSize } from "@/lib/pagination";
export const dynamic = "force-dynamic";
export default async function EventsPage({
searchParams,
}: {
searchParams: Promise<{ cursor?: string; agent_id?: string; check_id?: string }>;
searchParams: Promise<{
cursor?: string;
agent_id?: string;
check_id?: string;
limit?: string;
}>;
}) {
const sp = await searchParams;
const limit = normalizePageSize(sp.limit);
let data;
try {
data = await loadEventsWithLiveness({
data = await loadEvents({
agentId: sp.agent_id,
checkId: sp.check_id,
cursor: sp.cursor,
limit,
});
} catch (e) {
return <ErrorPanel error={e} />;
}
return (
<EventsBrowser
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}`}
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}:${limit}`}
initialData={data}
cursor={sp.cursor}
agentId={sp.agent_id}
checkId={sp.check_id}
limit={limit}
/>
);
}

View File

@@ -11,17 +11,17 @@ export default async function IncidentsPage({
}) {
const sp = await searchParams;
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
let data;
let items;
let inventory;
try {
[data, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
[items, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
} catch (e) {
return <ErrorPanel error={e} />;
}
return (
<IncidentsBrowser
key={state ?? ""}
initialData={data}
initialItems={items}
initialInventory={inventory}
state={state}
/>

View File

@@ -1,20 +1,15 @@
import { ErrorPanel } from "@/components/DataPanel";
import { OutboxBrowser } from "@/components/OutboxBrowser";
import { api } from "@/lib/api";
import { loadOutboxAll } from "@/lib/loaders";
export const dynamic = "force-dynamic";
export default async function OutboxPage({
searchParams,
}: {
searchParams: Promise<{ cursor?: string }>;
}) {
const sp = await searchParams;
let data;
export default async function OutboxPage() {
let items;
try {
data = await api.listOutbox({ cursor: sp.cursor });
items = await loadOutboxAll();
} catch (e) {
return <ErrorPanel error={e} />;
}
return <OutboxBrowser key={sp.cursor ?? ""} initialData={data} cursor={sp.cursor} />;
return <OutboxBrowser initialItems={items} />;
}

View File

@@ -1,28 +1,37 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { AgentFeatures } from "@/components/AgentFeatures";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { Td, Th } from "@/components/DataPanel";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, SummaryCell, Td, Th } from "@/components/DataPanel";
import { DetailTabs } from "@/components/DetailTabs";
import { LabelChips } from "@/components/Labels";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { Pager } from "@/components/Pager";
import { DateTime } from "@/components/TimeZoneControls";
import type { components } from "@/lib/api-types";
import { withLivenessChecks } from "@/lib/check-groups";
import { statusBadgeClass } from "@/lib/format";
import {
DEFAULT_PAGE_SIZE,
clampPage,
pageCountOf,
paginate,
type PageSize,
} from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
type Agent = components["schemas"]["Agent"];
type CheckState = components["schemas"]["CheckState"];
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
type SortKey = "severity" | "check_id" | "last_seen";
type TabKey = "checks" | "events";
type AgentDetailData = {
agent: Agent;
checks: CheckState[];
events: StoredEvent[];
};
type AgentDetailData = { agent: Agent; checks: CheckState[] };
type AgentPollPayload = AgentDetailData & { events?: StoredEvent[] };
const severityRank: Record<CheckState["status"], number> = {
critical: 0,
@@ -33,22 +42,24 @@ const severityRank: Record<CheckState["status"], number> = {
export function AgentDetailBrowser({
initialData,
initialEvents,
sort,
tab,
eventsLimit,
eventsCursor,
}: {
initialData: AgentDetailData;
initialEvents: EventsPageData;
sort: SortKey;
tab: TabKey;
eventsLimit: PageSize;
eventsCursor?: string;
}) {
const { data } = usePolling(
initialData,
`/api/poll/agent?agent_id=${encodeURIComponent(initialData.agent.agent_id)}`,
);
const { agent, events } = data;
const checks = useMemo(
() => sortChecks(withLivenessChecks([agent], data.checks), sort),
[agent, data.checks, sort],
);
const agentId = initialData.agent.agent_id;
const pollUrl = `/api/poll/agent?agent_id=${encodeURIComponent(agentId)}`;
const { data } = usePolling<AgentPollPayload>(initialData, pollUrl);
const agent = data.agent;
const checks = useMemo(() => sortChecks(data.checks, sort), [data.checks, sort]);
return (
<div className="space-y-6">
@@ -76,28 +87,32 @@ export function AgentDetailBrowser({
<LabelChips labels={agent.labels} />
</dd>
</dl>
<TabNav agentId={agent.agent_id} current={tab} sort={sort} />
{tab === "checks" ? <ChecksTable agentId={agent.agent_id} checks={checks} sort={sort} /> : <EventsTable events={events} />}
</div>
);
}
function TabNav({ agentId, current, sort }: { agentId: string; current: TabKey; sort: SortKey }) {
const base = `/agents/${encodeURIComponent(agentId)}`;
return (
<div className="flex gap-4 border-b border-neutral-800 text-sm">
<Link
href={`${base}?tab=checks&sort=${sort}`}
className={`pb-2 ${current === "checks" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
>
Checks
</Link>
<Link
href={`${base}?tab=events&sort=${sort}`}
className={`pb-2 ${current === "events" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
>
Events
</Link>
<DetailTabs
current={tab}
tabs={[
{
key: "checks",
label: "Checks",
href: `/agents/${encodeURIComponent(agentId)}?tab=checks&sort=${sort}`,
},
{
key: "events",
label: "Events",
href: `/agents/${encodeURIComponent(agentId)}?tab=events&sort=${sort}&events_limit=${eventsLimit}`,
},
]}
/>
{tab === "checks" ? (
<ChecksTable agentId={agentId} checks={checks} sort={sort} />
) : (
<EventsTable
agentId={agentId}
sort={sort}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
initialEvents={initialEvents}
/>
)}
</div>
);
}
@@ -111,9 +126,22 @@ function ChecksTable({
checks: CheckState[];
sort: SortKey;
}) {
if (checks.length === 0) return <div className="text-sm text-neutral-500">No checks.</div>;
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
};
const pageCount = pageCountOf(checks.length, pageSize);
const currentPage = clampPage(page, pageCount);
const visible = paginate(checks, currentPage, pageSize);
if (checks.length === 0) return <EmptyPanel label="No checks" />;
return (
<section>
<div className="mb-3 flex justify-end">
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
</div>
<table className="w-full text-sm">
<thead>
<tr>
@@ -136,7 +164,7 @@ function ChecksTable({
</tr>
</thead>
<tbody>
{checks.map((c) => (
{visible.map((c) => (
<tr key={c.check_id}>
<Td>
<CheckEventsLink agentId={agentId} checkId={c.check_id} />
@@ -145,43 +173,81 @@ function ChecksTable({
<Td>
<DateTime iso={c.last_observed_at} />
</Td>
<Td className="max-w-md truncate text-xs text-neutral-400">{c.summary ?? "—"}</Td>
<SummaryCell text={c.summary} />
</tr>
))}
</tbody>
</table>
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
</section>
);
}
function EventsTable({ events }: { events: StoredEvent[] }) {
if (events.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
function EventsTable({
agentId,
sort,
eventsLimit,
eventsCursor,
initialEvents,
}: {
agentId: string;
sort: SortKey;
eventsLimit: PageSize;
eventsCursor?: string;
initialEvents: EventsPageData;
}) {
const router = useRouter();
const pollUrl = useMemo(() => {
const qs = new URLSearchParams({ agent_id: agentId, limit: String(eventsLimit) });
if (eventsCursor) qs.set("cursor", eventsCursor);
return `/api/poll/events?${qs.toString()}`;
}, [agentId, eventsCursor, eventsLimit]);
const { data } = usePolling(initialEvents, pollUrl);
const onLimitChange = (next: PageSize) => {
const qs = new URLSearchParams({ tab: "events", sort, events_limit: String(next) });
router.push(`/agents/${encodeURIComponent(agentId)}?${qs.toString()}`);
};
return (
<section>
<table className="w-full text-sm">
<thead>
<tr>
<Th>observed_at</Th>
<Th>check_id</Th>
<Th>status</Th>
<Th>duration_ms</Th>
</tr>
</thead>
<tbody>
{events.map((e) => (
<tr key={e.event_id}>
<Td>
<DateTime iso={e.observed_at} />
</Td>
<Td>
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
</Td>
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
<Td>{e.duration_ms}</Td>
<div className="mb-3 flex justify-end">
<PageSizeSelect value={eventsLimit} onChange={onLimitChange} />
</div>
{data.items.length === 0 ? (
<EmptyPanel label="No events" />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>observed_at</Th>
<Th>check_id</Th>
<Th>status</Th>
<Th>duration_ms</Th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{data.items.map((e) => (
<tr key={e.event_id}>
<Td>
<DateTime iso={e.observed_at} />
</Td>
<Td>
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
</Td>
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
<Td>{e.duration_ms}</Td>
</tr>
))}
</tbody>
</table>
)}
<Pager
basePath={`/agents/${encodeURIComponent(agentId)}`}
cursor={eventsCursor}
nextCursor={data.next_cursor}
extra={{ tab: "events", sort, events_limit: String(eventsLimit) }}
/>
</section>
);
}

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

View File

@@ -1,13 +1,24 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { AgentFeatures } from "@/components/AgentFeatures";
import { AgentLink } from "@/components/AgentLink";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, Td, Th } from "@/components/DataPanel";
import { LabelChips } from "@/components/Labels";
import { ListToolbar } from "@/components/ListToolbar";
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
import { DateTime } from "@/components/TimeZoneControls";
import type { components } from "@/lib/api-types";
import { statusBadgeClass } from "@/lib/format";
import {
DEFAULT_PAGE_SIZE,
clampPage,
pageCountOf,
paginate,
type PageSize,
} from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types";
@@ -16,7 +27,6 @@ type CheckState = components["schemas"]["CheckState"];
type CheckStatus = CheckState["status"];
type CheckCounts = Record<CheckStatus, number>;
type SortField = "host" | "status" | "last_seen_at";
type SortDir = "asc" | "desc";
export type AgentRow = { agent: Agent; checks: CheckCounts };
@@ -36,77 +46,75 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
[dir, initialRows, query, sort],
);
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
const pageCount = pageCountOf(rows.length, pageSize);
const currentPage = clampPage(page, pageCount);
const visible = paginate(rows, currentPage, pageSize);
const onQueryChange = (value: string) => {
setQuery(value);
setPage(1);
};
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
};
const chooseSort = (field: SortField) => {
if (field === sort) {
setDir((current) => (current === "asc" ? "desc" : "asc"));
return;
} else {
setSort(field);
setDir(fieldDefaultDir(field));
}
setSort(field);
setDir(fieldDefaultDir(field));
setPage(1);
};
return (
<>
<div className="mb-4 flex items-baseline gap-3">
<h1 className="text-lg font-semibold">Agents</h1>
<span className="text-sm text-neutral-400">{rows.length} items</span>
</div>
<div className="mb-4 flex max-w-md gap-2">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="search agent"
className="min-w-0 flex-1 border border-neutral-800 bg-neutral-950 px-2 py-1.5 text-sm text-neutral-100 outline-none focus:border-neutral-500"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="border border-neutral-900 px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-300"
>
clear
</button>
) : null}
<span className="text-sm text-neutral-400">
{rows.length} of {initialRows.length} items
</span>
</div>
<ListToolbar
query={query}
onQueryChange={onQueryChange}
placeholder="search agent"
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
/>
{rows.length === 0 ? (
<div className="text-sm text-neutral-500 italic">No data.</div>
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>
<SortButton field="host" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
HOST
</SortButton>
</SortHeaderButton>
</Th>
<Th>
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
STATUS
</SortButton>
</SortHeaderButton>
</Th>
<Th>
<SortButton field="last_seen_at" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="last_seen_at" current={sort} dir={dir} onClick={chooseSort}>
LAST_SEEN_AT
</SortButton>
</SortHeaderButton>
</Th>
<Th>features</Th>
<Th>labels</Th>
</tr>
</thead>
<tbody>
{rows.map(({ agent: a, checks }) => (
{visible.map(({ agent: a, checks }) => (
<tr key={a.agent_id}>
<Td>
<Link
href={`/agents/${encodeURIComponent(a.agent_id)}`}
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
>
<span>{a.hostname}</span>
{a.agent_id !== a.hostname ? (
<span className="text-xs text-neutral-500">{a.agent_id}</span>
) : null}
</Link>
<AgentLink agent={a} agentId={a.agent_id} />
</Td>
<Td>
<AgentStatus status={a.status} checks={checks} />
@@ -125,50 +133,11 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
</tbody>
</table>
)}
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
</>
);
}
function Th({ children }: { children: React.ReactNode }) {
return (
<th className="text-left text-xs font-semibold uppercase tracking-wide text-neutral-400 border-b border-neutral-800 px-2 py-2">
{children}
</th>
);
}
function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return (
<td className={`px-2 py-1.5 border-b border-neutral-900 align-top ${className}`}>{children}</td>
);
}
function SortButton({
field,
current,
dir,
onClick,
children,
}: {
field: SortField;
current: SortField;
dir: SortDir;
onClick: (field: SortField) => void;
children: React.ReactNode;
}) {
const active = field === current;
return (
<button
type="button"
onClick={() => onClick(field)}
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
>
<span>{children}</span>
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
</button>
);
}
function filterAgents(rows: AgentRow[], query: string): AgentRow[] {
const needle = query.trim().toLowerCase();
if (!needle) return rows;

View File

@@ -1,20 +1,27 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { AgentLink } from "@/components/AgentLink";
import { CheckStatusSummary } from "@/components/CheckStatusSummary";
import { Td, Th } from "@/components/DataPanel";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, SummaryCell, Td, Th } from "@/components/DataPanel";
import { DetailTabs } from "@/components/DetailTabs";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { Pager } from "@/components/Pager";
import { DateTime } from "@/components/TimeZoneControls";
import type { components } from "@/lib/api-types";
import {
groupChecks,
statusSeverity,
withLivenessChecks,
type Agent,
type CheckGroup,
} from "@/lib/check-groups";
import { groupChecks, statusSeverity, type Agent, type CheckGroup } from "@/lib/check-groups";
import { statusBadgeClass } from "@/lib/format";
import {
DEFAULT_PAGE_SIZE,
clampPage,
pageCountOf,
paginate,
type PageSize,
} from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types";
@@ -27,21 +34,24 @@ export function CheckDetailBrowser({
initialData,
initialEvents,
tab,
eventsLimit,
eventsCursor,
}: {
checkId: string;
initialData: InventoryData;
initialEvents: EventsPageData;
tab: TabKey;
eventsLimit: PageSize;
eventsCursor?: string;
}) {
const { data } = usePolling(initialData, "/api/poll/inventory");
const agentsById = useMemo(() => new Map(data.agents.map((agent) => [agent.agent_id, agent])), [data.agents]);
const checks = useMemo(
() => withLivenessChecks(data.agents, data.checks),
[data.agents, data.checks],
const agentsById = useMemo(
() => new Map(data.agents.map((agent) => [agent.agent_id, agent])),
[data.agents],
);
const group = useMemo(
() => groupChecks(checks, agentsById).find((item) => item.checkId === checkId),
[agentsById, checkId, checks],
() => groupChecks(data.checks, agentsById).find((item) => item.checkId === checkId),
[agentsById, checkId, data.checks],
);
if (!group) {
@@ -76,76 +86,88 @@ export function CheckDetailBrowser({
<DateTime iso={group.lastObservedAt} />
</dd>
</dl>
<TabNav checkId={checkId} current={tab} />
<DetailTabs
current={tab}
tabs={[
{
key: "agents",
label: "Agents",
href: `/checks/${encodeURIComponent(checkId)}?tab=agents`,
},
{
key: "events",
label: "Events",
href: `/checks/${encodeURIComponent(checkId)}?tab=events&events_limit=${eventsLimit}`,
},
]}
/>
{tab === "agents" ? (
<AgentsForCheck group={group} />
) : (
<EventsForCheck checkId={checkId} initialData={initialEvents} agentsById={agentsById} />
<EventsForCheck
checkId={checkId}
initialData={initialEvents}
agentsById={agentsById}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
/>
)}
</div>
);
}
function TabNav({ checkId, current }: { checkId: string; current: TabKey }) {
const base = `/checks/${encodeURIComponent(checkId)}`;
return (
<div className="flex gap-4 border-b border-neutral-800 text-sm">
<Link
href={`${base}?tab=agents`}
className={`pb-2 ${current === "agents" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
>
Agents
</Link>
<Link
href={`${base}?tab=events`}
className={`pb-2 ${current === "events" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
>
Events
</Link>
</div>
);
}
function AgentsForCheck({ group }: { group: CheckGroup }) {
const rows = [...group.checks].sort(
(a, b) =>
statusSeverity[b.check.status] - statusSeverity[a.check.status] ||
hostName(a).localeCompare(hostName(b)),
const rows = useMemo(
() =>
[...group.checks].sort(
(a, b) =>
statusSeverity[b.check.status] - statusSeverity[a.check.status] ||
hostName(a).localeCompare(hostName(b)),
),
[group.checks],
);
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
};
const pageCount = pageCountOf(rows.length, pageSize);
const currentPage = clampPage(page, pageCount);
const visible = paginate(rows, currentPage, pageSize);
return (
<table className="w-full text-sm">
<thead>
<tr>
<Th>HOST</Th>
<Th>STATUS</Th>
<Th>LAST_OBSERVED_AT</Th>
<Th>SUMMARY</Th>
</tr>
</thead>
<tbody>
{rows.map(({ check, agent }) => (
<tr key={check.agent_id}>
<Td>
<Link
href={`/agents/${encodeURIComponent(check.agent_id)}`}
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
>
<span>{agent?.hostname || check.agent_id}</span>
{agent && agent.hostname !== check.agent_id ? (
<span className="text-xs text-neutral-500">{check.agent_id}</span>
) : null}
</Link>
</Td>
<Td className={statusBadgeClass(check.status)}>{check.status}</Td>
<Td>
<DateTime iso={check.last_observed_at} />
</Td>
<Td className="max-w-md truncate text-xs text-neutral-400">{check.summary ?? "—"}</Td>
<section>
<div className="mb-3 flex justify-end">
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
</div>
<table className="w-full text-sm">
<thead>
<tr>
<Th>HOST</Th>
<Th>STATUS</Th>
<Th>LAST_OBSERVED_AT</Th>
<Th>SUMMARY</Th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{visible.map(({ check, agent }) => (
<tr key={check.agent_id}>
<Td>
<AgentLink agent={agent} agentId={check.agent_id} />
</Td>
<Td className={statusBadgeClass(check.status)}>{check.status}</Td>
<Td>
<DateTime iso={check.last_observed_at} />
</Td>
<SummaryCell text={check.summary} />
</tr>
))}
</tbody>
</table>
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
</section>
);
}
@@ -153,57 +175,81 @@ function EventsForCheck({
checkId,
initialData,
agentsById,
eventsLimit,
eventsCursor,
}: {
checkId: string;
initialData: EventsPageData;
agentsById: Map<string, Agent>;
eventsLimit: PageSize;
eventsCursor?: string;
}) {
const { data } = usePolling(
initialData,
`/api/poll/events?check_id=${encodeURIComponent(checkId)}&limit=50`,
);
const router = useRouter();
const pollUrl = useMemo(() => {
const qs = new URLSearchParams({
check_id: checkId,
limit: String(eventsLimit),
});
if (eventsCursor) qs.set("cursor", eventsCursor);
return `/api/poll/events?${qs.toString()}`;
}, [checkId, eventsCursor, eventsLimit]);
const { data } = usePolling(initialData, pollUrl);
if (data.items.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
const onLimitChange = (next: PageSize) => {
router.push(
`/checks/${encodeURIComponent(checkId)}?tab=events&events_limit=${next}`,
);
};
return (
<table className="w-full text-sm">
<thead>
<tr>
<Th>OBSERVED_AT</Th>
<Th>RECEIVED_AT</Th>
<Th>HOST</Th>
<Th>STATUS</Th>
<Th>DURATION_MS</Th>
<Th>SUMMARY</Th>
</tr>
</thead>
<tbody>
{data.items.map((event) => {
const agent = agentsById.get(event.agent_id);
return (
<tr key={event.event_id}>
<Td>
<DateTime iso={event.observed_at} />
</Td>
<Td>
<DateTime iso={event.received_at} />
</Td>
<Td>
<Link
href={`/agents/${encodeURIComponent(event.agent_id)}`}
className="text-blue-300 hover:text-blue-200"
>
{agent?.hostname || event.agent_id}
</Link>
</Td>
<Td className={statusBadgeClass(event.status)}>{event.status}</Td>
<Td>{event.duration_ms}</Td>
<Td className="max-w-md truncate text-xs text-neutral-400">{event.output ?? "—"}</Td>
<section>
<div className="mb-3 flex justify-end">
<PageSizeSelect value={eventsLimit} onChange={onLimitChange} />
</div>
{data.items.length === 0 ? (
<EmptyPanel label="No events" />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>OBSERVED_AT</Th>
<Th>RECEIVED_AT</Th>
<Th>HOST</Th>
<Th>STATUS</Th>
<Th>DURATION_MS</Th>
<Th>SUMMARY</Th>
</tr>
);
})}
</tbody>
</table>
</thead>
<tbody>
{data.items.map((event) => {
const agent = agentsById.get(event.agent_id);
return (
<tr key={event.event_id}>
<Td>
<DateTime iso={event.observed_at} />
</Td>
<Td>
<DateTime iso={event.received_at} />
</Td>
<Td>
<AgentLink agent={agent} agentId={event.agent_id} />
</Td>
<Td className={statusBadgeClass(event.status)}>{event.status}</Td>
<Td>{event.duration_ms}</Td>
<SummaryCell text={event.output} />
</tr>
);
})}
</tbody>
</table>
)}
<Pager
basePath={`/checks/${encodeURIComponent(checkId)}`}
cursor={eventsCursor}
nextCursor={data.next_cursor}
extra={{ tab: "events", events_limit: String(eventsLimit) }}
/>
</section>
);
}

View File

@@ -4,17 +4,27 @@ import Link from "next/link";
import { useMemo, useState } from "react";
import { CheckStatusSummary } from "@/components/CheckStatusSummary";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, Td, Th } from "@/components/DataPanel";
import { ListToolbar } from "@/components/ListToolbar";
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
import { DateTime } from "@/components/TimeZoneControls";
import {
compareStatusCounts,
groupChecks,
type CheckGroup,
} from "@/lib/check-groups";
import {
DEFAULT_PAGE_SIZE,
clampPage,
pageCountOf,
paginate,
type PageSize,
} from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types";
type SortField = "check" | "status" | "last_observed_at";
type SortDir = "asc" | "desc";
export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
const { data } = usePolling(initialData, "/api/poll/inventory");
@@ -29,65 +39,71 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
[dir, groups, query, sort],
);
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
const pageCount = pageCountOf(rows.length, pageSize);
const currentPage = clampPage(page, pageCount);
const visible = paginate(rows, currentPage, pageSize);
const onQueryChange = (value: string) => {
setQuery(value);
setPage(1);
};
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
};
const chooseSort = (field: SortField) => {
if (field === sort) {
setDir((current) => (current === "asc" ? "desc" : "asc"));
return;
} else {
setSort(field);
setDir(fieldDefaultDir(field));
}
setSort(field);
setDir(fieldDefaultDir(field));
setPage(1);
};
return (
<>
<div className="mb-4 flex items-baseline gap-3">
<h1 className="text-lg font-semibold">Checks</h1>
<span className="text-sm text-neutral-400">{rows.length} items</span>
</div>
<div className="mb-4 flex max-w-md gap-2">
<input
type="search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="search check"
className="min-w-0 flex-1 border border-neutral-800 bg-neutral-950 px-2 py-1.5 text-sm text-neutral-100 outline-none focus:border-neutral-500"
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
className="border border-neutral-900 px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-300"
>
clear
</button>
) : null}
<span className="text-sm text-neutral-400">
{rows.length} of {groups.length} items
</span>
</div>
<ListToolbar
query={query}
onQueryChange={onQueryChange}
placeholder="search check"
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
/>
{rows.length === 0 ? (
<div className="text-sm text-neutral-500 italic">No data.</div>
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>
<SortButton field="check" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="check" current={sort} dir={dir} onClick={chooseSort}>
CHECK
</SortButton>
</SortHeaderButton>
</Th>
<Th>
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
STATUS
</SortButton>
</SortHeaderButton>
</Th>
<Th>
<SortButton field="last_observed_at" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="last_observed_at" current={sort} dir={dir} onClick={chooseSort}>
LAST_OBSERVED_AT
</SortButton>
</SortHeaderButton>
</Th>
<Th>AGENTS</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
{visible.map((row) => (
<tr key={row.checkId}>
<Td>
<Link
@@ -109,48 +125,11 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
</tbody>
</table>
)}
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
</>
);
}
function Th({ children }: { children: React.ReactNode }) {
return (
<th className="border-b border-neutral-800 px-2 py-2 text-left text-xs font-semibold uppercase tracking-wide text-neutral-400">
{children}
</th>
);
}
function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) {
return <td className={`border-b border-neutral-900 px-2 py-1.5 align-top ${className}`}>{children}</td>;
}
function SortButton({
field,
current,
dir,
onClick,
children,
}: {
field: SortField;
current: SortField;
dir: SortDir;
onClick: (field: SortField) => void;
children: React.ReactNode;
}) {
const active = field === current;
return (
<button
type="button"
onClick={() => onClick(field)}
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
>
<span>{children}</span>
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
</button>
);
}
function filterChecks(rows: CheckGroup[], query: string): CheckGroup[] {
const needle = query.trim().toLowerCase();
if (!needle) return rows;

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

View File

@@ -39,3 +39,7 @@ export function Td({ children, className = "" }: { children: React.ReactNode; cl
<td className={`px-2 py-1.5 border-b border-neutral-900 align-top ${className}`}>{children}</td>
);
}
export function SummaryCell({ text }: { text?: string | null }) {
return <Td className="max-w-md truncate text-xs text-neutral-400">{text ?? "—"}</Td>;
}

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

View File

@@ -1,14 +1,17 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { Pager } from "@/components/Pager";
import { DateTime } from "@/components/TimeZoneControls";
import type { components } from "@/lib/api-types";
import { statusBadgeClass } from "@/lib/format";
import { type PageSize } from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
@@ -19,19 +22,32 @@ export function EventsBrowser({
cursor,
agentId,
checkId,
limit,
}: {
initialData: EventsPageData;
cursor?: string;
agentId?: string;
checkId?: string;
limit: PageSize;
}) {
const url = useMemo(() => eventsPollUrl({ cursor, agentId, checkId }), [agentId, checkId, cursor]);
const router = useRouter();
const url = useMemo(
() => eventsPollUrl({ cursor, agentId, checkId, limit }),
[agentId, checkId, cursor, limit],
);
const { data } = usePolling(initialData, url);
const onLimitChange = (next: PageSize) => {
router.push(eventsHref({ agent_id: agentId, check_id: checkId, limit: next }));
};
return (
<>
<PageHeader title="Events" count={data.items.length} />
<ActiveFilters agentId={agentId} checkId={checkId} />
<div className="mb-4 flex items-center justify-between gap-3">
<ActiveFilters agentId={agentId} checkId={checkId} limit={limit} />
<PageSizeSelect value={limit} onChange={onLimitChange} />
</div>
{data.items.length === 0 ? (
<EmptyPanel />
) : (
@@ -62,7 +78,7 @@ export function EventsBrowser({
</Td>
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
<Td>{e.duration_ms}</Td>
<Td className="max-w-md truncate text-xs text-neutral-400">{e.output ?? "—"}</Td>
<SummaryCell text={e.output} />
</tr>
))}
</tbody>
@@ -72,24 +88,43 @@ export function EventsBrowser({
basePath="/events"
cursor={cursor}
nextCursor={data.next_cursor}
extra={{ agent_id: agentId, check_id: checkId }}
extra={{ agent_id: agentId, check_id: checkId, limit: String(limit) }}
/>
</>
);
}
function ActiveFilters({ agentId, checkId }: { agentId?: string; checkId?: string }) {
if (!agentId && !checkId) return null;
function ActiveFilters({
agentId,
checkId,
limit,
}: {
agentId?: string;
checkId?: string;
limit: PageSize;
}) {
if (!agentId && !checkId) return <div />;
return (
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
<div className="flex flex-wrap items-center gap-2 text-xs">
{agentId ? (
<FilterChip label="agent_id" value={agentId} href={eventsHref({ check_id: checkId })} />
<FilterChip
label="agent_id"
value={agentId}
href={eventsHref({ check_id: checkId, limit })}
/>
) : null}
{checkId ? (
<FilterChip label="check_id" value={checkId} href={eventsHref({ agent_id: agentId })} />
<FilterChip
label="check_id"
value={checkId}
href={eventsHref({ agent_id: agentId, limit })}
/>
) : null}
<Link href="/events" className="text-neutral-500 hover:text-neutral-300">
<Link
href={eventsHref({ limit })}
className="text-neutral-500 hover:text-neutral-300"
>
clear
</Link>
</div>
@@ -110,10 +145,15 @@ function FilterChip({ label, value, href }: { label: string; value: string; href
);
}
function eventsHref(params: { agent_id?: string; check_id?: string }): string {
function eventsHref(params: {
agent_id?: string;
check_id?: string;
limit?: PageSize;
}): string {
const qs = new URLSearchParams();
if (params.agent_id) qs.set("agent_id", params.agent_id);
if (params.check_id) qs.set("check_id", params.check_id);
if (params.limit) qs.set("limit", String(params.limit));
const query = qs.toString();
return query ? `/events?${query}` : "/events";
}
@@ -122,15 +162,17 @@ function eventsPollUrl({
cursor,
agentId,
checkId,
limit,
}: {
cursor?: string;
agentId?: string;
checkId?: string;
limit: PageSize;
}) {
const qs = new URLSearchParams();
if (cursor) qs.set("cursor", cursor);
if (agentId) qs.set("agent_id", agentId);
if (checkId) qs.set("check_id", checkId);
const query = qs.toString();
return query ? `/api/poll/events?${query}` : "/api/poll/events";
qs.set("limit", String(limit));
return `/api/poll/events?${qs.toString()}`;
}

View File

@@ -3,11 +3,22 @@
import Link from "next/link";
import { useMemo, useState } from "react";
import { AgentLink } from "@/components/AgentLink";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
import { DateTime } from "@/components/TimeZoneControls";
import type { components } from "@/lib/api-types";
import { statusBadgeClass } from "@/lib/format";
import {
DEFAULT_PAGE_SIZE,
clampPage,
pageCountOf,
paginate,
type PageSize,
} from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types";
@@ -15,104 +26,130 @@ const STATES = ["all", "open", "resolved"] as const;
type Incident = components["schemas"]["Incident"];
type Agent = components["schemas"]["Agent"];
type IncidentsPageData = { items: Incident[] };
type IncidentsData = { items: Incident[] };
type SortField = "status" | "host" | "check" | "opened_at" | "resolved_at";
type SortDir = "asc" | "desc";
const stateRank: Record<Incident["state"], number> = { resolved: 0, open: 1 };
const severityRank: Record<Incident["severity"], number> = { unknown: 1, warning: 2, critical: 3 };
const stateRank: Record<Incident["state"], number> = { open: 0, resolved: 1 };
const severityRank: Record<Incident["severity"], number> = {
critical: 0,
warning: 1,
unknown: 2,
};
export function IncidentsBrowser({
initialData,
initialItems,
initialInventory,
state,
}: {
initialData: IncidentsPageData;
initialItems: Incident[];
initialInventory: InventoryData;
state?: "open" | "resolved";
}) {
const url = useMemo(() => incidentsPollUrl({ state }), [state]);
const { data } = usePolling(initialData, url);
const url = useMemo(
() => (state ? `/api/poll/incidents?state=${state}` : "/api/poll/incidents"),
[state],
);
const { data } = usePolling<IncidentsData>({ items: initialItems }, url);
const { data: inventory } = usePolling(initialInventory, "/api/poll/inventory");
const [sort, setSort] = useState<SortField>("status");
const [dir, setDir] = useState<SortDir>("desc");
const agentsById = useMemo(
() => new Map(inventory.agents.map((agent) => [agent.agent_id, agent])),
[inventory.agents],
);
const rows = useMemo(
const [sort, setSort] = useState<SortField>("status");
const [dir, setDir] = useState<SortDir>("asc");
const sorted = useMemo(
() => sortIncidents(data.items, agentsById, sort, dir),
[agentsById, data.items, dir, sort],
);
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
};
const chooseSort = (field: SortField) => {
if (field === sort) {
setDir((current) => (current === "asc" ? "desc" : "asc"));
return;
} else {
setSort(field);
setDir(fieldDefaultDir(field));
}
setSort(field);
setDir(fieldDefaultDir(field));
setPage(1);
};
const pageCount = pageCountOf(sorted.length, pageSize);
const currentPage = clampPage(page, pageCount);
const visible = paginate(sorted, currentPage, pageSize);
return (
<>
<PageHeader title="Incidents" count={rows.length} />
<div className="mb-3 flex gap-3 text-sm">
{STATES.map((s) => {
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`;
const active = (s === "all" && !state) || s === state;
return (
<Link
key={s}
href={href}
className={active ? "text-white underline" : "text-neutral-400 hover:text-white"}
>
{s}
</Link>
);
})}
<PageHeader title="Incidents" count={sorted.length} />
<div className="mb-3 flex items-center justify-between gap-3 text-sm">
<div className="flex gap-3">
{STATES.map((s) => {
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`;
const active = (s === "all" && !state) || s === state;
return (
<Link
key={s}
href={href}
className={active ? "text-white underline" : "text-neutral-400 hover:text-white"}
>
{s}
</Link>
);
})}
</div>
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
</div>
{rows.length === 0 ? (
{sorted.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
STATUS
</SortButton>
</SortHeaderButton>
</Th>
<Th>
<SortButton field="host" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
HOST
</SortButton>
</SortHeaderButton>
</Th>
<Th>
<SortButton field="check" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="check" current={sort} dir={dir} onClick={chooseSort}>
CHECK
</SortButton>
</SortHeaderButton>
</Th>
<Th>
<SortButton field="opened_at" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="opened_at" current={sort} dir={dir} onClick={chooseSort}>
OPENED_AT
</SortButton>
</SortHeaderButton>
</Th>
<Th>
<SortButton field="resolved_at" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="resolved_at" current={sort} dir={dir} onClick={chooseSort}>
RESOLVED_AT
</SortButton>
</SortHeaderButton>
</Th>
<Th>SUMMARY</Th>
</tr>
</thead>
<tbody>
{rows.map(({ incident, agent }) => (
<IncidentRow key={incident.id} incident={incident} agent={agent} />
{visible.map((incident) => (
<IncidentRow
key={incident.id}
incident={incident}
agent={agentsById.get(incidentAgentId(incident))}
/>
))}
</tbody>
</table>
)}
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
</>
);
}
@@ -128,21 +165,7 @@ function IncidentRow({ incident, agent }: { incident: Incident; agent?: Agent })
<span className={statusBadgeClass(incident.severity)}>{incident.severity}</span>
</div>
</Td>
<Td>
{agentId ? (
<Link
href={`/agents/${encodeURIComponent(agentId)}`}
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
>
<span>{agent?.hostname || agentId}</span>
{agent && agent.hostname !== agentId ? (
<span className="text-xs text-neutral-500">{agentId}</span>
) : null}
</Link>
) : (
"—"
)}
</Td>
<Td>{agentId ? <AgentLink agent={agent} agentId={agentId} /> : "—"}</Td>
<Td>
<IncidentCheckLink agentId={agentId} checkId={checkId} />
</Td>
@@ -152,7 +175,7 @@ function IncidentRow({ incident, agent }: { incident: Incident; agent?: Agent })
<Td>
<DateTime iso={incident.resolved_at} />
</Td>
<Td className="max-w-md truncate text-xs text-neutral-400">{incident.summary ?? "—"}</Td>
<SummaryCell text={incident.summary} />
</tr>
);
}
@@ -172,97 +195,6 @@ function IncidentCheckLink({ agentId, checkId }: { agentId: string; checkId: str
return agentId ? <CheckEventsLink agentId={agentId} checkId={checkId} /> : <span>{checkId}</span>;
}
function SortButton({
field,
current,
dir,
onClick,
children,
}: {
field: SortField;
current: SortField;
dir: SortDir;
onClick: (field: SortField) => void;
children: React.ReactNode;
}) {
const active = field === current;
return (
<button
type="button"
onClick={() => onClick(field)}
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
>
<span>{children}</span>
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
</button>
);
}
function fieldDefaultDir(field: SortField): SortDir {
return field === "host" || field === "check" ? "asc" : "desc";
}
function sortIncidents(
incidents: Incident[],
agentsById: Map<string, Agent>,
sort: SortField,
dir: SortDir,
): { incident: Incident; agent?: Agent }[] {
return incidents
.map((incident) => ({ incident, agent: agentsById.get(incidentAgentId(incident)) }))
.sort((a, b) => {
const primary = compareByField(a, b, sort);
if (primary !== 0) return dir === "asc" ? primary : -primary;
return fallbackCompare(a, b);
});
}
function compareByField(
a: { incident: Incident; agent?: Agent },
b: { incident: Incident; agent?: Agent },
sort: SortField,
): number {
switch (sort) {
case "host":
return hostName(a).localeCompare(hostName(b)) || incidentAgentId(a.incident).localeCompare(incidentAgentId(b.incident));
case "check":
return incidentCheckId(a.incident).localeCompare(incidentCheckId(b.incident));
case "opened_at":
return Date.parse(a.incident.opened_at) - Date.parse(b.incident.opened_at);
case "resolved_at":
return dateValue(a.incident.resolved_at) - dateValue(b.incident.resolved_at);
case "status":
return compareIncidentImportance(a.incident, b.incident);
}
}
function compareIncidentImportance(a: Incident, b: Incident): number {
return (
stateRank[a.state] - stateRank[b.state] ||
severityRank[a.severity] - severityRank[b.severity] ||
Date.parse(a.opened_at) - Date.parse(b.opened_at)
);
}
function fallbackCompare(
a: { incident: Incident; agent?: Agent },
b: { incident: Incident; agent?: Agent },
): number {
return (
compareIncidentImportance(a.incident, b.incident) ||
hostName(a).localeCompare(hostName(b)) ||
incidentCheckId(a.incident).localeCompare(incidentCheckId(b.incident))
);
}
function dateValue(value?: string | null): number {
return value ? Date.parse(value) : 0;
}
function hostName(row: { incident: Incident; agent?: Agent }): string {
return row.agent?.hostname || incidentAgentId(row.incident);
}
function incidentAgentId(incident: Incident): string {
return incident.agent_id ?? "";
}
@@ -271,6 +203,56 @@ function incidentCheckId(incident: Incident): string {
return incident.check_id ?? "";
}
function incidentsPollUrl({ state }: { state?: string }) {
return state ? `/api/poll/incidents?state=${encodeURIComponent(state)}` : "/api/poll/incidents";
function fieldDefaultDir(field: SortField): SortDir {
switch (field) {
case "status":
return "asc";
case "host":
case "check":
return "asc";
case "opened_at":
case "resolved_at":
return "desc";
}
}
function sortIncidents(
items: Incident[],
agentsById: Map<string, Agent>,
sort: SortField,
dir: SortDir,
): Incident[] {
return [...items].sort((a, b) => {
const primary = compareIncidents(a, b, agentsById, sort);
if (primary !== 0) return dir === "asc" ? primary : -primary;
return (b.opened_at ?? "").localeCompare(a.opened_at ?? "");
});
}
function compareIncidents(
a: Incident,
b: Incident,
agentsById: Map<string, Agent>,
sort: SortField,
): number {
switch (sort) {
case "status": {
const s = stateRank[a.state] - stateRank[b.state];
if (s !== 0) return s;
return severityRank[a.severity] - severityRank[b.severity];
}
case "host":
return hostFor(a, agentsById).localeCompare(hostFor(b, agentsById));
case "check":
return incidentCheckId(a).localeCompare(incidentCheckId(b));
case "opened_at":
return (a.opened_at ?? "").localeCompare(b.opened_at ?? "");
case "resolved_at":
return (a.resolved_at ?? "").localeCompare(b.resolved_at ?? "");
}
}
function hostFor(incident: Incident, agentsById: Map<string, Agent>): string {
const id = incidentAgentId(incident);
return agentsById.get(id)?.hostname || id;
}

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

View File

@@ -1,33 +1,45 @@
"use client";
import { useMemo } from "react";
import { useState } from "react";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { Pager } from "@/components/Pager";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { DateTime } from "@/components/TimeZoneControls";
import type { components } from "@/lib/api-types";
import { statusBadgeClass } from "@/lib/format";
import {
DEFAULT_PAGE_SIZE,
clampPage,
pageCountOf,
paginate,
type PageSize,
} from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
type OutboxItem = components["schemas"]["NotificationOutboxItem"];
type OutboxPageData = { items: OutboxItem[]; next_cursor?: string | null };
type OutboxData = { items: OutboxItem[] };
export function OutboxBrowser({
initialData,
cursor,
}: {
initialData: OutboxPageData;
cursor?: string;
}) {
const url = useMemo(() => {
if (!cursor) return "/api/poll/outbox";
return `/api/poll/outbox?cursor=${encodeURIComponent(cursor)}`;
}, [cursor]);
const { data } = usePolling(initialData, url);
export function OutboxBrowser({ initialItems }: { initialItems: OutboxItem[] }) {
const { data } = usePolling<OutboxData>({ items: initialItems }, "/api/poll/outbox");
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
};
const pageCount = pageCountOf(data.items.length, pageSize);
const currentPage = clampPage(page, pageCount);
const visible = paginate(data.items, currentPage, pageSize);
return (
<>
<PageHeader title="Notification outbox" count={data.items.length} />
<div className="mb-3 flex justify-end">
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
</div>
{data.items.length === 0 ? (
<EmptyPanel />
) : (
@@ -44,7 +56,7 @@ export function OutboxBrowser({
</tr>
</thead>
<tbody>
{data.items.map((o) => (
{visible.map((o) => (
<tr key={o.id}>
<Td>
<DateTime iso={o.updated_at} />
@@ -62,7 +74,7 @@ export function OutboxBrowser({
</tbody>
</table>
)}
<Pager basePath="/outbox" cursor={cursor} nextCursor={data.next_cursor} />
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
</>
);
}

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

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

View File

@@ -2,7 +2,6 @@ import type { components } from "@/lib/api-types";
export type Agent = components["schemas"]["Agent"];
export type CheckState = components["schemas"]["CheckState"];
export type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
export type CheckStatus = CheckState["status"];
export type CheckCounts = Record<CheckStatus, number>;
@@ -44,67 +43,6 @@ export function groupChecks(checks: CheckState[], agentsById: Map<string, Agent>
return [...byCheck.values()];
}
export function withLivenessChecks(agents: Agent[], checks: CheckState[]): CheckState[] {
const regularChecks = checks.filter((check) => check.check_id !== LIVENESS_CHECK_ID);
const livenessChecks = agents
.map(livenessCheckForAgent)
.filter((check): check is CheckState => check !== null);
return [...regularChecks, ...livenessChecks];
}
export function livenessCheckForAgent(agent: Agent): CheckState | null {
if (agent.status !== "stale" && agent.status !== "dead") return null;
const status: CheckStatus = agent.status === "dead" ? "critical" : "warning";
return {
agent_id: agent.agent_id,
check_id: LIVENESS_CHECK_ID,
status,
last_observed_at: agent.last_seen_at,
exit_code: status === "critical" ? 2 : 1,
incident_key: `agent:${agent.agent_id}:liveness`,
summary: `agent is ${agent.status}`,
};
}
export function withLivenessEvents(
agents: Agent[],
events: StoredEvent[],
filters: { agentId?: string; checkId?: string; cursor?: string } = {},
): StoredEvent[] {
if (filters.cursor) return events;
const livenessEvents = agents
.map(livenessEventForAgent)
.filter((event): event is StoredEvent => event !== null)
.filter((event) => {
if (filters.agentId && event.agent_id !== filters.agentId) return false;
if (filters.checkId && event.check_id !== filters.checkId) return false;
return true;
});
return [...events, ...livenessEvents].sort((a, b) => b.observed_at.localeCompare(a.observed_at));
}
export function livenessEventForAgent(agent: Agent): StoredEvent | null {
const check = livenessCheckForAgent(agent);
if (!check) return null;
// Synthetic event represents the current liveness state, not the last heartbeat,
// so observed_at uses "now" to keep it at the top of the events list across polls.
const now = new Date().toISOString();
return {
event_id: `synthetic:${LIVENESS_CHECK_ID}:${agent.agent_id}:${agent.status}:${agent.last_seen_at}`,
agent_id: agent.agent_id,
check_id: LIVENESS_CHECK_ID,
observed_at: now,
received_at: now,
status: check.status,
exit_code: check.status === "critical" ? 2 : 1,
duration_ms: 0,
output: check.summary,
output_truncated: false,
notifications_enabled: false,
incident_key: check.incident_key,
};
}
export function emptyCheckCounts(): CheckCounts {
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
}

View File

@@ -1,8 +1,7 @@
import "server-only";
import { ApiError, api, type Agent, type CheckState, type StoredEvent } from "@/lib/api";
import { api, type Agent, type CheckState, type OutboxItem, type StoredEvent } from "@/lib/api";
import type { components } from "@/lib/api-types";
import { LIVENESS_CHECK_ID, withLivenessChecks, withLivenessEvents } from "@/lib/check-groups";
import type { InventoryData, OverviewData } from "@/lib/view-types";
type Incident = components["schemas"]["Incident"];
@@ -31,10 +30,10 @@ export async function loadChecks(agentId?: string): Promise<CheckState[]> {
export async function loadInventory(): Promise<InventoryData> {
const [agents, checks] = await Promise.all([loadAgents(), loadChecks()]);
return { agents, checks: withLivenessChecks(agents, checks) };
return { agents, checks };
}
export async function loadEventsWithLiveness({
export async function loadEvents({
agentId,
checkId,
cursor,
@@ -45,28 +44,10 @@ export async function loadEventsWithLiveness({
cursor?: string;
limit?: number;
} = {}): Promise<{ items: StoredEvent[]; next_cursor?: string | null }> {
const data = await api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, limit });
if (cursor || (checkId && checkId !== LIVENESS_CHECK_ID)) return data;
const agents = agentId ? await loadOptionalAgent(agentId) : await loadAgents();
return {
...data,
items: withLivenessEvents(agents, data.items, { agentId, checkId, cursor }),
};
return api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, limit });
}
async function loadOptionalAgent(agentId: string): Promise<Agent[]> {
try {
return [await api.getAgent(agentId)];
} catch (e) {
if (e instanceof ApiError && e.status === 404) return [];
throw e;
}
}
export async function loadIncidents(
state?: "open" | "resolved",
): Promise<{ items: Incident[] }> {
export async function loadIncidents(state?: "open" | "resolved"): Promise<Incident[]> {
const items: Incident[] = [];
let cursor: string | undefined;
do {
@@ -74,25 +55,46 @@ export async function loadIncidents(
items.push(...page.items);
cursor = page.next_cursor ?? undefined;
} while (cursor);
return { items };
return items;
}
export async function loadOutboxAll(): Promise<OutboxItem[]> {
const items: OutboxItem[] = [];
let cursor: string | undefined;
do {
const page = await api.listOutbox({ cursor, limit: 500 });
items.push(...page.items);
cursor = page.next_cursor ?? undefined;
} while (cursor);
return items;
}
export async function loadOverview(): Promise<OverviewData> {
const [agents, checks, incidents] = await Promise.all([
const [agents, checks, openIncidents] = await Promise.all([
loadAgents(),
loadChecks(),
api.listIncidents({ state: "open", limit: 500 }),
countOpenIncidents(),
]);
const checksWithLiveness = withLivenessChecks(agents, checks);
return {
agents: tally(agents),
checks: tally(checksWithLiveness),
openIncidents: incidents.items.length,
checks: tally(checks),
openIncidents,
totalAgents: agents.length,
totalChecks: checksWithLiveness.length,
totalChecks: checks.length,
};
}
async function countOpenIncidents(): Promise<number> {
let total = 0;
let cursor: string | undefined;
do {
const page = await api.listIncidents({ state: "open", cursor, limit: 500 });
total += page.items.length;
cursor = page.next_cursor ?? undefined;
} while (cursor);
return total;
}
function tally(xs: { status?: string }[]): Record<string, number> {
return xs.reduce<Record<string, number>>((acc, x) => {
const k = x.status ?? "unknown";

23
web/src/lib/pagination.ts Normal file
View 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;
}

View File

@@ -55,6 +55,24 @@ const fixtures = {
incident_key: "agent-1:load",
summary: "load high",
},
{
agent_id: "agent-1",
check_id: "agent_liveness",
status: "ok",
exit_code: 0,
last_observed_at: now,
incident_key: "agent:agent-1:liveness",
summary: "agent is alive",
},
{
agent_id: "agent-2",
check_id: "agent_liveness",
status: "critical",
exit_code: 2,
last_observed_at: now,
incident_key: "agent:agent-2:liveness",
summary: "agent is dead",
},
],
next_cursor: null,
},
@@ -122,6 +140,19 @@ const fixtures = {
output_truncated: false,
notifications_enabled: true,
},
{
event_id: "00000000-0000-7000-8000-000000000003",
agent_id: "agent-2",
check_id: "agent_liveness",
observed_at: now,
received_at: now,
status: "critical",
exit_code: 2,
duration_ms: 0,
output: "agent is dead",
output_truncated: false,
notifications_enabled: true,
},
],
next_cursor: null,
},

View File

@@ -17,7 +17,7 @@ test("agents list links to detail", async ({ page }) => {
await expect(page.locator("thead")).not.toContainText("asc");
await expect(page.locator("thead")).not.toContainText("desc");
await expect(page.locator("thead")).toContainText("↓");
await expect(page.locator("body")).toContainText("checks 2");
await expect(page.locator("body")).toContainText("checks 3");
await expect(page.locator("body")).toContainText("warn 1");
await expect(page.getByRole("button", { name: /Timezone: default/ })).toBeVisible();
await page.getByRole("button", { name: /Timezone: default/ }).click();
@@ -73,7 +73,7 @@ test("checks page lists rows", async ({ page }) => {
await expect(page.locator("body")).toContainText("host-1");
await expect(page.locator("body")).toContainText("disk ok");
await page.getByRole("link", { name: "Events" }).last().click();
await expect(page).toHaveURL(/\/checks\/disk\?tab=events$/);
await expect(page).toHaveURL(/\/checks\/disk\?tab=events(?:&events_limit=\d+)?$/);
await expect(page.locator("body")).toContainText("12");
await expect(page.locator("thead").last()).not.toContainText("exit");
});
@@ -83,7 +83,6 @@ test("incidents filter renders", async ({ page }) => {
await expect(page.locator("body")).toContainText("load high");
await expect(page.locator("thead")).toContainText("HOST");
await expect(page.locator("thead")).not.toContainText("agent_id");
await expect(page.locator("thead")).toContainText("↓");
await expect(page.locator("tbody")).toContainText("host-1");
await expect(page.locator("tbody")).toContainText("agent_liveness");
await expect(page.locator("tbody")).toContainText("agent is dead");
@@ -91,10 +90,7 @@ test("incidents filter renders", async ({ page }) => {
await expect(page).toHaveURL(/\/checks\/agent_liveness$/);
await expect(page.locator("body")).toContainText("host-2");
await page.goto("/incidents");
await page.getByRole("button", { name: /^HOST/ }).click();
await expect(page).toHaveURL(/\/incidents$/);
await expect(page.locator("thead")).toContainText("↑");
await page.getByRole("link", { name: "host-1" }).click();
await page.getByRole("link", { name: "host-1" }).first().click();
await expect(page).toHaveURL(/\/agents\/agent-1$/);
});