271 lines
7.9 KiB
TypeScript
271 lines
7.9 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import { AgentLink } from "@/components/AgentLink";
|
|
import { CheckStatusSummary } from "@/components/CheckStatusSummary";
|
|
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, 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";
|
|
|
|
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
|
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null };
|
|
type TabKey = "agents" | "events";
|
|
|
|
export function CheckDetailBrowser({
|
|
checkId,
|
|
initialData,
|
|
initialEvents,
|
|
tab,
|
|
eventsLimit,
|
|
eventsCursor,
|
|
eventsBack,
|
|
eventsPage,
|
|
}: {
|
|
checkId: string;
|
|
initialData: InventoryData;
|
|
initialEvents: EventsPageData;
|
|
tab: TabKey;
|
|
eventsLimit: PageSize;
|
|
eventsCursor?: string;
|
|
eventsBack: boolean;
|
|
eventsPage: number;
|
|
}) {
|
|
const { data } = usePolling(initialData, "/api/poll/inventory");
|
|
const agentsById = useMemo(
|
|
() => new Map(data.agents.map((agent) => [agent.agent_id, agent])),
|
|
[data.agents],
|
|
);
|
|
const group = useMemo(
|
|
() => groupChecks(data.checks, agentsById).find((item) => item.checkId === checkId),
|
|
[agentsById, checkId, data.checks],
|
|
);
|
|
|
|
if (!group) {
|
|
return (
|
|
<div>
|
|
<Link href="/checks" className="text-sm text-blue-300 hover:text-blue-200">
|
|
← checks
|
|
</Link>
|
|
<h1 className="mt-2 text-lg font-semibold">{checkId}</h1>
|
|
<div className="mt-4 text-sm text-neutral-500">Check not found.</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<Link href="/checks" className="text-sm text-blue-300 hover:text-blue-200">
|
|
← checks
|
|
</Link>
|
|
<h1 className="mt-2 text-lg font-semibold">{checkId}</h1>
|
|
</div>
|
|
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
|
<dt className="text-neutral-400">status</dt>
|
|
<dd>
|
|
<CheckStatusSummary counts={group.counts} />
|
|
</dd>
|
|
<dt className="text-neutral-400">agents</dt>
|
|
<dd>{group.checks.length}</dd>
|
|
<dt className="text-neutral-400">last_observed_at</dt>
|
|
<dd>
|
|
<DateTime iso={group.lastObservedAt} />
|
|
</dd>
|
|
</dl>
|
|
<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}
|
|
eventsLimit={eventsLimit}
|
|
eventsCursor={eventsCursor}
|
|
eventsBack={eventsBack}
|
|
eventsPage={eventsPage}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AgentsForCheck({ group }: { group: CheckGroup }) {
|
|
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 (
|
|
<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>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
function EventsForCheck({
|
|
checkId,
|
|
initialData,
|
|
agentsById,
|
|
eventsLimit,
|
|
eventsCursor,
|
|
eventsBack,
|
|
eventsPage,
|
|
}: {
|
|
checkId: string;
|
|
initialData: EventsPageData;
|
|
agentsById: Map<string, Agent>;
|
|
eventsLimit: PageSize;
|
|
eventsCursor?: string;
|
|
eventsBack: boolean;
|
|
eventsPage: number;
|
|
}) {
|
|
const router = useRouter();
|
|
const pollUrl = useMemo(() => {
|
|
const qs = new URLSearchParams({
|
|
check_id: checkId,
|
|
limit: String(eventsLimit),
|
|
});
|
|
if (eventsCursor) qs.set("cursor", eventsCursor);
|
|
if (eventsBack) qs.set("back", "1");
|
|
return `/api/poll/events?${qs.toString()}`;
|
|
}, [checkId, eventsCursor, eventsBack, eventsLimit]);
|
|
const { data } = usePolling(initialData, pollUrl);
|
|
|
|
const onLimitChange = (next: PageSize) => {
|
|
router.push(
|
|
`/checks/${encodeURIComponent(checkId)}?tab=events&events_limit=${next}`,
|
|
);
|
|
};
|
|
|
|
return (
|
|
<section>
|
|
<div className="mb-3 flex justify-end">
|
|
<PageSizeSelect value={eventsLimit} onChange={onLimitChange} />
|
|
</div>
|
|
{data.items.length === 0 ? (
|
|
<EmptyPanel label="No events" />
|
|
) : (
|
|
<table className="w-full text-sm">
|
|
<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>
|
|
<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)}`}
|
|
prevCursor={data.prev_cursor}
|
|
nextCursor={data.next_cursor}
|
|
page={eventsPage}
|
|
extra={{ tab: "events", events_limit: String(eventsLimit) }}
|
|
/>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function hostName(row: { agent?: Agent; check: { agent_id: string } }): string {
|
|
return row.agent?.hostname || row.check.agent_id;
|
|
}
|