refactor web pagination and add partitioned events, flap/timeout showcase agents
This commit is contained in:
@@ -1,13 +1,14 @@
|
||||
import { AgentDetailBrowser } from "@/components/AgentDetailBrowser";
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { withLivenessEvents } from "@/lib/check-groups";
|
||||
import { loadChecks } from "@/lib/loaders";
|
||||
import { normalizePageSize } from "@/lib/pagination";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SortKey = "severity" | "check_id" | "last_seen";
|
||||
type TabKey = "checks" | "events";
|
||||
type EventsPage = Awaited<ReturnType<typeof api.queryEvents>>;
|
||||
|
||||
function normalizeSort(sort?: string): SortKey {
|
||||
return sort === "check_id" || sort === "last_seen" ? sort : "severity";
|
||||
@@ -21,22 +22,35 @@ export default async function AgentDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ agent_id: string }>;
|
||||
searchParams: Promise<{ sort?: string; tab?: string }>;
|
||||
params: Promise<{
|
||||
agent_id: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
sort?: string;
|
||||
tab?: string;
|
||||
events_limit?: string;
|
||||
cursor?: string;
|
||||
}>;
|
||||
}) {
|
||||
const { agent_id } = await params;
|
||||
const sp = await searchParams;
|
||||
const sort = normalizeSort(sp.sort);
|
||||
const tab = normalizeTab(sp.tab);
|
||||
const eventsLimit = normalizePageSize(sp.events_limit, 50);
|
||||
const eventsCursor = sp.cursor;
|
||||
|
||||
let data;
|
||||
let agent;
|
||||
let checks;
|
||||
let events: EventsPage = { items: [], next_cursor: null };
|
||||
try {
|
||||
const [agent, checks, events] = await Promise.all([
|
||||
api.getAgent(agent_id),
|
||||
loadChecks(agent_id),
|
||||
api.queryEvents({ agent_id, limit: 50 }).then((page) => page.items),
|
||||
]);
|
||||
data = { agent, checks, events: withLivenessEvents([agent], events, { agentId: agent_id }) };
|
||||
[agent, checks] = await Promise.all([api.getAgent(agent_id), loadChecks(agent_id)]);
|
||||
if (tab === "events") {
|
||||
events = await api.queryEvents({
|
||||
agent_id,
|
||||
limit: eventsLimit,
|
||||
cursor: eventsCursor,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
return <div className="text-sm text-neutral-400">Agent not found.</div>;
|
||||
@@ -45,10 +59,13 @@ export default async function AgentDetailPage({
|
||||
}
|
||||
return (
|
||||
<AgentDetailBrowser
|
||||
key={`${agent_id}:${tab}:${sort}`}
|
||||
initialData={data}
|
||||
key={`${agent_id}:${tab}:${sort}:${eventsLimit}:${eventsCursor ?? ""}`}
|
||||
initialData={{ agent, checks }}
|
||||
initialEvents={events}
|
||||
sort={sort}
|
||||
tab={tab}
|
||||
eventsLimit={eventsLimit}
|
||||
eventsCursor={eventsCursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { withLivenessEvents } from "@/lib/check-groups";
|
||||
import { loadChecks } from "@/lib/loaders";
|
||||
import { jsonError } from "@/lib/poll-response";
|
||||
|
||||
@@ -19,16 +18,8 @@ export async function GET(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const [agent, checks, events] = await Promise.all([
|
||||
api.getAgent(agentId),
|
||||
loadChecks(agentId),
|
||||
api.queryEvents({ agent_id: agentId, limit: 50 }).then((page) => page.items),
|
||||
]);
|
||||
return NextResponse.json({
|
||||
agent,
|
||||
checks,
|
||||
events: withLivenessEvents([agent], events, { agentId }),
|
||||
});
|
||||
const [agent, checks] = await Promise.all([api.getAgent(agentId), loadChecks(agentId)]);
|
||||
return NextResponse.json({ agent, checks });
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { loadEventsWithLiveness } from "@/lib/loaders";
|
||||
import { loadEvents } from "@/lib/loaders";
|
||||
import { normalizePageSize } from "@/lib/pagination";
|
||||
import { jsonError } from "@/lib/poll-response";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const limit = Number(url.searchParams.get("limit") ?? "100");
|
||||
const limit = normalizePageSize(url.searchParams.get("limit"), 50);
|
||||
|
||||
try {
|
||||
const data = await loadEventsWithLiveness({
|
||||
const data = await loadEvents({
|
||||
agentId: url.searchParams.get("agent_id") ?? undefined,
|
||||
checkId: url.searchParams.get("check_id") ?? undefined,
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
limit: Number.isFinite(limit) ? limit : 100,
|
||||
limit,
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
} catch (e) {
|
||||
|
||||
@@ -11,8 +11,8 @@ export async function GET(request: Request) {
|
||||
const state = stateParam === "open" || stateParam === "resolved" ? stateParam : undefined;
|
||||
|
||||
try {
|
||||
const data = await loadIncidents(state);
|
||||
return NextResponse.json(data);
|
||||
const items = await loadIncidents(state);
|
||||
return NextResponse.json({ items });
|
||||
} catch (e) {
|
||||
return jsonError(e);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { loadOutboxAll } from "@/lib/loaders";
|
||||
import { jsonError } from "@/lib/poll-response";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const data = await api.listOutbox({
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
});
|
||||
return NextResponse.json(data);
|
||||
const items = await loadOutboxAll();
|
||||
return NextResponse.json({ items });
|
||||
} catch (e) {
|
||||
return jsonError(e);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { CheckDetailBrowser } from "@/components/CheckDetailBrowser";
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { api } from "@/lib/api";
|
||||
import { withLivenessEvents } from "@/lib/check-groups";
|
||||
import { loadInventory } from "@/lib/loaders";
|
||||
import { normalizePageSize } from "@/lib/pagination";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -18,22 +18,24 @@ export default async function CheckDetailPage({
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ check_id: string }>;
|
||||
searchParams: Promise<{ tab?: string }>;
|
||||
searchParams: Promise<{ tab?: string; events_limit?: string; cursor?: string }>;
|
||||
}) {
|
||||
const { check_id } = await params;
|
||||
const sp = await searchParams;
|
||||
const tab = normalizeTab(sp.tab);
|
||||
const eventsLimit = normalizePageSize(sp.events_limit, 50);
|
||||
const eventsCursor = sp.cursor;
|
||||
|
||||
let data;
|
||||
let events: EventsPage = { items: [], next_cursor: null };
|
||||
try {
|
||||
data = await loadInventory();
|
||||
if (tab === "events") {
|
||||
const rawEvents = await api.queryEvents({ check_id, limit: 50 });
|
||||
events = {
|
||||
...rawEvents,
|
||||
items: withLivenessEvents(data.agents, rawEvents.items, { checkId: check_id }),
|
||||
};
|
||||
events = await api.queryEvents({
|
||||
check_id,
|
||||
limit: eventsLimit,
|
||||
cursor: eventsCursor,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
@@ -41,11 +43,13 @@ export default async function CheckDetailPage({
|
||||
|
||||
return (
|
||||
<CheckDetailBrowser
|
||||
key={`${check_id}:${tab}`}
|
||||
key={`${check_id}:${tab}:${eventsLimit}:${eventsCursor ?? ""}`}
|
||||
checkId={check_id}
|
||||
initialData={data}
|
||||
initialEvents={events}
|
||||
tab={tab}
|
||||
eventsLimit={eventsLimit}
|
||||
eventsCursor={eventsCursor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,32 +1,41 @@
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { EventsBrowser } from "@/components/EventsBrowser";
|
||||
import { loadEventsWithLiveness } from "@/lib/loaders";
|
||||
import { loadEvents } from "@/lib/loaders";
|
||||
import { normalizePageSize } from "@/lib/pagination";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EventsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ cursor?: string; agent_id?: string; check_id?: string }>;
|
||||
searchParams: Promise<{
|
||||
cursor?: string;
|
||||
agent_id?: string;
|
||||
check_id?: string;
|
||||
limit?: string;
|
||||
}>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const limit = normalizePageSize(sp.limit);
|
||||
let data;
|
||||
try {
|
||||
data = await loadEventsWithLiveness({
|
||||
data = await loadEvents({
|
||||
agentId: sp.agent_id,
|
||||
checkId: sp.check_id,
|
||||
cursor: sp.cursor,
|
||||
limit,
|
||||
});
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<EventsBrowser
|
||||
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}`}
|
||||
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}:${limit}`}
|
||||
initialData={data}
|
||||
cursor={sp.cursor}
|
||||
agentId={sp.agent_id}
|
||||
checkId={sp.check_id}
|
||||
limit={limit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,17 +11,17 @@ export default async function IncidentsPage({
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
|
||||
let data;
|
||||
let items;
|
||||
let inventory;
|
||||
try {
|
||||
[data, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
|
||||
[items, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<IncidentsBrowser
|
||||
key={state ?? ""}
|
||||
initialData={data}
|
||||
initialItems={items}
|
||||
initialInventory={inventory}
|
||||
state={state}
|
||||
/>
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { OutboxBrowser } from "@/components/OutboxBrowser";
|
||||
import { api } from "@/lib/api";
|
||||
import { loadOutboxAll } from "@/lib/loaders";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OutboxPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ cursor?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
let data;
|
||||
export default async function OutboxPage() {
|
||||
let items;
|
||||
try {
|
||||
data = await api.listOutbox({ cursor: sp.cursor });
|
||||
items = await loadOutboxAll();
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return <OutboxBrowser key={sp.cursor ?? ""} initialData={data} cursor={sp.cursor} />;
|
||||
return <OutboxBrowser initialItems={items} />;
|
||||
}
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { Td, Th } from "@/components/DataPanel";
|
||||
import { ClientPager } from "@/components/ClientPager";
|
||||
import { EmptyPanel, SummaryCell, Td, Th } from "@/components/DataPanel";
|
||||
import { DetailTabs } from "@/components/DetailTabs";
|
||||
import { LabelChips } from "@/components/Labels";
|
||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { withLivenessChecks } from "@/lib/check-groups";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
clampPage,
|
||||
pageCountOf,
|
||||
paginate,
|
||||
type PageSize,
|
||||
} from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
|
||||
type Agent = components["schemas"]["Agent"];
|
||||
type CheckState = components["schemas"]["CheckState"];
|
||||
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
|
||||
type SortKey = "severity" | "check_id" | "last_seen";
|
||||
type TabKey = "checks" | "events";
|
||||
type AgentDetailData = {
|
||||
agent: Agent;
|
||||
checks: CheckState[];
|
||||
events: StoredEvent[];
|
||||
};
|
||||
type AgentDetailData = { agent: Agent; checks: CheckState[] };
|
||||
type AgentPollPayload = AgentDetailData & { events?: StoredEvent[] };
|
||||
|
||||
const severityRank: Record<CheckState["status"], number> = {
|
||||
critical: 0,
|
||||
@@ -33,22 +42,24 @@ const severityRank: Record<CheckState["status"], number> = {
|
||||
|
||||
export function AgentDetailBrowser({
|
||||
initialData,
|
||||
initialEvents,
|
||||
sort,
|
||||
tab,
|
||||
eventsLimit,
|
||||
eventsCursor,
|
||||
}: {
|
||||
initialData: AgentDetailData;
|
||||
initialEvents: EventsPageData;
|
||||
sort: SortKey;
|
||||
tab: TabKey;
|
||||
eventsLimit: PageSize;
|
||||
eventsCursor?: string;
|
||||
}) {
|
||||
const { data } = usePolling(
|
||||
initialData,
|
||||
`/api/poll/agent?agent_id=${encodeURIComponent(initialData.agent.agent_id)}`,
|
||||
);
|
||||
const { agent, events } = data;
|
||||
const checks = useMemo(
|
||||
() => sortChecks(withLivenessChecks([agent], data.checks), sort),
|
||||
[agent, data.checks, sort],
|
||||
);
|
||||
const agentId = initialData.agent.agent_id;
|
||||
const pollUrl = `/api/poll/agent?agent_id=${encodeURIComponent(agentId)}`;
|
||||
const { data } = usePolling<AgentPollPayload>(initialData, pollUrl);
|
||||
const agent = data.agent;
|
||||
const checks = useMemo(() => sortChecks(data.checks, sort), [data.checks, sort]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -76,28 +87,32 @@ export function AgentDetailBrowser({
|
||||
<LabelChips labels={agent.labels} />
|
||||
</dd>
|
||||
</dl>
|
||||
<TabNav agentId={agent.agent_id} current={tab} sort={sort} />
|
||||
{tab === "checks" ? <ChecksTable agentId={agent.agent_id} checks={checks} sort={sort} /> : <EventsTable events={events} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabNav({ agentId, current, sort }: { agentId: string; current: TabKey; sort: SortKey }) {
|
||||
const base = `/agents/${encodeURIComponent(agentId)}`;
|
||||
return (
|
||||
<div className="flex gap-4 border-b border-neutral-800 text-sm">
|
||||
<Link
|
||||
href={`${base}?tab=checks&sort=${sort}`}
|
||||
className={`pb-2 ${current === "checks" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
|
||||
>
|
||||
Checks
|
||||
</Link>
|
||||
<Link
|
||||
href={`${base}?tab=events&sort=${sort}`}
|
||||
className={`pb-2 ${current === "events" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
|
||||
>
|
||||
Events
|
||||
</Link>
|
||||
<DetailTabs
|
||||
current={tab}
|
||||
tabs={[
|
||||
{
|
||||
key: "checks",
|
||||
label: "Checks",
|
||||
href: `/agents/${encodeURIComponent(agentId)}?tab=checks&sort=${sort}`,
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
label: "Events",
|
||||
href: `/agents/${encodeURIComponent(agentId)}?tab=events&sort=${sort}&events_limit=${eventsLimit}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{tab === "checks" ? (
|
||||
<ChecksTable agentId={agentId} checks={checks} sort={sort} />
|
||||
) : (
|
||||
<EventsTable
|
||||
agentId={agentId}
|
||||
sort={sort}
|
||||
eventsLimit={eventsLimit}
|
||||
eventsCursor={eventsCursor}
|
||||
initialEvents={initialEvents}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -111,9 +126,22 @@ function ChecksTable({
|
||||
checks: CheckState[];
|
||||
sort: SortKey;
|
||||
}) {
|
||||
if (checks.length === 0) return <div className="text-sm text-neutral-500">No checks.</div>;
|
||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||
const [page, setPage] = useState(1);
|
||||
const onPageSizeChange = (next: PageSize) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
const pageCount = pageCountOf(checks.length, pageSize);
|
||||
const currentPage = clampPage(page, pageCount);
|
||||
const visible = paginate(checks, currentPage, pageSize);
|
||||
|
||||
if (checks.length === 0) return <EmptyPanel label="No checks" />;
|
||||
return (
|
||||
<section>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -136,7 +164,7 @@ function ChecksTable({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{checks.map((c) => (
|
||||
{visible.map((c) => (
|
||||
<tr key={c.check_id}>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={agentId} checkId={c.check_id} />
|
||||
@@ -145,43 +173,81 @@ function ChecksTable({
|
||||
<Td>
|
||||
<DateTime iso={c.last_observed_at} />
|
||||
</Td>
|
||||
<Td className="max-w-md truncate text-xs text-neutral-400">{c.summary ?? "—"}</Td>
|
||||
<SummaryCell text={c.summary} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsTable({ events }: { events: StoredEvent[] }) {
|
||||
if (events.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
|
||||
function EventsTable({
|
||||
agentId,
|
||||
sort,
|
||||
eventsLimit,
|
||||
eventsCursor,
|
||||
initialEvents,
|
||||
}: {
|
||||
agentId: string;
|
||||
sort: SortKey;
|
||||
eventsLimit: PageSize;
|
||||
eventsCursor?: string;
|
||||
initialEvents: EventsPageData;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pollUrl = useMemo(() => {
|
||||
const qs = new URLSearchParams({ agent_id: agentId, limit: String(eventsLimit) });
|
||||
if (eventsCursor) qs.set("cursor", eventsCursor);
|
||||
return `/api/poll/events?${qs.toString()}`;
|
||||
}, [agentId, eventsCursor, eventsLimit]);
|
||||
const { data } = usePolling(initialEvents, pollUrl);
|
||||
|
||||
const onLimitChange = (next: PageSize) => {
|
||||
const qs = new URLSearchParams({ tab: "events", sort, events_limit: String(next) });
|
||||
router.push(`/agents/${encodeURIComponent(agentId)}?${qs.toString()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<section>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>observed_at</Th>
|
||||
<Th>check_id</Th>
|
||||
<Th>status</Th>
|
||||
<Th>duration_ms</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.event_id}>
|
||||
<Td>
|
||||
<DateTime iso={e.observed_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
||||
<Td>{e.duration_ms}</Td>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<PageSizeSelect value={eventsLimit} onChange={onLimitChange} />
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel label="No events" />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>observed_at</Th>
|
||||
<Th>check_id</Th>
|
||||
<Th>status</Th>
|
||||
<Th>duration_ms</Th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((e) => (
|
||||
<tr key={e.event_id}>
|
||||
<Td>
|
||||
<DateTime iso={e.observed_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<CheckEventsLink agentId={e.agent_id} checkId={e.check_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
||||
<Td>{e.duration_ms}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager
|
||||
basePath={`/agents/${encodeURIComponent(agentId)}`}
|
||||
cursor={eventsCursor}
|
||||
nextCursor={data.next_cursor}
|
||||
extra={{ tab: "events", sort, events_limit: String(eventsLimit) }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
19
web/src/components/AgentLink.tsx
Normal file
19
web/src/components/AgentLink.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import type { components } from "@/lib/api-types";
|
||||
|
||||
type Agent = components["schemas"]["Agent"];
|
||||
|
||||
export function AgentLink({ agent, agentId }: { agent?: Agent; agentId: string }) {
|
||||
const hostname = agent?.hostname || agentId;
|
||||
const showId = agent && agent.hostname && agent.hostname !== agentId;
|
||||
return (
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(agentId)}`}
|
||||
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
<span>{hostname}</span>
|
||||
{showId ? <span className="text-xs text-neutral-500">{agentId}</span> : null}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||
import { AgentLink } from "@/components/AgentLink";
|
||||
import { ClientPager } from "@/components/ClientPager";
|
||||
import { EmptyPanel, Td, Th } from "@/components/DataPanel";
|
||||
import { LabelChips } from "@/components/Labels";
|
||||
import { ListToolbar } from "@/components/ListToolbar";
|
||||
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
clampPage,
|
||||
pageCountOf,
|
||||
paginate,
|
||||
type PageSize,
|
||||
} from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
import type { InventoryData } from "@/lib/view-types";
|
||||
|
||||
@@ -16,7 +27,6 @@ type CheckState = components["schemas"]["CheckState"];
|
||||
type CheckStatus = CheckState["status"];
|
||||
type CheckCounts = Record<CheckStatus, number>;
|
||||
type SortField = "host" | "status" | "last_seen_at";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
export type AgentRow = { agent: Agent; checks: CheckCounts };
|
||||
|
||||
@@ -36,77 +46,75 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
||||
[dir, initialRows, query, sort],
|
||||
);
|
||||
|
||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageCount = pageCountOf(rows.length, pageSize);
|
||||
const currentPage = clampPage(page, pageCount);
|
||||
const visible = paginate(rows, currentPage, pageSize);
|
||||
|
||||
const onQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
const onPageSizeChange = (next: PageSize) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
const chooseSort = (field: SortField) => {
|
||||
if (field === sort) {
|
||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
} else {
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
}
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-baseline gap-3">
|
||||
<h1 className="text-lg font-semibold">Agents</h1>
|
||||
<span className="text-sm text-neutral-400">{rows.length} items</span>
|
||||
</div>
|
||||
<div className="mb-4 flex max-w-md gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="search agent"
|
||||
className="min-w-0 flex-1 border border-neutral-800 bg-neutral-950 px-2 py-1.5 text-sm text-neutral-100 outline-none focus:border-neutral-500"
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
className="border border-neutral-900 px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-300"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
) : null}
|
||||
<span className="text-sm text-neutral-400">
|
||||
{rows.length} of {initialRows.length} items
|
||||
</span>
|
||||
</div>
|
||||
<ListToolbar
|
||||
query={query}
|
||||
onQueryChange={onQueryChange}
|
||||
placeholder="search agent"
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 italic">No data.</div>
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>
|
||||
<SortButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
||||
HOST
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
STATUS
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="last_seen_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="last_seen_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
LAST_SEEN_AT
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>features</Th>
|
||||
<Th>labels</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ agent: a, checks }) => (
|
||||
{visible.map(({ agent: a, checks }) => (
|
||||
<tr key={a.agent_id}>
|
||||
<Td>
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(a.agent_id)}`}
|
||||
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
<span>{a.hostname}</span>
|
||||
{a.agent_id !== a.hostname ? (
|
||||
<span className="text-xs text-neutral-500">{a.agent_id}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
<AgentLink agent={a} agentId={a.agent_id} />
|
||||
</Td>
|
||||
<Td>
|
||||
<AgentStatus status={a.status} checks={checks} />
|
||||
@@ -125,50 +133,11 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="text-left text-xs font-semibold uppercase tracking-wide text-neutral-400 border-b border-neutral-800 px-2 py-2">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<td className={`px-2 py-1.5 border-b border-neutral-900 align-top ${className}`}>{children}</td>
|
||||
);
|
||||
}
|
||||
|
||||
function SortButton({
|
||||
field,
|
||||
current,
|
||||
dir,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
field: SortField;
|
||||
current: SortField;
|
||||
dir: SortDir;
|
||||
onClick: (field: SortField) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const active = field === current;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(field)}
|
||||
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function filterAgents(rows: AgentRow[], query: string): AgentRow[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return rows;
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AgentLink } from "@/components/AgentLink";
|
||||
import { CheckStatusSummary } from "@/components/CheckStatusSummary";
|
||||
import { Td, Th } from "@/components/DataPanel";
|
||||
import { ClientPager } from "@/components/ClientPager";
|
||||
import { EmptyPanel, SummaryCell, Td, Th } from "@/components/DataPanel";
|
||||
import { DetailTabs } from "@/components/DetailTabs";
|
||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import {
|
||||
groupChecks,
|
||||
statusSeverity,
|
||||
withLivenessChecks,
|
||||
type Agent,
|
||||
type CheckGroup,
|
||||
} from "@/lib/check-groups";
|
||||
import { groupChecks, statusSeverity, type Agent, type CheckGroup } from "@/lib/check-groups";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
clampPage,
|
||||
pageCountOf,
|
||||
paginate,
|
||||
type PageSize,
|
||||
} from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
import type { InventoryData } from "@/lib/view-types";
|
||||
|
||||
@@ -27,21 +34,24 @@ export function CheckDetailBrowser({
|
||||
initialData,
|
||||
initialEvents,
|
||||
tab,
|
||||
eventsLimit,
|
||||
eventsCursor,
|
||||
}: {
|
||||
checkId: string;
|
||||
initialData: InventoryData;
|
||||
initialEvents: EventsPageData;
|
||||
tab: TabKey;
|
||||
eventsLimit: PageSize;
|
||||
eventsCursor?: string;
|
||||
}) {
|
||||
const { data } = usePolling(initialData, "/api/poll/inventory");
|
||||
const agentsById = useMemo(() => new Map(data.agents.map((agent) => [agent.agent_id, agent])), [data.agents]);
|
||||
const checks = useMemo(
|
||||
() => withLivenessChecks(data.agents, data.checks),
|
||||
[data.agents, data.checks],
|
||||
const agentsById = useMemo(
|
||||
() => new Map(data.agents.map((agent) => [agent.agent_id, agent])),
|
||||
[data.agents],
|
||||
);
|
||||
const group = useMemo(
|
||||
() => groupChecks(checks, agentsById).find((item) => item.checkId === checkId),
|
||||
[agentsById, checkId, checks],
|
||||
() => groupChecks(data.checks, agentsById).find((item) => item.checkId === checkId),
|
||||
[agentsById, checkId, data.checks],
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
@@ -76,76 +86,88 @@ export function CheckDetailBrowser({
|
||||
<DateTime iso={group.lastObservedAt} />
|
||||
</dd>
|
||||
</dl>
|
||||
<TabNav checkId={checkId} current={tab} />
|
||||
<DetailTabs
|
||||
current={tab}
|
||||
tabs={[
|
||||
{
|
||||
key: "agents",
|
||||
label: "Agents",
|
||||
href: `/checks/${encodeURIComponent(checkId)}?tab=agents`,
|
||||
},
|
||||
{
|
||||
key: "events",
|
||||
label: "Events",
|
||||
href: `/checks/${encodeURIComponent(checkId)}?tab=events&events_limit=${eventsLimit}`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{tab === "agents" ? (
|
||||
<AgentsForCheck group={group} />
|
||||
) : (
|
||||
<EventsForCheck checkId={checkId} initialData={initialEvents} agentsById={agentsById} />
|
||||
<EventsForCheck
|
||||
checkId={checkId}
|
||||
initialData={initialEvents}
|
||||
agentsById={agentsById}
|
||||
eventsLimit={eventsLimit}
|
||||
eventsCursor={eventsCursor}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabNav({ checkId, current }: { checkId: string; current: TabKey }) {
|
||||
const base = `/checks/${encodeURIComponent(checkId)}`;
|
||||
return (
|
||||
<div className="flex gap-4 border-b border-neutral-800 text-sm">
|
||||
<Link
|
||||
href={`${base}?tab=agents`}
|
||||
className={`pb-2 ${current === "agents" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
|
||||
>
|
||||
Agents
|
||||
</Link>
|
||||
<Link
|
||||
href={`${base}?tab=events`}
|
||||
className={`pb-2 ${current === "events" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
|
||||
>
|
||||
Events
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentsForCheck({ group }: { group: CheckGroup }) {
|
||||
const rows = [...group.checks].sort(
|
||||
(a, b) =>
|
||||
statusSeverity[b.check.status] - statusSeverity[a.check.status] ||
|
||||
hostName(a).localeCompare(hostName(b)),
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
[...group.checks].sort(
|
||||
(a, b) =>
|
||||
statusSeverity[b.check.status] - statusSeverity[a.check.status] ||
|
||||
hostName(a).localeCompare(hostName(b)),
|
||||
),
|
||||
[group.checks],
|
||||
);
|
||||
|
||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||
const [page, setPage] = useState(1);
|
||||
const onPageSizeChange = (next: PageSize) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
const pageCount = pageCountOf(rows.length, pageSize);
|
||||
const currentPage = clampPage(page, pageCount);
|
||||
const visible = paginate(rows, currentPage, pageSize);
|
||||
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>HOST</Th>
|
||||
<Th>STATUS</Th>
|
||||
<Th>LAST_OBSERVED_AT</Th>
|
||||
<Th>SUMMARY</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ check, agent }) => (
|
||||
<tr key={check.agent_id}>
|
||||
<Td>
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(check.agent_id)}`}
|
||||
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
<span>{agent?.hostname || check.agent_id}</span>
|
||||
{agent && agent.hostname !== check.agent_id ? (
|
||||
<span className="text-xs text-neutral-500">{check.agent_id}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(check.status)}>{check.status}</Td>
|
||||
<Td>
|
||||
<DateTime iso={check.last_observed_at} />
|
||||
</Td>
|
||||
<Td className="max-w-md truncate text-xs text-neutral-400">{check.summary ?? "—"}</Td>
|
||||
<section>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>HOST</Th>
|
||||
<Th>STATUS</Th>
|
||||
<Th>LAST_OBSERVED_AT</Th>
|
||||
<Th>SUMMARY</Th>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visible.map(({ check, agent }) => (
|
||||
<tr key={check.agent_id}>
|
||||
<Td>
|
||||
<AgentLink agent={agent} agentId={check.agent_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(check.status)}>{check.status}</Td>
|
||||
<Td>
|
||||
<DateTime iso={check.last_observed_at} />
|
||||
</Td>
|
||||
<SummaryCell text={check.summary} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,57 +175,81 @@ function EventsForCheck({
|
||||
checkId,
|
||||
initialData,
|
||||
agentsById,
|
||||
eventsLimit,
|
||||
eventsCursor,
|
||||
}: {
|
||||
checkId: string;
|
||||
initialData: EventsPageData;
|
||||
agentsById: Map<string, Agent>;
|
||||
eventsLimit: PageSize;
|
||||
eventsCursor?: string;
|
||||
}) {
|
||||
const { data } = usePolling(
|
||||
initialData,
|
||||
`/api/poll/events?check_id=${encodeURIComponent(checkId)}&limit=50`,
|
||||
);
|
||||
const router = useRouter();
|
||||
const pollUrl = useMemo(() => {
|
||||
const qs = new URLSearchParams({
|
||||
check_id: checkId,
|
||||
limit: String(eventsLimit),
|
||||
});
|
||||
if (eventsCursor) qs.set("cursor", eventsCursor);
|
||||
return `/api/poll/events?${qs.toString()}`;
|
||||
}, [checkId, eventsCursor, eventsLimit]);
|
||||
const { data } = usePolling(initialData, pollUrl);
|
||||
|
||||
if (data.items.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
|
||||
const onLimitChange = (next: PageSize) => {
|
||||
router.push(
|
||||
`/checks/${encodeURIComponent(checkId)}?tab=events&events_limit=${next}`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>OBSERVED_AT</Th>
|
||||
<Th>RECEIVED_AT</Th>
|
||||
<Th>HOST</Th>
|
||||
<Th>STATUS</Th>
|
||||
<Th>DURATION_MS</Th>
|
||||
<Th>SUMMARY</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((event) => {
|
||||
const agent = agentsById.get(event.agent_id);
|
||||
return (
|
||||
<tr key={event.event_id}>
|
||||
<Td>
|
||||
<DateTime iso={event.observed_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<DateTime iso={event.received_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(event.agent_id)}`}
|
||||
className="text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
{agent?.hostname || event.agent_id}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(event.status)}>{event.status}</Td>
|
||||
<Td>{event.duration_ms}</Td>
|
||||
<Td className="max-w-md truncate text-xs text-neutral-400">{event.output ?? "—"}</Td>
|
||||
<section>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<PageSizeSelect value={eventsLimit} onChange={onLimitChange} />
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel label="No events" />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>OBSERVED_AT</Th>
|
||||
<Th>RECEIVED_AT</Th>
|
||||
<Th>HOST</Th>
|
||||
<Th>STATUS</Th>
|
||||
<Th>DURATION_MS</Th>
|
||||
<Th>SUMMARY</Th>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((event) => {
|
||||
const agent = agentsById.get(event.agent_id);
|
||||
return (
|
||||
<tr key={event.event_id}>
|
||||
<Td>
|
||||
<DateTime iso={event.observed_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<DateTime iso={event.received_at} />
|
||||
</Td>
|
||||
<Td>
|
||||
<AgentLink agent={agent} agentId={event.agent_id} />
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(event.status)}>{event.status}</Td>
|
||||
<Td>{event.duration_ms}</Td>
|
||||
<SummaryCell text={event.output} />
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager
|
||||
basePath={`/checks/${encodeURIComponent(checkId)}`}
|
||||
cursor={eventsCursor}
|
||||
nextCursor={data.next_cursor}
|
||||
extra={{ tab: "events", events_limit: String(eventsLimit) }}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,17 +4,27 @@ import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { CheckStatusSummary } from "@/components/CheckStatusSummary";
|
||||
import { ClientPager } from "@/components/ClientPager";
|
||||
import { EmptyPanel, Td, Th } from "@/components/DataPanel";
|
||||
import { ListToolbar } from "@/components/ListToolbar";
|
||||
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import {
|
||||
compareStatusCounts,
|
||||
groupChecks,
|
||||
type CheckGroup,
|
||||
} from "@/lib/check-groups";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
clampPage,
|
||||
pageCountOf,
|
||||
paginate,
|
||||
type PageSize,
|
||||
} from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
import type { InventoryData } from "@/lib/view-types";
|
||||
|
||||
type SortField = "check" | "status" | "last_observed_at";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
|
||||
const { data } = usePolling(initialData, "/api/poll/inventory");
|
||||
@@ -29,65 +39,71 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
|
||||
[dir, groups, query, sort],
|
||||
);
|
||||
|
||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||
const [page, setPage] = useState(1);
|
||||
const pageCount = pageCountOf(rows.length, pageSize);
|
||||
const currentPage = clampPage(page, pageCount);
|
||||
const visible = paginate(rows, currentPage, pageSize);
|
||||
|
||||
const onQueryChange = (value: string) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
const onPageSizeChange = (next: PageSize) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
const chooseSort = (field: SortField) => {
|
||||
if (field === sort) {
|
||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
} else {
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
}
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-baseline gap-3">
|
||||
<h1 className="text-lg font-semibold">Checks</h1>
|
||||
<span className="text-sm text-neutral-400">{rows.length} items</span>
|
||||
</div>
|
||||
<div className="mb-4 flex max-w-md gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="search check"
|
||||
className="min-w-0 flex-1 border border-neutral-800 bg-neutral-950 px-2 py-1.5 text-sm text-neutral-100 outline-none focus:border-neutral-500"
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
className="border border-neutral-900 px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-300"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
) : null}
|
||||
<span className="text-sm text-neutral-400">
|
||||
{rows.length} of {groups.length} items
|
||||
</span>
|
||||
</div>
|
||||
<ListToolbar
|
||||
query={query}
|
||||
onQueryChange={onQueryChange}
|
||||
placeholder="search check"
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={onPageSizeChange}
|
||||
/>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 italic">No data.</div>
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>
|
||||
<SortButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
||||
CHECK
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
STATUS
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="last_observed_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="last_observed_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
LAST_OBSERVED_AT
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>AGENTS</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
{visible.map((row) => (
|
||||
<tr key={row.checkId}>
|
||||
<Td>
|
||||
<Link
|
||||
@@ -109,48 +125,11 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="border-b border-neutral-800 px-2 py-2 text-left text-xs font-semibold uppercase tracking-wide text-neutral-400">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
return <td className={`border-b border-neutral-900 px-2 py-1.5 align-top ${className}`}>{children}</td>;
|
||||
}
|
||||
|
||||
function SortButton({
|
||||
field,
|
||||
current,
|
||||
dir,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
field: SortField;
|
||||
current: SortField;
|
||||
dir: SortDir;
|
||||
onClick: (field: SortField) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const active = field === current;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(field)}
|
||||
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function filterChecks(rows: CheckGroup[], query: string): CheckGroup[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return rows;
|
||||
|
||||
39
web/src/components/ClientPager.tsx
Normal file
39
web/src/components/ClientPager.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
export function ClientPager({
|
||||
page,
|
||||
pageCount,
|
||||
onPageChange,
|
||||
}: {
|
||||
page: number;
|
||||
pageCount: number;
|
||||
onPageChange: (page: number) => void;
|
||||
}) {
|
||||
const prev = () => onPageChange(Math.max(1, page - 1));
|
||||
const next = () => onPageChange(Math.min(pageCount, page + 1));
|
||||
const disabledPrev = page <= 1;
|
||||
const disabledNext = page >= pageCount;
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-between text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={prev}
|
||||
disabled={disabledPrev}
|
||||
className="text-blue-300 hover:text-blue-200 disabled:cursor-default disabled:text-neutral-600"
|
||||
>
|
||||
← prev
|
||||
</button>
|
||||
<span className="text-neutral-500">
|
||||
page {page} of {pageCount}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
disabled={disabledNext}
|
||||
className="text-blue-300 hover:text-blue-200 disabled:cursor-default disabled:text-neutral-600"
|
||||
>
|
||||
next →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -39,3 +39,7 @@ export function Td({ children, className = "" }: { children: React.ReactNode; cl
|
||||
<td className={`px-2 py-1.5 border-b border-neutral-900 align-top ${className}`}>{children}</td>
|
||||
);
|
||||
}
|
||||
|
||||
export function SummaryCell({ text }: { text?: string | null }) {
|
||||
return <Td className="max-w-md truncate text-xs text-neutral-400">{text ?? "—"}</Td>;
|
||||
}
|
||||
|
||||
23
web/src/components/DetailTabs.tsx
Normal file
23
web/src/components/DetailTabs.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export type DetailTab = { key: string; label: string; href: string };
|
||||
|
||||
export function DetailTabs({ tabs, current }: { tabs: DetailTab[]; current: string }) {
|
||||
return (
|
||||
<div className="flex gap-4 border-b border-neutral-800 text-sm">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.key}
|
||||
href={tab.href}
|
||||
className={`pb-2 ${
|
||||
current === tab.key
|
||||
? "border-b border-neutral-200 text-neutral-100"
|
||||
: "text-neutral-400 hover:text-neutral-200"
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
|
||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import { type PageSize } from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
|
||||
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||
@@ -19,19 +22,32 @@ export function EventsBrowser({
|
||||
cursor,
|
||||
agentId,
|
||||
checkId,
|
||||
limit,
|
||||
}: {
|
||||
initialData: EventsPageData;
|
||||
cursor?: string;
|
||||
agentId?: string;
|
||||
checkId?: string;
|
||||
limit: PageSize;
|
||||
}) {
|
||||
const url = useMemo(() => eventsPollUrl({ cursor, agentId, checkId }), [agentId, checkId, cursor]);
|
||||
const router = useRouter();
|
||||
const url = useMemo(
|
||||
() => eventsPollUrl({ cursor, agentId, checkId, limit }),
|
||||
[agentId, checkId, cursor, limit],
|
||||
);
|
||||
const { data } = usePolling(initialData, url);
|
||||
|
||||
const onLimitChange = (next: PageSize) => {
|
||||
router.push(eventsHref({ agent_id: agentId, check_id: checkId, limit: next }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Events" count={data.items.length} />
|
||||
<ActiveFilters agentId={agentId} checkId={checkId} />
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<ActiveFilters agentId={agentId} checkId={checkId} limit={limit} />
|
||||
<PageSizeSelect value={limit} onChange={onLimitChange} />
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
@@ -62,7 +78,7 @@ export function EventsBrowser({
|
||||
</Td>
|
||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
||||
<Td>{e.duration_ms}</Td>
|
||||
<Td className="max-w-md truncate text-xs text-neutral-400">{e.output ?? "—"}</Td>
|
||||
<SummaryCell text={e.output} />
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -72,24 +88,43 @@ export function EventsBrowser({
|
||||
basePath="/events"
|
||||
cursor={cursor}
|
||||
nextCursor={data.next_cursor}
|
||||
extra={{ agent_id: agentId, check_id: checkId }}
|
||||
extra={{ agent_id: agentId, check_id: checkId, limit: String(limit) }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveFilters({ agentId, checkId }: { agentId?: string; checkId?: string }) {
|
||||
if (!agentId && !checkId) return null;
|
||||
function ActiveFilters({
|
||||
agentId,
|
||||
checkId,
|
||||
limit,
|
||||
}: {
|
||||
agentId?: string;
|
||||
checkId?: string;
|
||||
limit: PageSize;
|
||||
}) {
|
||||
if (!agentId && !checkId) return <div />;
|
||||
|
||||
return (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 text-xs">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
{agentId ? (
|
||||
<FilterChip label="agent_id" value={agentId} href={eventsHref({ check_id: checkId })} />
|
||||
<FilterChip
|
||||
label="agent_id"
|
||||
value={agentId}
|
||||
href={eventsHref({ check_id: checkId, limit })}
|
||||
/>
|
||||
) : null}
|
||||
{checkId ? (
|
||||
<FilterChip label="check_id" value={checkId} href={eventsHref({ agent_id: agentId })} />
|
||||
<FilterChip
|
||||
label="check_id"
|
||||
value={checkId}
|
||||
href={eventsHref({ agent_id: agentId, limit })}
|
||||
/>
|
||||
) : null}
|
||||
<Link href="/events" className="text-neutral-500 hover:text-neutral-300">
|
||||
<Link
|
||||
href={eventsHref({ limit })}
|
||||
className="text-neutral-500 hover:text-neutral-300"
|
||||
>
|
||||
clear
|
||||
</Link>
|
||||
</div>
|
||||
@@ -110,10 +145,15 @@ function FilterChip({ label, value, href }: { label: string; value: string; href
|
||||
);
|
||||
}
|
||||
|
||||
function eventsHref(params: { agent_id?: string; check_id?: string }): string {
|
||||
function eventsHref(params: {
|
||||
agent_id?: string;
|
||||
check_id?: string;
|
||||
limit?: PageSize;
|
||||
}): string {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.agent_id) qs.set("agent_id", params.agent_id);
|
||||
if (params.check_id) qs.set("check_id", params.check_id);
|
||||
if (params.limit) qs.set("limit", String(params.limit));
|
||||
const query = qs.toString();
|
||||
return query ? `/events?${query}` : "/events";
|
||||
}
|
||||
@@ -122,15 +162,17 @@ function eventsPollUrl({
|
||||
cursor,
|
||||
agentId,
|
||||
checkId,
|
||||
limit,
|
||||
}: {
|
||||
cursor?: string;
|
||||
agentId?: string;
|
||||
checkId?: string;
|
||||
limit: PageSize;
|
||||
}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (cursor) qs.set("cursor", cursor);
|
||||
if (agentId) qs.set("agent_id", agentId);
|
||||
if (checkId) qs.set("check_id", checkId);
|
||||
const query = qs.toString();
|
||||
return query ? `/api/poll/events?${query}` : "/api/poll/events";
|
||||
qs.set("limit", String(limit));
|
||||
return `/api/poll/events?${qs.toString()}`;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,22 @@
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AgentLink } from "@/components/AgentLink";
|
||||
import { CheckEventsLink } from "@/components/CheckEventsLink";
|
||||
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { ClientPager } from "@/components/ClientPager";
|
||||
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
|
||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
clampPage,
|
||||
pageCountOf,
|
||||
paginate,
|
||||
type PageSize,
|
||||
} from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
import type { InventoryData } from "@/lib/view-types";
|
||||
|
||||
@@ -15,104 +26,130 @@ const STATES = ["all", "open", "resolved"] as const;
|
||||
|
||||
type Incident = components["schemas"]["Incident"];
|
||||
type Agent = components["schemas"]["Agent"];
|
||||
type IncidentsPageData = { items: Incident[] };
|
||||
type IncidentsData = { items: Incident[] };
|
||||
type SortField = "status" | "host" | "check" | "opened_at" | "resolved_at";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
const stateRank: Record<Incident["state"], number> = { resolved: 0, open: 1 };
|
||||
const severityRank: Record<Incident["severity"], number> = { unknown: 1, warning: 2, critical: 3 };
|
||||
const stateRank: Record<Incident["state"], number> = { open: 0, resolved: 1 };
|
||||
const severityRank: Record<Incident["severity"], number> = {
|
||||
critical: 0,
|
||||
warning: 1,
|
||||
unknown: 2,
|
||||
};
|
||||
|
||||
export function IncidentsBrowser({
|
||||
initialData,
|
||||
initialItems,
|
||||
initialInventory,
|
||||
state,
|
||||
}: {
|
||||
initialData: IncidentsPageData;
|
||||
initialItems: Incident[];
|
||||
initialInventory: InventoryData;
|
||||
state?: "open" | "resolved";
|
||||
}) {
|
||||
const url = useMemo(() => incidentsPollUrl({ state }), [state]);
|
||||
const { data } = usePolling(initialData, url);
|
||||
const url = useMemo(
|
||||
() => (state ? `/api/poll/incidents?state=${state}` : "/api/poll/incidents"),
|
||||
[state],
|
||||
);
|
||||
const { data } = usePolling<IncidentsData>({ items: initialItems }, url);
|
||||
const { data: inventory } = usePolling(initialInventory, "/api/poll/inventory");
|
||||
const [sort, setSort] = useState<SortField>("status");
|
||||
const [dir, setDir] = useState<SortDir>("desc");
|
||||
const agentsById = useMemo(
|
||||
() => new Map(inventory.agents.map((agent) => [agent.agent_id, agent])),
|
||||
[inventory.agents],
|
||||
);
|
||||
const rows = useMemo(
|
||||
|
||||
const [sort, setSort] = useState<SortField>("status");
|
||||
const [dir, setDir] = useState<SortDir>("asc");
|
||||
const sorted = useMemo(
|
||||
() => sortIncidents(data.items, agentsById, sort, dir),
|
||||
[agentsById, data.items, dir, sort],
|
||||
);
|
||||
|
||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||
const [page, setPage] = useState(1);
|
||||
const onPageSizeChange = (next: PageSize) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
const chooseSort = (field: SortField) => {
|
||||
if (field === sort) {
|
||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
} else {
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
}
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const pageCount = pageCountOf(sorted.length, pageSize);
|
||||
const currentPage = clampPage(page, pageCount);
|
||||
const visible = paginate(sorted, currentPage, pageSize);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Incidents" count={rows.length} />
|
||||
<div className="mb-3 flex gap-3 text-sm">
|
||||
{STATES.map((s) => {
|
||||
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`;
|
||||
const active = (s === "all" && !state) || s === state;
|
||||
return (
|
||||
<Link
|
||||
key={s}
|
||||
href={href}
|
||||
className={active ? "text-white underline" : "text-neutral-400 hover:text-white"}
|
||||
>
|
||||
{s}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<PageHeader title="Incidents" count={sorted.length} />
|
||||
<div className="mb-3 flex items-center justify-between gap-3 text-sm">
|
||||
<div className="flex gap-3">
|
||||
{STATES.map((s) => {
|
||||
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`;
|
||||
const active = (s === "all" && !state) || s === state;
|
||||
return (
|
||||
<Link
|
||||
key={s}
|
||||
href={href}
|
||||
className={active ? "text-white underline" : "text-neutral-400 hover:text-white"}
|
||||
>
|
||||
{s}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
{sorted.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>
|
||||
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
STATUS
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
||||
HOST
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="check" current={sort} dir={dir} onClick={chooseSort}>
|
||||
CHECK
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="opened_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="opened_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
OPENED_AT
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="resolved_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
<SortHeaderButton field="resolved_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
RESOLVED_AT
|
||||
</SortButton>
|
||||
</SortHeaderButton>
|
||||
</Th>
|
||||
<Th>SUMMARY</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ incident, agent }) => (
|
||||
<IncidentRow key={incident.id} incident={incident} agent={agent} />
|
||||
{visible.map((incident) => (
|
||||
<IncidentRow
|
||||
key={incident.id}
|
||||
incident={incident}
|
||||
agent={agentsById.get(incidentAgentId(incident))}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -128,21 +165,7 @@ function IncidentRow({ incident, agent }: { incident: Incident; agent?: Agent })
|
||||
<span className={statusBadgeClass(incident.severity)}>{incident.severity}</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{agentId ? (
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(agentId)}`}
|
||||
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
<span>{agent?.hostname || agentId}</span>
|
||||
{agent && agent.hostname !== agentId ? (
|
||||
<span className="text-xs text-neutral-500">{agentId}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Td>
|
||||
<Td>{agentId ? <AgentLink agent={agent} agentId={agentId} /> : "—"}</Td>
|
||||
<Td>
|
||||
<IncidentCheckLink agentId={agentId} checkId={checkId} />
|
||||
</Td>
|
||||
@@ -152,7 +175,7 @@ function IncidentRow({ incident, agent }: { incident: Incident; agent?: Agent })
|
||||
<Td>
|
||||
<DateTime iso={incident.resolved_at} />
|
||||
</Td>
|
||||
<Td className="max-w-md truncate text-xs text-neutral-400">{incident.summary ?? "—"}</Td>
|
||||
<SummaryCell text={incident.summary} />
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -172,97 +195,6 @@ function IncidentCheckLink({ agentId, checkId }: { agentId: string; checkId: str
|
||||
return agentId ? <CheckEventsLink agentId={agentId} checkId={checkId} /> : <span>{checkId}</span>;
|
||||
}
|
||||
|
||||
function SortButton({
|
||||
field,
|
||||
current,
|
||||
dir,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
field: SortField;
|
||||
current: SortField;
|
||||
dir: SortDir;
|
||||
onClick: (field: SortField) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const active = field === current;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(field)}
|
||||
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function fieldDefaultDir(field: SortField): SortDir {
|
||||
return field === "host" || field === "check" ? "asc" : "desc";
|
||||
}
|
||||
|
||||
function sortIncidents(
|
||||
incidents: Incident[],
|
||||
agentsById: Map<string, Agent>,
|
||||
sort: SortField,
|
||||
dir: SortDir,
|
||||
): { incident: Incident; agent?: Agent }[] {
|
||||
return incidents
|
||||
.map((incident) => ({ incident, agent: agentsById.get(incidentAgentId(incident)) }))
|
||||
.sort((a, b) => {
|
||||
const primary = compareByField(a, b, sort);
|
||||
if (primary !== 0) return dir === "asc" ? primary : -primary;
|
||||
return fallbackCompare(a, b);
|
||||
});
|
||||
}
|
||||
|
||||
function compareByField(
|
||||
a: { incident: Incident; agent?: Agent },
|
||||
b: { incident: Incident; agent?: Agent },
|
||||
sort: SortField,
|
||||
): number {
|
||||
switch (sort) {
|
||||
case "host":
|
||||
return hostName(a).localeCompare(hostName(b)) || incidentAgentId(a.incident).localeCompare(incidentAgentId(b.incident));
|
||||
case "check":
|
||||
return incidentCheckId(a.incident).localeCompare(incidentCheckId(b.incident));
|
||||
case "opened_at":
|
||||
return Date.parse(a.incident.opened_at) - Date.parse(b.incident.opened_at);
|
||||
case "resolved_at":
|
||||
return dateValue(a.incident.resolved_at) - dateValue(b.incident.resolved_at);
|
||||
case "status":
|
||||
return compareIncidentImportance(a.incident, b.incident);
|
||||
}
|
||||
}
|
||||
|
||||
function compareIncidentImportance(a: Incident, b: Incident): number {
|
||||
return (
|
||||
stateRank[a.state] - stateRank[b.state] ||
|
||||
severityRank[a.severity] - severityRank[b.severity] ||
|
||||
Date.parse(a.opened_at) - Date.parse(b.opened_at)
|
||||
);
|
||||
}
|
||||
|
||||
function fallbackCompare(
|
||||
a: { incident: Incident; agent?: Agent },
|
||||
b: { incident: Incident; agent?: Agent },
|
||||
): number {
|
||||
return (
|
||||
compareIncidentImportance(a.incident, b.incident) ||
|
||||
hostName(a).localeCompare(hostName(b)) ||
|
||||
incidentCheckId(a.incident).localeCompare(incidentCheckId(b.incident))
|
||||
);
|
||||
}
|
||||
|
||||
function dateValue(value?: string | null): number {
|
||||
return value ? Date.parse(value) : 0;
|
||||
}
|
||||
|
||||
function hostName(row: { incident: Incident; agent?: Agent }): string {
|
||||
return row.agent?.hostname || incidentAgentId(row.incident);
|
||||
}
|
||||
|
||||
function incidentAgentId(incident: Incident): string {
|
||||
return incident.agent_id ?? "";
|
||||
}
|
||||
@@ -271,6 +203,56 @@ function incidentCheckId(incident: Incident): string {
|
||||
return incident.check_id ?? "";
|
||||
}
|
||||
|
||||
function incidentsPollUrl({ state }: { state?: string }) {
|
||||
return state ? `/api/poll/incidents?state=${encodeURIComponent(state)}` : "/api/poll/incidents";
|
||||
function fieldDefaultDir(field: SortField): SortDir {
|
||||
switch (field) {
|
||||
case "status":
|
||||
return "asc";
|
||||
case "host":
|
||||
case "check":
|
||||
return "asc";
|
||||
case "opened_at":
|
||||
case "resolved_at":
|
||||
return "desc";
|
||||
}
|
||||
}
|
||||
|
||||
function sortIncidents(
|
||||
items: Incident[],
|
||||
agentsById: Map<string, Agent>,
|
||||
sort: SortField,
|
||||
dir: SortDir,
|
||||
): Incident[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const primary = compareIncidents(a, b, agentsById, sort);
|
||||
if (primary !== 0) return dir === "asc" ? primary : -primary;
|
||||
return (b.opened_at ?? "").localeCompare(a.opened_at ?? "");
|
||||
});
|
||||
}
|
||||
|
||||
function compareIncidents(
|
||||
a: Incident,
|
||||
b: Incident,
|
||||
agentsById: Map<string, Agent>,
|
||||
sort: SortField,
|
||||
): number {
|
||||
switch (sort) {
|
||||
case "status": {
|
||||
const s = stateRank[a.state] - stateRank[b.state];
|
||||
if (s !== 0) return s;
|
||||
return severityRank[a.severity] - severityRank[b.severity];
|
||||
}
|
||||
case "host":
|
||||
return hostFor(a, agentsById).localeCompare(hostFor(b, agentsById));
|
||||
case "check":
|
||||
return incidentCheckId(a).localeCompare(incidentCheckId(b));
|
||||
case "opened_at":
|
||||
return (a.opened_at ?? "").localeCompare(b.opened_at ?? "");
|
||||
case "resolved_at":
|
||||
return (a.resolved_at ?? "").localeCompare(b.resolved_at ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
function hostFor(incident: Incident, agentsById: Map<string, Agent>): string {
|
||||
const id = incidentAgentId(incident);
|
||||
return agentsById.get(id)?.hostname || id;
|
||||
}
|
||||
|
||||
42
web/src/components/ListToolbar.tsx
Normal file
42
web/src/components/ListToolbar.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||
import { type PageSize } from "@/lib/pagination";
|
||||
|
||||
export function ListToolbar({
|
||||
query,
|
||||
onQueryChange,
|
||||
placeholder,
|
||||
pageSize,
|
||||
onPageSizeChange,
|
||||
}: {
|
||||
query: string;
|
||||
onQueryChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
pageSize: PageSize;
|
||||
onPageSizeChange: (next: PageSize) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex max-w-md flex-1 gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => onQueryChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="min-w-0 flex-1 border border-neutral-800 bg-neutral-950 px-2 py-1.5 text-sm text-neutral-100 outline-none focus:border-neutral-500"
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onQueryChange("")}
|
||||
className="border border-neutral-900 px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-300"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ClientPager } from "@/components/ClientPager";
|
||||
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { PageSizeSelect } from "@/components/PageSizeSelect";
|
||||
import { DateTime } from "@/components/TimeZoneControls";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
clampPage,
|
||||
pageCountOf,
|
||||
paginate,
|
||||
type PageSize,
|
||||
} from "@/lib/pagination";
|
||||
import { usePolling } from "@/lib/usePolling";
|
||||
|
||||
type OutboxItem = components["schemas"]["NotificationOutboxItem"];
|
||||
type OutboxPageData = { items: OutboxItem[]; next_cursor?: string | null };
|
||||
type OutboxData = { items: OutboxItem[] };
|
||||
|
||||
export function OutboxBrowser({
|
||||
initialData,
|
||||
cursor,
|
||||
}: {
|
||||
initialData: OutboxPageData;
|
||||
cursor?: string;
|
||||
}) {
|
||||
const url = useMemo(() => {
|
||||
if (!cursor) return "/api/poll/outbox";
|
||||
return `/api/poll/outbox?cursor=${encodeURIComponent(cursor)}`;
|
||||
}, [cursor]);
|
||||
const { data } = usePolling(initialData, url);
|
||||
export function OutboxBrowser({ initialItems }: { initialItems: OutboxItem[] }) {
|
||||
const { data } = usePolling<OutboxData>({ items: initialItems }, "/api/poll/outbox");
|
||||
|
||||
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
|
||||
const [page, setPage] = useState(1);
|
||||
const onPageSizeChange = (next: PageSize) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const pageCount = pageCountOf(data.items.length, pageSize);
|
||||
const currentPage = clampPage(page, pageCount);
|
||||
const visible = paginate(data.items, currentPage, pageSize);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Notification outbox" count={data.items.length} />
|
||||
<div className="mb-3 flex justify-end">
|
||||
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
@@ -44,7 +56,7 @@ export function OutboxBrowser({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((o) => (
|
||||
{visible.map((o) => (
|
||||
<tr key={o.id}>
|
||||
<Td>
|
||||
<DateTime iso={o.updated_at} />
|
||||
@@ -62,7 +74,7 @@ export function OutboxBrowser({
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager basePath="/outbox" cursor={cursor} nextCursor={data.next_cursor} />
|
||||
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
30
web/src/components/PageSizeSelect.tsx
Normal file
30
web/src/components/PageSizeSelect.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
"use client";
|
||||
|
||||
import { PAGE_SIZE_OPTIONS, type PageSize } from "@/lib/pagination";
|
||||
|
||||
export function PageSizeSelect({
|
||||
value,
|
||||
onChange,
|
||||
options = PAGE_SIZE_OPTIONS,
|
||||
}: {
|
||||
value: PageSize;
|
||||
onChange: (size: PageSize) => void;
|
||||
options?: readonly number[];
|
||||
}) {
|
||||
return (
|
||||
<label className="inline-flex items-center gap-2 text-xs text-neutral-400">
|
||||
<span>per page</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value) as PageSize)}
|
||||
className="border border-neutral-800 bg-neutral-950 px-2 py-1 text-sm text-neutral-100 outline-none focus:border-neutral-500"
|
||||
>
|
||||
{options.map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
31
web/src/components/SortHeaderButton.tsx
Normal file
31
web/src/components/SortHeaderButton.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type SortDir = "asc" | "desc";
|
||||
|
||||
export function SortHeaderButton<F extends string>({
|
||||
field,
|
||||
current,
|
||||
dir,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
field: F;
|
||||
current: F;
|
||||
dir: SortDir;
|
||||
onClick: (field: F) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const active = field === current;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(field)}
|
||||
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import type { components } from "@/lib/api-types";
|
||||
|
||||
export type Agent = components["schemas"]["Agent"];
|
||||
export type CheckState = components["schemas"]["CheckState"];
|
||||
export type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||
export type CheckStatus = CheckState["status"];
|
||||
export type CheckCounts = Record<CheckStatus, number>;
|
||||
|
||||
@@ -44,67 +43,6 @@ export function groupChecks(checks: CheckState[], agentsById: Map<string, Agent>
|
||||
return [...byCheck.values()];
|
||||
}
|
||||
|
||||
export function withLivenessChecks(agents: Agent[], checks: CheckState[]): CheckState[] {
|
||||
const regularChecks = checks.filter((check) => check.check_id !== LIVENESS_CHECK_ID);
|
||||
const livenessChecks = agents
|
||||
.map(livenessCheckForAgent)
|
||||
.filter((check): check is CheckState => check !== null);
|
||||
return [...regularChecks, ...livenessChecks];
|
||||
}
|
||||
|
||||
export function livenessCheckForAgent(agent: Agent): CheckState | null {
|
||||
if (agent.status !== "stale" && agent.status !== "dead") return null;
|
||||
const status: CheckStatus = agent.status === "dead" ? "critical" : "warning";
|
||||
return {
|
||||
agent_id: agent.agent_id,
|
||||
check_id: LIVENESS_CHECK_ID,
|
||||
status,
|
||||
last_observed_at: agent.last_seen_at,
|
||||
exit_code: status === "critical" ? 2 : 1,
|
||||
incident_key: `agent:${agent.agent_id}:liveness`,
|
||||
summary: `agent is ${agent.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function withLivenessEvents(
|
||||
agents: Agent[],
|
||||
events: StoredEvent[],
|
||||
filters: { agentId?: string; checkId?: string; cursor?: string } = {},
|
||||
): StoredEvent[] {
|
||||
if (filters.cursor) return events;
|
||||
const livenessEvents = agents
|
||||
.map(livenessEventForAgent)
|
||||
.filter((event): event is StoredEvent => event !== null)
|
||||
.filter((event) => {
|
||||
if (filters.agentId && event.agent_id !== filters.agentId) return false;
|
||||
if (filters.checkId && event.check_id !== filters.checkId) return false;
|
||||
return true;
|
||||
});
|
||||
return [...events, ...livenessEvents].sort((a, b) => b.observed_at.localeCompare(a.observed_at));
|
||||
}
|
||||
|
||||
export function livenessEventForAgent(agent: Agent): StoredEvent | null {
|
||||
const check = livenessCheckForAgent(agent);
|
||||
if (!check) return null;
|
||||
// Synthetic event represents the current liveness state, not the last heartbeat,
|
||||
// so observed_at uses "now" to keep it at the top of the events list across polls.
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
event_id: `synthetic:${LIVENESS_CHECK_ID}:${agent.agent_id}:${agent.status}:${agent.last_seen_at}`,
|
||||
agent_id: agent.agent_id,
|
||||
check_id: LIVENESS_CHECK_ID,
|
||||
observed_at: now,
|
||||
received_at: now,
|
||||
status: check.status,
|
||||
exit_code: check.status === "critical" ? 2 : 1,
|
||||
duration_ms: 0,
|
||||
output: check.summary,
|
||||
output_truncated: false,
|
||||
notifications_enabled: false,
|
||||
incident_key: check.incident_key,
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyCheckCounts(): CheckCounts {
|
||||
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { ApiError, api, type Agent, type CheckState, type StoredEvent } from "@/lib/api";
|
||||
import { api, type Agent, type CheckState, type OutboxItem, type StoredEvent } from "@/lib/api";
|
||||
import type { components } from "@/lib/api-types";
|
||||
import { LIVENESS_CHECK_ID, withLivenessChecks, withLivenessEvents } from "@/lib/check-groups";
|
||||
import type { InventoryData, OverviewData } from "@/lib/view-types";
|
||||
|
||||
type Incident = components["schemas"]["Incident"];
|
||||
@@ -31,10 +30,10 @@ export async function loadChecks(agentId?: string): Promise<CheckState[]> {
|
||||
|
||||
export async function loadInventory(): Promise<InventoryData> {
|
||||
const [agents, checks] = await Promise.all([loadAgents(), loadChecks()]);
|
||||
return { agents, checks: withLivenessChecks(agents, checks) };
|
||||
return { agents, checks };
|
||||
}
|
||||
|
||||
export async function loadEventsWithLiveness({
|
||||
export async function loadEvents({
|
||||
agentId,
|
||||
checkId,
|
||||
cursor,
|
||||
@@ -45,28 +44,10 @@ export async function loadEventsWithLiveness({
|
||||
cursor?: string;
|
||||
limit?: number;
|
||||
} = {}): Promise<{ items: StoredEvent[]; next_cursor?: string | null }> {
|
||||
const data = await api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, limit });
|
||||
if (cursor || (checkId && checkId !== LIVENESS_CHECK_ID)) return data;
|
||||
|
||||
const agents = agentId ? await loadOptionalAgent(agentId) : await loadAgents();
|
||||
return {
|
||||
...data,
|
||||
items: withLivenessEvents(agents, data.items, { agentId, checkId, cursor }),
|
||||
};
|
||||
return api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, limit });
|
||||
}
|
||||
|
||||
async function loadOptionalAgent(agentId: string): Promise<Agent[]> {
|
||||
try {
|
||||
return [await api.getAgent(agentId)];
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) return [];
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadIncidents(
|
||||
state?: "open" | "resolved",
|
||||
): Promise<{ items: Incident[] }> {
|
||||
export async function loadIncidents(state?: "open" | "resolved"): Promise<Incident[]> {
|
||||
const items: Incident[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
@@ -74,25 +55,46 @@ export async function loadIncidents(
|
||||
items.push(...page.items);
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return { items };
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function loadOutboxAll(): Promise<OutboxItem[]> {
|
||||
const items: OutboxItem[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listOutbox({ cursor, limit: 500 });
|
||||
items.push(...page.items);
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function loadOverview(): Promise<OverviewData> {
|
||||
const [agents, checks, incidents] = await Promise.all([
|
||||
const [agents, checks, openIncidents] = await Promise.all([
|
||||
loadAgents(),
|
||||
loadChecks(),
|
||||
api.listIncidents({ state: "open", limit: 500 }),
|
||||
countOpenIncidents(),
|
||||
]);
|
||||
const checksWithLiveness = withLivenessChecks(agents, checks);
|
||||
return {
|
||||
agents: tally(agents),
|
||||
checks: tally(checksWithLiveness),
|
||||
openIncidents: incidents.items.length,
|
||||
checks: tally(checks),
|
||||
openIncidents,
|
||||
totalAgents: agents.length,
|
||||
totalChecks: checksWithLiveness.length,
|
||||
totalChecks: checks.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function countOpenIncidents(): Promise<number> {
|
||||
let total = 0;
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listIncidents({ state: "open", cursor, limit: 500 });
|
||||
total += page.items.length;
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return total;
|
||||
}
|
||||
|
||||
function tally(xs: { status?: string }[]): Record<string, number> {
|
||||
return xs.reduce<Record<string, number>>((acc, x) => {
|
||||
const k = x.status ?? "unknown";
|
||||
|
||||
23
web/src/lib/pagination.ts
Normal file
23
web/src/lib/pagination.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const PAGE_SIZE_OPTIONS = [10, 50, 100] as const;
|
||||
export type PageSize = (typeof PAGE_SIZE_OPTIONS)[number];
|
||||
export const DEFAULT_PAGE_SIZE: PageSize = 10;
|
||||
|
||||
export function pageCountOf(total: number, pageSize: number): number {
|
||||
return Math.max(1, Math.ceil(total / pageSize));
|
||||
}
|
||||
|
||||
export function clampPage(page: number, pageCount: number): number {
|
||||
if (page < 1) return 1;
|
||||
if (page > pageCount) return pageCount;
|
||||
return page;
|
||||
}
|
||||
|
||||
export function paginate<T>(items: T[], page: number, pageSize: number): T[] {
|
||||
const start = (page - 1) * pageSize;
|
||||
return items.slice(start, start + pageSize);
|
||||
}
|
||||
|
||||
export function normalizePageSize(value: unknown, fallback: PageSize = DEFAULT_PAGE_SIZE): PageSize {
|
||||
const n = Number(value);
|
||||
return (PAGE_SIZE_OPTIONS as readonly number[]).includes(n) ? (n as PageSize) : fallback;
|
||||
}
|
||||
Reference in New Issue
Block a user