refactor web pagination and add partitioned events, flap/timeout showcase agents
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user