Harden production workflows and agent admission

This commit is contained in:
Stanislav Rossovskii
2026-05-28 14:19:27 +04:00
parent a2e88b4e76
commit 37b1a1d6d6
109 changed files with 4927 additions and 894 deletions

View File

@@ -2,7 +2,7 @@ import { AgentDetailBrowser } from "@/components/AgentDetailBrowser";
import { ErrorPanel } from "@/components/DataPanel";
import { ApiError, api } from "@/lib/api";
import { loadChecks } from "@/lib/loaders";
import { normalizePageSize } from "@/lib/pagination";
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
export const dynamic = "force-dynamic";
@@ -30,6 +30,8 @@ export default async function AgentDetailPage({
tab?: string;
events_limit?: string;
cursor?: string;
back?: string;
page?: string;
}>;
}) {
const { agent_id } = await params;
@@ -38,10 +40,12 @@ export default async function AgentDetailPage({
const tab = normalizeTab(sp.tab);
const eventsLimit = normalizePageSize(sp.events_limit, 50);
const eventsCursor = sp.cursor;
const eventsPage = normalizePageNumber(sp.page);
const eventsBack = normalizeBack(sp.back);
let agent;
let checks;
let events: EventsPage = { items: [], next_cursor: null };
let events: EventsPage = { items: [], next_cursor: null, prev_cursor: null };
try {
[agent, checks] = await Promise.all([api.getAgent(agent_id), loadChecks(agent_id)]);
if (tab === "events") {
@@ -49,6 +53,7 @@ export default async function AgentDetailPage({
agent_id,
limit: eventsLimit,
cursor: eventsCursor,
back: eventsBack,
});
}
} catch (e) {
@@ -59,13 +64,15 @@ export default async function AgentDetailPage({
}
return (
<AgentDetailBrowser
key={`${agent_id}:${tab}:${sort}:${eventsLimit}:${eventsCursor ?? ""}`}
key={`${agent_id}:${tab}:${sort}:${eventsLimit}:${eventsCursor ?? ""}:${eventsBack ? "b" : "f"}:${eventsPage}`}
initialData={{ agent, checks }}
initialEvents={events}
sort={sort}
tab={tab}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
eventsBack={eventsBack}
eventsPage={eventsPage}
/>
);
}

View File

@@ -0,0 +1,15 @@
import { AgentBlacklistBrowser } from "@/components/AgentBlacklistBrowser";
import { ErrorPanel } from "@/components/DataPanel";
import { loadAgentBlacklist } from "@/lib/loaders";
export const dynamic = "force-dynamic";
export default async function AgentBlacklistPage() {
let rows;
try {
rows = await loadAgentBlacklist();
} catch (e) {
return <ErrorPanel error={e} />;
}
return <AgentBlacklistBrowser initialRows={rows} />;
}

View File

@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { api } from "@/lib/api";
import { jsonError } from "@/lib/poll-response";
export const dynamic = "force-dynamic";
type Action =
| "accept"
| "block"
| "remove"
| "accept_all"
| "block_all"
| "remove_all"
| "blacklist_delete"
| "blacklist_clear";
export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as {
action?: Action;
agent_id?: string;
blacklist_id?: string;
include_rotation?: boolean;
};
switch (body.action) {
case "accept":
return NextResponse.json(await api.acceptAgent(required(body.agent_id, "agent_id")));
case "block":
return NextResponse.json(await api.blockAgent(required(body.agent_id, "agent_id")));
case "remove":
return NextResponse.json(await api.removeAgent(required(body.agent_id, "agent_id")));
case "accept_all":
return NextResponse.json(await api.acceptAllPendingAgents(body.include_rotation === true));
case "block_all":
return NextResponse.json(await api.blockAllPendingAgents());
case "remove_all":
return NextResponse.json(await api.removeAllPendingAgents());
case "blacklist_delete":
return NextResponse.json(
await api.deleteAgentBlacklistItem(required(body.blacklist_id, "blacklist_id")),
);
case "blacklist_clear":
return NextResponse.json(await api.clearAgentBlacklist());
default:
return NextResponse.json({ error: "unknown action" }, { status: 400 });
}
} catch (e) {
return jsonError(e);
}
}
function required(value: string | undefined, name: string): string {
if (!value) throw new Error(`${name} is required`);
return value;
}

View File

@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
export function GET() {
return NextResponse.json({ ok: true });
}

View File

@@ -15,6 +15,7 @@ export async function GET(request: Request) {
agentId: url.searchParams.get("agent_id") ?? undefined,
checkId: url.searchParams.get("check_id") ?? undefined,
cursor: url.searchParams.get("cursor") ?? undefined,
back: url.searchParams.get("back") === "1",
limit,
});
return NextResponse.json(data);

View File

@@ -1,18 +1,35 @@
import { NextResponse } from "next/server";
import { loadIncidents } from "@/lib/loaders";
import { loadIncidentsPage } from "@/lib/loaders";
import { jsonError } from "@/lib/poll-response";
export const dynamic = "force-dynamic";
const SORTS = new Set(["status", "opened_at", "resolved_at"]);
const DIRS = new Set(["asc", "desc"]);
// PH-001: poll a single page, not the entire incident history. Sort and
// direction are forwarded so the server orders consistently across pages.
export async function GET(request: Request) {
const url = new URL(request.url);
const stateParam = url.searchParams.get("state");
const state = stateParam === "open" || stateParam === "resolved" ? stateParam : undefined;
const cursor = url.searchParams.get("cursor") ?? undefined;
const back = url.searchParams.get("back") === "1";
const limitRaw = url.searchParams.get("limit");
const parsed = limitRaw ? Number(limitRaw) : undefined;
const limit = parsed && Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 500) : 100;
const sortRaw = url.searchParams.get("sort") ?? undefined;
const sort =
sortRaw && SORTS.has(sortRaw)
? (sortRaw as "status" | "opened_at" | "resolved_at")
: undefined;
const dirRaw = url.searchParams.get("direction") ?? undefined;
const direction = dirRaw && DIRS.has(dirRaw) ? (dirRaw as "asc" | "desc") : undefined;
try {
const items = await loadIncidents(state);
return NextResponse.json({ items });
const page = await loadIncidentsPage({ state, cursor, back, limit, sort, direction });
return NextResponse.json(page);
} catch (e) {
return jsonError(e);
}

View File

@@ -1,14 +1,38 @@
import { NextResponse } from "next/server";
import { loadOutboxAll } from "@/lib/loaders";
import { loadOutboxPage } from "@/lib/loaders";
import { jsonError } from "@/lib/poll-response";
export const dynamic = "force-dynamic";
export async function GET() {
const SORTS = new Set(["created_at", "updated_at"]);
const DIRS = new Set(["asc", "desc"]);
// PH-002: poll a single outbox page; the table can grow under notifier failures.
export async function GET(request: Request) {
const url = new URL(request.url);
const cursor = url.searchParams.get("cursor") ?? undefined;
const back = url.searchParams.get("back") === "1";
const limitRaw = url.searchParams.get("limit");
const parsed = limitRaw ? Number(limitRaw) : undefined;
const limit = parsed && Number.isFinite(parsed) ? Math.min(Math.max(parsed, 1), 500) : 100;
const state = url.searchParams.get("state") ?? undefined;
const sortRaw = url.searchParams.get("sort") ?? undefined;
const sort =
sortRaw && SORTS.has(sortRaw) ? (sortRaw as "created_at" | "updated_at") : undefined;
const dirRaw = url.searchParams.get("direction") ?? undefined;
const direction = dirRaw && DIRS.has(dirRaw) ? (dirRaw as "asc" | "desc") : undefined;
try {
const items = await loadOutboxAll();
return NextResponse.json({ items });
const page = await loadOutboxPage({
cursor,
back,
limit,
state: state as never,
sort,
direction,
});
return NextResponse.json(page);
} catch (e) {
return jsonError(e);
}

View File

@@ -1,13 +1,13 @@
import { NextResponse } from "next/server";
import { loadOverview } from "@/lib/loaders";
import { loadSystemSummary } from "@/lib/loaders";
import { jsonError } from "@/lib/poll-response";
export const dynamic = "force-dynamic";
export async function GET() {
try {
return NextResponse.json(await loadOverview());
return NextResponse.json(await loadSystemSummary());
} catch (e) {
return jsonError(e);
}

View File

@@ -2,7 +2,7 @@ import { CheckDetailBrowser } from "@/components/CheckDetailBrowser";
import { ErrorPanel } from "@/components/DataPanel";
import { api } from "@/lib/api";
import { loadInventory } from "@/lib/loaders";
import { normalizePageSize } from "@/lib/pagination";
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
export const dynamic = "force-dynamic";
@@ -18,16 +18,24 @@ export default async function CheckDetailPage({
searchParams,
}: {
params: Promise<{ check_id: string }>;
searchParams: Promise<{ tab?: string; events_limit?: string; cursor?: string }>;
searchParams: Promise<{
tab?: string;
events_limit?: string;
cursor?: string;
back?: string;
page?: 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;
const eventsPage = normalizePageNumber(sp.page);
const eventsBack = normalizeBack(sp.back);
let data;
let events: EventsPage = { items: [], next_cursor: null };
let events: EventsPage = { items: [], next_cursor: null, prev_cursor: null };
try {
data = await loadInventory();
if (tab === "events") {
@@ -35,6 +43,7 @@ export default async function CheckDetailPage({
check_id,
limit: eventsLimit,
cursor: eventsCursor,
back: eventsBack,
});
}
} catch (e) {
@@ -43,13 +52,15 @@ export default async function CheckDetailPage({
return (
<CheckDetailBrowser
key={`${check_id}:${tab}:${eventsLimit}:${eventsCursor ?? ""}`}
key={`${check_id}:${tab}:${eventsLimit}:${eventsCursor ?? ""}:${eventsBack ? "b" : "f"}:${eventsPage}`}
checkId={check_id}
initialData={data}
initialEvents={events}
tab={tab}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
eventsBack={eventsBack}
eventsPage={eventsPage}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { ErrorPanel } from "@/components/DataPanel";
import { EventsBrowser } from "@/components/EventsBrowser";
import { loadEvents } from "@/lib/loaders";
import { normalizePageSize } from "@/lib/pagination";
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
export const dynamic = "force-dynamic";
@@ -10,19 +10,24 @@ export default async function EventsPage({
}: {
searchParams: Promise<{
cursor?: string;
back?: string;
agent_id?: string;
check_id?: string;
limit?: string;
page?: string;
}>;
}) {
const sp = await searchParams;
const limit = normalizePageSize(sp.limit);
const page = normalizePageNumber(sp.page);
const back = normalizeBack(sp.back);
let data;
try {
data = await loadEvents({
agentId: sp.agent_id,
checkId: sp.check_id,
cursor: sp.cursor,
back,
limit,
});
} catch (e) {
@@ -30,12 +35,14 @@ export default async function EventsPage({
}
return (
<EventsBrowser
key={`${sp.cursor ?? ""}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}:${limit}`}
key={`${sp.cursor ?? ""}:${back ? "b" : "f"}:${page}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}:${limit}`}
initialData={data}
cursor={sp.cursor}
back={back}
agentId={sp.agent_id}
checkId={sp.check_id}
limit={limit}
page={page}
/>
);
}

View File

@@ -1,29 +1,55 @@
import { ErrorPanel } from "@/components/DataPanel";
import { IncidentsBrowser } from "@/components/IncidentsBrowser";
import { loadIncidents, loadInventory } from "@/lib/loaders";
import { loadIncidentsPage, loadInventory } from "@/lib/loaders";
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
export const dynamic = "force-dynamic";
export default async function IncidentsPage({
searchParams,
}: {
searchParams: Promise<{ state?: string }>;
searchParams: Promise<{
state?: string;
cursor?: string;
back?: string;
limit?: string;
sort?: string;
direction?: string;
page?: string;
}>;
}) {
const sp = await searchParams;
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
let items;
const sort =
sp.sort === "opened_at" || sp.sort === "resolved_at" || sp.sort === "status"
? sp.sort
: "status";
const direction = sp.direction === "asc" || sp.direction === "desc" ? sp.direction : "desc";
const limit = normalizePageSize(sp.limit);
const pageNumber = normalizePageNumber(sp.page);
const back = normalizeBack(sp.back);
let page;
let inventory;
try {
[items, inventory] = await Promise.all([loadIncidents(state), loadInventory()]);
[page, inventory] = await Promise.all([
loadIncidentsPage({ state, cursor: sp.cursor, back, limit, sort, direction }),
loadInventory(),
]);
} catch (e) {
return <ErrorPanel error={e} />;
}
return (
<IncidentsBrowser
key={state ?? ""}
initialItems={items}
key={`${sp.cursor ?? ""}:${back ? "b" : "f"}:${pageNumber}:${state ?? ""}:${sort}:${direction}:${limit}`}
initialData={page}
initialInventory={inventory}
state={state}
cursor={sp.cursor}
back={back}
sort={sort}
direction={direction}
limit={limit}
page={pageNumber}
/>
);
}

View File

@@ -1,7 +1,10 @@
import type { Metadata } from "next";
import Link from "next/link";
import { TimeZoneCarousel, TimeZoneProvider } from "@/components/TimeZoneControls";
import type { ReactNode } from "react";
import { TimeZoneProvider } from "@/components/TimeZoneControls";
import { TopNav } from "@/components/TopNav";
import { loadSystemSummary } from "@/lib/loaders";
import { getUiTimeZone } from "@/lib/timezone";
import type { SystemSummaryData } from "@/lib/view-types";
import "./globals.css";
export const metadata: Metadata = {
@@ -9,50 +12,25 @@ export const metadata: Metadata = {
description: "Read-only monitoring dashboard",
};
const NAV = [
{ href: "/", label: "Overview" },
{ href: "/agents", label: "Agents" },
{ href: "/checks", label: "Checks" },
{ href: "/incidents", label: "Incidents" },
{ href: "/events", label: "Events" },
{ href: "/outbox", label: "Outbox" },
];
export const dynamic = "force-dynamic";
function Brand() {
return (
<Link href="/" aria-label="Monlet overview" className="group flex shrink-0 items-center gap-2">
<span className="relative h-8 w-8 overflow-hidden rounded-md border border-emerald-400/40 bg-neutral-900 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]">
<span className="absolute left-2 top-2 h-1.5 w-1.5 rounded-full bg-emerald-300 shadow-[0_0_12px_rgba(110,231,183,0.75)]" />
<span className="absolute bottom-2 left-2 h-2 w-0.5 rounded-full bg-cyan-300/80" />
<span className="absolute bottom-2 left-3.5 h-4 w-0.5 rounded-full bg-emerald-300" />
<span className="absolute bottom-2 left-5 h-2.5 w-0.5 rounded-full bg-sky-300/80" />
<span className="absolute right-1.5 top-1.5 h-1 w-1 rounded-full bg-neutral-500 group-hover:bg-emerald-200" />
</span>
<span className="text-xl font-semibold tracking-wide text-white">
mon<span className="text-emerald-300">let</span>
</span>
</Link>
);
async function safeLoadSystemSummary(): Promise<SystemSummaryData | null> {
try {
return await loadSystemSummary();
} catch {
return null;
}
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
export default async function RootLayout({ children }: { children: ReactNode }) {
const configuredTimeZone = getUiTimeZone();
const summary = await safeLoadSystemSummary();
return (
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col bg-neutral-950 text-neutral-100 font-mono">
<TimeZoneProvider configuredTimeZone={configuredTimeZone}>
<header className="border-b border-neutral-800 px-4 py-3 flex flex-wrap items-center gap-4">
<Brand />
<nav className="flex flex-wrap gap-4 text-sm text-neutral-300">
{NAV.map((n) => (
<Link key={n.href} href={n.href} className="hover:text-white">
{n.label}
</Link>
))}
</nav>
<TimeZoneCarousel />
</header>
<TopNav initialSummary={summary} />
<main className="flex-1 px-4 py-6">{children}</main>
</TimeZoneProvider>
</body>

View File

@@ -1,15 +1,44 @@
import { ErrorPanel } from "@/components/DataPanel";
import { OutboxBrowser } from "@/components/OutboxBrowser";
import { loadOutboxAll } from "@/lib/loaders";
import { loadOutboxPage } from "@/lib/loaders";
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
export const dynamic = "force-dynamic";
export default async function OutboxPage() {
let items;
export default async function OutboxPage({
searchParams,
}: {
searchParams: Promise<{
cursor?: string;
back?: string;
limit?: string;
sort?: string;
direction?: string;
page?: string;
}>;
}) {
const sp = await searchParams;
const limit = normalizePageSize(sp.limit);
const sort = sp.sort === "created_at" || sp.sort === "updated_at" ? sp.sort : "updated_at";
const direction = sp.direction === "asc" || sp.direction === "desc" ? sp.direction : "desc";
const pageNumber = normalizePageNumber(sp.page);
const back = normalizeBack(sp.back);
let page;
try {
items = await loadOutboxAll();
page = await loadOutboxPage({ cursor: sp.cursor, back, limit, sort, direction });
} catch (e) {
return <ErrorPanel error={e} />;
}
return <OutboxBrowser initialItems={items} />;
return (
<OutboxBrowser
key={`${sp.cursor ?? ""}:${back ? "b" : "f"}:${pageNumber}:${sort}:${direction}:${limit}`}
initialData={page}
cursor={sp.cursor}
back={back}
sort={sort}
direction={direction}
limit={limit}
page={pageNumber}
/>
);
}

View File

@@ -1,15 +1,7 @@
import { ErrorPanel } from "@/components/DataPanel";
import { OverviewDashboard } from "@/components/OverviewDashboard";
import { loadOverview } from "@/lib/loaders";
import { redirect } from "next/navigation";
export const dynamic = "force-dynamic";
export default async function OverviewPage() {
let data;
try {
data = await loadOverview();
} catch (e) {
return <ErrorPanel error={e} />;
}
return <OverviewDashboard initialData={data} />;
export default function HomePage() {
redirect("/agents");
}

View File

@@ -0,0 +1,104 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { DateTime } from "@/components/TimeZoneControls";
import type { AgentBlacklistItem } from "@/lib/api";
export function AgentBlacklistBrowser({ initialRows }: { initialRows: AgentBlacklistItem[] }) {
const [rows, setRows] = useState(initialRows);
const [busy, setBusy] = useState(false);
const runAction = async (action: string, blacklistId?: string, confirmText?: string) => {
if (confirmText && !window.confirm(confirmText)) return;
setBusy(true);
try {
const res = await fetch("/api/agents/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, blacklist_id: blacklistId }),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error?.message ?? res.statusText);
}
if (action === "blacklist_clear") {
setRows([]);
} else if (blacklistId) {
setRows((current) => current.filter((r) => r.id !== blacklistId));
}
} catch (e) {
window.alert(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
return (
<>
<Link href="/agents" className="mb-4 inline-block text-sm text-blue-300 hover:text-blue-200">
agents
</Link>
<div className="mb-3 flex items-center justify-between gap-3">
<PageHeader title="Blacklist" count={rows.length} />
{rows.length > 0 ? (
<button
type="button"
disabled={busy}
onClick={() => runAction("blacklist_clear", undefined, "Clear all blocked agent keys?")}
className="rounded border border-red-900 px-3 py-1.5 text-sm text-red-300 hover:border-red-500 disabled:opacity-40"
>
clear all
</button>
) : null}
</div>
{rows.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>fingerprint</Th>
<Th>host</Th>
<Th>agent_id</Th>
<Th>last_seen_at</Th>
<Th>actions</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<Td>{row.key_fingerprint}</Td>
<Td>{row.hostname}</Td>
<Td>{row.agent_id}</Td>
<Td>
<DateTime iso={row.last_seen_at} />
</Td>
<Td>
<button
type="button"
title="Remove from blacklist"
aria-label="Remove from blacklist"
disabled={busy}
onClick={() =>
runAction(
"blacklist_delete",
row.id,
`Remove ${row.hostname} from blacklist?`,
)
}
className="rounded border border-neutral-800 px-2 py-1 hover:border-neutral-500 disabled:opacity-40"
>
🗑
</button>
</Td>
</tr>
))}
</tbody>
</table>
)}
</>
);
}

View File

@@ -27,7 +27,7 @@ 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 EventsPageData = { items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null };
type SortKey = "severity" | "check_id" | "last_seen";
type TabKey = "checks" | "events";
type AgentDetailData = { agent: Agent; checks: CheckState[] };
@@ -47,6 +47,8 @@ export function AgentDetailBrowser({
tab,
eventsLimit,
eventsCursor,
eventsBack,
eventsPage,
}: {
initialData: AgentDetailData;
initialEvents: EventsPageData;
@@ -54,6 +56,8 @@ export function AgentDetailBrowser({
tab: TabKey;
eventsLimit: PageSize;
eventsCursor?: string;
eventsBack: boolean;
eventsPage: number;
}) {
const agentId = initialData.agent.agent_id;
const pollUrl = `/api/poll/agent?agent_id=${encodeURIComponent(agentId)}`;
@@ -110,6 +114,8 @@ export function AgentDetailBrowser({
sort={sort}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
eventsBack={eventsBack}
eventsPage={eventsPage}
initialEvents={initialEvents}
/>
)}
@@ -188,20 +194,25 @@ function EventsTable({
sort,
eventsLimit,
eventsCursor,
eventsBack,
eventsPage,
initialEvents,
}: {
agentId: string;
sort: SortKey;
eventsLimit: PageSize;
eventsCursor?: string;
eventsBack: boolean;
eventsPage: number;
initialEvents: EventsPageData;
}) {
const router = useRouter();
const pollUrl = useMemo(() => {
const qs = new URLSearchParams({ agent_id: agentId, limit: String(eventsLimit) });
if (eventsCursor) qs.set("cursor", eventsCursor);
if (eventsBack) qs.set("back", "1");
return `/api/poll/events?${qs.toString()}`;
}, [agentId, eventsCursor, eventsLimit]);
}, [agentId, eventsCursor, eventsBack, eventsLimit]);
const { data } = usePolling(initialEvents, pollUrl);
const onLimitChange = (next: PageSize) => {
@@ -224,6 +235,7 @@ function EventsTable({
<Th>check_id</Th>
<Th>status</Th>
<Th>duration_ms</Th>
<Th>SUMMARY</Th>
</tr>
</thead>
<tbody>
@@ -237,6 +249,7 @@ function EventsTable({
</Td>
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
<Td>{e.duration_ms}</Td>
<SummaryCell text={e.output} />
</tr>
))}
</tbody>
@@ -244,8 +257,9 @@ function EventsTable({
)}
<Pager
basePath={`/agents/${encodeURIComponent(agentId)}`}
cursor={eventsCursor}
prevCursor={data.prev_cursor}
nextCursor={data.next_cursor}
page={eventsPage}
extra={{ tab: "events", sort, events_limit: String(eventsLimit) }}
/>
</section>

View File

@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { AgentFeatures } from "@/components/AgentFeatures";
@@ -35,16 +36,25 @@ const statusRank: Record<Agent["status"], number> = { alive: 0, stale: 1, dead:
const checkSortStatuses: CheckStatus[] = ["critical", "warning", "unknown", "ok"];
export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
const { data } = usePolling(initialData, "/api/poll/inventory");
const [reloadKey, setReloadKey] = useState(0);
const { data } = usePolling(initialData, `/api/poll/inventory?v=${reloadKey}`);
const [query, setQuery] = useState("");
const [sort, setSort] = useState<SortField>("status");
const [dir, setDir] = useState<SortDir>("desc");
const [busy, setBusy] = useState<string | null>(null);
const initialRows = useMemo(() => rowsFromInventory(data), [data]);
const rows = useMemo(
const filteredRows = useMemo(
() => sortAgents(filterAgents(initialRows, query), sort, dir),
[dir, initialRows, query, sort],
);
const pendingRows = filteredRows.filter(({ agent }) => agent.admission_state === "pending");
const acceptedRows = filteredRows.filter(({ agent }) => agent.admission_state === "accepted");
const rows = [...pendingRows, ...acceptedRows];
const allPendingRows = initialRows.filter(({ agent }) => agent.admission_state === "pending");
const allRotationPendingCount = allPendingRows.filter(
({ agent }) => agent.rotation_pending,
).length;
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
@@ -69,14 +79,45 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
}
setPage(1);
};
const runAction = async (
action: string,
agentId?: string,
confirmText?: string,
includeRotation = false,
) => {
if (confirmText && !window.confirm(confirmText)) return;
const key = `${action}:${agentId ?? "*"}`;
setBusy(key);
try {
const res = await fetch("/api/agents/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action, agent_id: agentId, include_rotation: includeRotation }),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.error?.message ?? res.statusText);
}
setReloadKey((v) => v + 1);
} catch (e) {
window.alert(e instanceof Error ? e.message : String(e));
} finally {
setBusy(null);
}
};
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} of {initialRows.length} items
</span>
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-3">
<div className="flex items-baseline gap-3">
<h1 className="text-lg font-semibold">Agents</h1>
<span className="text-sm text-neutral-400">
{rows.length} of {initialRows.length} items
</span>
</div>
<Link href="/agents/blacklist" className="text-sm text-blue-300 hover:text-blue-200">
Blacklist
</Link>
</div>
<ListToolbar
query={query}
@@ -88,56 +129,215 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
{rows.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
HOST
</SortHeaderButton>
</Th>
<Th>
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
STATUS
</SortHeaderButton>
</Th>
<Th>
<SortHeaderButton field="last_seen_at" current={sort} dir={dir} onClick={chooseSort}>
LAST_SEEN_AT
</SortHeaderButton>
</Th>
<Th>features</Th>
<Th>labels</Th>
</tr>
</thead>
<tbody>
{visible.map(({ agent: a, checks }) => (
<tr key={a.agent_id}>
<Td>
<AgentLink agent={a} agentId={a.agent_id} />
</Td>
<Td>
<AgentStatus status={a.status} checks={checks} />
</Td>
<Td>
<DateTime iso={a.last_seen_at} />
</Td>
<Td>
<AgentFeatures features={a.features} />
</Td>
<Td>
<LabelChips labels={a.labels} limit={4} />
</Td>
</tr>
))}
</tbody>
</table>
<AgentsTable
rows={visible}
sort={sort}
dir={dir}
chooseSort={chooseSort}
busy={busy}
runAction={runAction}
pendingCount={allPendingRows.length}
rotationPendingCount={allRotationPendingCount}
/>
)}
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
</>
);
}
function AgentsTable({
rows,
sort,
dir,
chooseSort,
busy,
runAction,
pendingCount,
rotationPendingCount,
}: {
rows: AgentRow[];
sort: SortField;
dir: SortDir;
chooseSort: (field: SortField) => void;
busy: string | null;
runAction: (
action: string,
agentId?: string,
confirmText?: string,
includeRotation?: boolean,
) => void;
pendingCount: number;
rotationPendingCount: number;
}) {
const acceptAllText =
rotationPendingCount > 0
? `ROTATION: existing accepted keys will be replaced for ${rotationPendingCount} agents. Accept all pending agents including rotations?`
: undefined;
return (
<table className="w-full text-sm">
<thead>
<tr>
<Th>
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
HOST
</SortHeaderButton>
</Th>
<Th>
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
STATUS
</SortHeaderButton>
</Th>
<Th>
<SortHeaderButton field="last_seen_at" current={sort} dir={dir} onClick={chooseSort}>
LAST_SEEN_AT
</SortHeaderButton>
</Th>
<Th>features</Th>
<Th>labels</Th>
<Th>actions</Th>
</tr>
</thead>
<tbody>
{pendingCount > 0 ? (
<tr>
<td colSpan={6} className="border-b border-neutral-900 bg-amber-950/20 px-2 py-2 text-amber-300">
<div className="flex flex-wrap items-center gap-2">
<span>{pendingCount} pending</span>
<ActionButton
label="✅"
title="Accept all"
disabled={busy !== null}
onClick={() =>
runAction("accept_all", undefined, acceptAllText, rotationPendingCount > 0)
}
/>
<ActionButton
label="⛔"
title="Block all"
disabled={busy !== null}
onClick={() => runAction("block_all", undefined, "Block all pending agents?")}
/>
<ActionButton
label="🗑"
title="Remove all"
disabled={busy !== null}
onClick={() => runAction("remove_all", undefined, "Remove all pending agents and their data?")}
/>
</div>
</td>
</tr>
) : null}
{rows.map(({ agent: a, checks }) => (
<tr key={a.agent_id} className={a.admission_state === "pending" ? "bg-amber-950/10" : ""}>
<Td>
<div className="flex items-center gap-2">
<AgentLink agent={a} agentId={a.agent_id} />
{a.admission_state === "pending" ? (
<span className="rounded border border-amber-800 px-1.5 py-0.5 text-xs text-amber-300">
{a.rotation_pending ? "ROTATION" : "pending"}
</span>
) : null}
</div>
</Td>
<Td>
<AgentStatus status={a.status} checks={checks} />
</Td>
<Td>
<DateTime iso={a.last_seen_at} />
</Td>
<Td>
<AgentFeatures features={a.features} />
</Td>
<Td>
<LabelChips labels={a.labels} limit={4} />
</Td>
<Td>
<AgentActions agent={a} busy={busy} runAction={runAction} />
</Td>
</tr>
))}
</tbody>
</table>
);
}
function AgentActions({
agent,
busy,
runAction,
}: {
agent: Agent;
busy: string | null;
runAction: (
action: string,
agentId?: string,
confirmText?: string,
includeRotation?: boolean,
) => void;
}) {
const disabled = busy !== null;
if (agent.admission_state === "pending") {
const acceptText = agent.rotation_pending
? `ROTATION: existing accepted key for ${agent.hostname} will be replaced. Accept?`
: undefined;
return (
<div className="flex gap-1">
<ActionButton
label="✅"
title={agent.rotation_pending ? "Accept key rotation" : "Accept"}
disabled={disabled}
onClick={() => runAction("accept", agent.agent_id, acceptText)}
/>
<ActionButton
label="⛔"
title="Block"
disabled={disabled}
onClick={() => runAction("block", agent.agent_id, `Block ${agent.hostname}?`)}
/>
<ActionButton
label="🗑"
title="Remove"
disabled={disabled}
onClick={() => runAction("remove", agent.agent_id, `Remove ${agent.hostname} and all its data?`)}
/>
</div>
);
}
return (
<ActionButton
label="🗑"
title="Remove"
disabled={disabled}
onClick={() => runAction("remove", agent.agent_id, `Remove ${agent.hostname} and all its data?`)}
/>
);
}
function ActionButton({
label,
title,
disabled,
onClick,
}: {
label: string;
title: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
disabled={disabled}
onClick={onClick}
className="rounded border border-neutral-800 bg-neutral-950 px-2 py-1 text-sm hover:border-neutral-500 disabled:opacity-40"
>
{label}
</button>
);
}
function filterAgents(rows: AgentRow[], query: string): AgentRow[] {
const needle = query.trim().toLowerCase();
if (!needle) return rows;

View File

@@ -26,7 +26,7 @@ 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 };
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null };
type TabKey = "agents" | "events";
export function CheckDetailBrowser({
@@ -36,6 +36,8 @@ export function CheckDetailBrowser({
tab,
eventsLimit,
eventsCursor,
eventsBack,
eventsPage,
}: {
checkId: string;
initialData: InventoryData;
@@ -43,6 +45,8 @@ export function CheckDetailBrowser({
tab: TabKey;
eventsLimit: PageSize;
eventsCursor?: string;
eventsBack: boolean;
eventsPage: number;
}) {
const { data } = usePolling(initialData, "/api/poll/inventory");
const agentsById = useMemo(
@@ -110,6 +114,8 @@ export function CheckDetailBrowser({
agentsById={agentsById}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
eventsBack={eventsBack}
eventsPage={eventsPage}
/>
)}
</div>
@@ -177,12 +183,16 @@ function EventsForCheck({
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(() => {
@@ -191,8 +201,9 @@ function EventsForCheck({
limit: String(eventsLimit),
});
if (eventsCursor) qs.set("cursor", eventsCursor);
if (eventsBack) qs.set("back", "1");
return `/api/poll/events?${qs.toString()}`;
}, [checkId, eventsCursor, eventsLimit]);
}, [checkId, eventsCursor, eventsBack, eventsLimit]);
const { data } = usePolling(initialData, pollUrl);
const onLimitChange = (next: PageSize) => {
@@ -245,8 +256,9 @@ function EventsForCheck({
)}
<Pager
basePath={`/checks/${encodeURIComponent(checkId)}`}
cursor={eventsCursor}
prevCursor={data.prev_cursor}
nextCursor={data.next_cursor}
page={eventsPage}
extra={{ tab: "events", events_limit: String(eventsLimit) }}
/>
</section>

View File

@@ -15,25 +15,29 @@ import { type PageSize } from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null };
export function EventsBrowser({
initialData,
cursor,
back,
agentId,
checkId,
limit,
page,
}: {
initialData: EventsPageData;
cursor?: string;
back: boolean;
agentId?: string;
checkId?: string;
limit: PageSize;
page: number;
}) {
const router = useRouter();
const url = useMemo(
() => eventsPollUrl({ cursor, agentId, checkId, limit }),
[agentId, checkId, cursor, limit],
() => eventsPollUrl({ cursor, back, agentId, checkId, limit }),
[agentId, back, checkId, cursor, limit],
);
const { data } = usePolling(initialData, url);
@@ -86,8 +90,9 @@ export function EventsBrowser({
)}
<Pager
basePath="/events"
cursor={cursor}
prevCursor={data.prev_cursor}
nextCursor={data.next_cursor}
page={page}
extra={{ agent_id: agentId, check_id: checkId, limit: String(limit) }}
/>
</>
@@ -160,17 +165,20 @@ function eventsHref(params: {
function eventsPollUrl({
cursor,
back,
agentId,
checkId,
limit,
}: {
cursor?: string;
back: boolean;
agentId?: string;
checkId?: string;
limit: PageSize;
}) {
const qs = new URLSearchParams();
if (cursor) qs.set("cursor", cursor);
if (back) qs.set("back", "1");
if (agentId) qs.set("agent_id", agentId);
if (checkId) qs.set("check_id", checkId);
qs.set("limit", String(limit));

View File

@@ -1,24 +1,19 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { AgentLink } from "@/components/AgentLink";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { Pager } from "@/components/Pager";
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 { type PageSize } from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types";
@@ -26,70 +21,75 @@ const STATES = ["all", "open", "resolved"] as const;
type Incident = components["schemas"]["Incident"];
type Agent = components["schemas"]["Agent"];
type IncidentsData = { items: Incident[] };
type SortField = "status" | "host" | "check" | "opened_at" | "resolved_at";
const stateRank: Record<Incident["state"], number> = { open: 0, resolved: 1 };
const severityRank: Record<Incident["severity"], number> = {
critical: 0,
warning: 1,
unknown: 2,
};
type IncidentsData = { items: Incident[]; next_cursor?: string | null; prev_cursor?: string | null };
type SortField = "status" | "opened_at" | "resolved_at";
export function IncidentsBrowser({
initialItems,
initialData,
initialInventory,
state,
cursor,
back,
sort,
direction,
limit,
page,
}: {
initialItems: Incident[];
initialData: IncidentsData;
initialInventory: InventoryData;
state?: "open" | "resolved";
cursor?: string;
back: boolean;
sort: SortField;
direction: SortDir;
limit: PageSize;
page: number;
}) {
const url = useMemo(
() => (state ? `/api/poll/incidents?state=${state}` : "/api/poll/incidents"),
[state],
);
const { data } = usePolling<IncidentsData>({ items: initialItems }, url);
const router = useRouter();
const url = useMemo(() => {
const q = new URLSearchParams();
if (state) q.set("state", state);
q.set("sort", sort);
q.set("direction", direction);
q.set("limit", String(limit));
if (cursor) q.set("cursor", cursor);
if (back) q.set("back", "1");
return `/api/poll/incidents?${q.toString()}`;
}, [cursor, back, direction, limit, state, sort]);
const { data } = usePolling<IncidentsData>(initialData, url);
const { data: inventory } = usePolling(initialInventory, "/api/poll/inventory");
const agentsById = useMemo(
() => new Map(inventory.agents.map((agent) => [agent.agent_id, agent])),
[inventory.agents],
);
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);
router.push(incidentsHref({ state, sort, direction, limit: next }));
};
const chooseSort = (field: SortField) => {
let nextSort = sort;
let nextDirection = direction;
if (field === sort) {
setDir((current) => (current === "asc" ? "desc" : "asc"));
nextDirection = direction === "asc" ? "desc" : "asc";
} else {
setSort(field);
setDir(fieldDefaultDir(field));
nextSort = field;
nextDirection = fieldDefaultDir();
}
setPage(1);
router.push(incidentsHref({ state, sort: nextSort, direction: nextDirection, limit }));
};
const pageCount = pageCountOf(sorted.length, pageSize);
const currentPage = clampPage(page, pageCount);
const visible = paginate(sorted, currentPage, pageSize);
return (
<>
<PageHeader title="Incidents" count={sorted.length} />
<PageHeader title="Incidents" count={data.items.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 href = incidentsHref({
state: s === "all" ? undefined : s,
sort: sort === "resolved_at" && s !== "resolved" ? "status" : sort,
direction,
limit,
});
const active = (s === "all" && !state) || s === state;
return (
<Link
@@ -102,44 +102,55 @@ export function IncidentsBrowser({
);
})}
</div>
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
<PageSizeSelect value={limit} onChange={onPageSizeChange} />
</div>
{sorted.length === 0 ? (
{data.items.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
<thead>
<tr>
<Th>
<SortHeaderButton field="status" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton field="status" current={sort} dir={direction} onClick={chooseSort}>
STATUS
</SortHeaderButton>
</Th>
<Th>HOST</Th>
<Th>CHECK</Th>
<Th>
<SortHeaderButton field="host" current={sort} dir={dir} onClick={chooseSort}>
HOST
</SortHeaderButton>
</Th>
<Th>
<SortHeaderButton field="check" current={sort} dir={dir} onClick={chooseSort}>
CHECK
</SortHeaderButton>
</Th>
<Th>
<SortHeaderButton field="opened_at" current={sort} dir={dir} onClick={chooseSort}>
<SortHeaderButton
field="opened_at"
current={sort}
dir={direction}
onClick={chooseSort}
>
OPENED_AT
</SortHeaderButton>
</Th>
<Th>
<SortHeaderButton field="resolved_at" current={sort} dir={dir} onClick={chooseSort}>
RESOLVED_AT
</SortHeaderButton>
{state === "resolved" ? (
<SortHeaderButton
field="resolved_at"
current={sort}
dir={direction}
onClick={chooseSort}
>
RESOLVED_AT
</SortHeaderButton>
) : (
// PH-review: backend rejects sort=resolved_at when state is
// not "resolved" (server/monlet_server/api/incidents.py
// auto-applies state=resolved when none is set, which would
// silently filter the "all" tab). Sort is available only on
// the resolved tab.
"RESOLVED_AT"
)}
</Th>
<Th>SUMMARY</Th>
</tr>
</thead>
<tbody>
{visible.map((incident) => (
{data.items.map((incident) => (
<IncidentRow
key={incident.id}
incident={incident}
@@ -149,7 +160,13 @@ export function IncidentsBrowser({
</tbody>
</table>
)}
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
<Pager
basePath="/incidents"
prevCursor={data.prev_cursor}
nextCursor={data.next_cursor}
page={page}
extra={{ state, sort, direction, limit: String(limit) }}
/>
</>
);
}
@@ -203,56 +220,26 @@ function incidentCheckId(incident: Incident): string {
return incident.check_id ?? "";
}
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 fieldDefaultDir(): SortDir {
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;
function incidentsHref({
state,
sort,
direction,
limit,
}: {
state?: "open" | "resolved";
sort: SortField;
direction: SortDir;
limit: PageSize;
}) {
const qs = new URLSearchParams();
if (state) qs.set("state", state);
qs.set("sort", sort);
qs.set("direction", direction);
qs.set("limit", String(limit));
const query = qs.toString();
return query ? `/incidents?${query}` : "/incidents";
}

View File

@@ -1,44 +1,64 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { Pager } from "@/components/Pager";
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 { type PageSize } from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
type OutboxItem = components["schemas"]["NotificationOutboxItem"];
type OutboxData = { items: OutboxItem[] };
type OutboxData = { items: OutboxItem[]; next_cursor?: string | null; prev_cursor?: string | null };
type SortField = "created_at" | "updated_at";
export function OutboxBrowser({ initialItems }: { initialItems: OutboxItem[] }) {
const { data } = usePolling<OutboxData>({ items: initialItems }, "/api/poll/outbox");
export function OutboxBrowser({
initialData,
cursor,
back,
sort,
direction,
limit,
page,
}: {
initialData: OutboxData;
cursor?: string;
back: boolean;
sort: SortField;
direction: SortDir;
limit: PageSize;
page: number;
}) {
const router = useRouter();
const url = useMemo(() => {
const q = new URLSearchParams();
q.set("sort", sort);
q.set("direction", direction);
q.set("limit", String(limit));
if (cursor) q.set("cursor", cursor);
if (back) q.set("back", "1");
return `/api/poll/outbox?${q.toString()}`;
}, [cursor, back, direction, limit, sort]);
const { data } = usePolling<OutboxData>(initialData, url);
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
router.push(outboxHref({ sort, direction, limit: next }));
};
const chooseSort = (field: SortField) => {
const nextDirection = field === sort && direction === "desc" ? "asc" : "desc";
router.push(outboxHref({ sort: field, direction: nextDirection, limit }));
};
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} />
<PageSizeSelect value={limit} onChange={onPageSizeChange} />
</div>
{data.items.length === 0 ? (
<EmptyPanel />
@@ -46,7 +66,11 @@ export function OutboxBrowser({ initialItems }: { initialItems: OutboxItem[] })
<table className="w-full text-sm">
<thead>
<tr>
<Th>updated_at</Th>
<Th>
<SortHeaderButton field="updated_at" current={sort} dir={direction} onClick={chooseSort}>
updated_at
</SortHeaderButton>
</Th>
<Th>notifier</Th>
<Th>event</Th>
<Th>state</Th>
@@ -56,7 +80,7 @@ export function OutboxBrowser({ initialItems }: { initialItems: OutboxItem[] })
</tr>
</thead>
<tbody>
{visible.map((o) => (
{data.items.map((o) => (
<tr key={o.id}>
<Td>
<DateTime iso={o.updated_at} />
@@ -74,7 +98,29 @@ export function OutboxBrowser({ initialItems }: { initialItems: OutboxItem[] })
</tbody>
</table>
)}
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
<Pager
basePath="/outbox"
prevCursor={data.prev_cursor}
nextCursor={data.next_cursor}
page={page}
extra={{ sort, direction, limit: String(limit) }}
/>
</>
);
}
function outboxHref({
sort,
direction,
limit,
}: {
sort: SortField;
direction: SortDir;
limit: PageSize;
}) {
const qs = new URLSearchParams();
qs.set("sort", sort);
qs.set("direction", direction);
qs.set("limit", String(limit));
return `/outbox?${qs.toString()}`;
}

View File

@@ -1,56 +0,0 @@
"use client";
import Link from "next/link";
import { statusBadgeClass } from "@/lib/format";
import { usePolling } from "@/lib/usePolling";
import type { OverviewData } from "@/lib/view-types";
export function OverviewDashboard({ initialData }: { initialData: OverviewData }) {
const { data } = usePolling(initialData, "/api/poll/overview");
return (
<div className="space-y-6">
<h1 className="text-lg font-semibold">Overview</h1>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<Card title="Agents" total={data.totalAgents} href="/agents" tally={data.agents} />
<Card title="Checks" total={data.totalChecks} href="/checks" tally={data.checks} />
<Card
title="Open incidents"
total={data.openIncidents}
href="/incidents"
tally={{}}
highlight={data.openIncidents > 0 ? "open" : undefined}
/>
</div>
</div>
);
}
function Card({
title,
total,
href,
tally,
highlight,
}: {
title: string;
total: number;
href: string;
tally: Record<string, number>;
highlight?: string;
}) {
return (
<Link href={href} className="block border border-neutral-800 p-4 hover:border-neutral-600">
<div className="text-xs uppercase tracking-wide text-neutral-400">{title}</div>
<div className={`mt-1 text-3xl ${highlight ? statusBadgeClass(highlight) : ""}`}>{total}</div>
<div className="mt-2 flex flex-wrap gap-3 text-xs">
{Object.entries(tally).map(([k, v]) => (
<span key={k} className={statusBadgeClass(k)}>
{k} {v}
</span>
))}
</div>
</Link>
);
}

View File

@@ -2,27 +2,55 @@ import Link from "next/link";
export function Pager({
basePath,
cursor,
prevCursor,
nextCursor,
page = 1,
extra = {},
}: {
basePath: string;
cursor?: string;
prevCursor?: string | null;
nextCursor?: string | null;
page?: number;
extra?: Record<string, string | undefined>;
}) {
const make = (c?: string) => {
const make = ({
nextPage,
nextPageCursor,
back,
}: {
nextPage: number;
nextPageCursor?: string;
back?: boolean;
}) => {
const sp = new URLSearchParams();
for (const [k, v] of Object.entries(extra)) if (v) sp.set(k, v);
if (c) sp.set("cursor", c);
if (nextPageCursor) sp.set("cursor", nextPageCursor);
if (back) sp.set("back", "1");
if (nextPage > 1) sp.set("page", String(nextPage));
const qs = sp.toString();
return qs ? `${basePath}?${qs}` : basePath;
};
const previousHref =
page > 1 && prevCursor
? make({ nextPage: page - 1, nextPageCursor: prevCursor, back: true })
: null;
const nextHref = nextCursor
? make({ nextPage: page + 1, nextPageCursor: nextCursor })
: null;
return (
<div className="mt-4 flex justify-between text-sm">
<span className="text-neutral-500">{cursor ? `cursor: ${cursor.slice(0, 12)}` : ""}</span>
{nextCursor ? (
<Link href={make(nextCursor)} className="text-blue-300 hover:text-blue-200">
{previousHref ? (
<Link href={previousHref} className="text-blue-300 hover:text-blue-200">
prev
</Link>
) : (
<span className="text-neutral-600"> prev</span>
)}
<span className="text-neutral-500">page {page}</span>
{nextHref ? (
<Link href={nextHref} className="text-blue-300 hover:text-blue-200">
next
</Link>
) : (

View File

@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import type { ReactNode } from "react";
import { TimeZoneCarousel } from "@/components/TimeZoneControls";
import { usePolling } from "@/lib/usePolling";
import type { SystemSummaryData } from "@/lib/view-types";
const ZERO_SUMMARY: SystemSummaryData = {
agents_online: 0,
agents_offline: 0,
checks_ok: 0,
checks_warning: 0,
checks_critical: 0,
checks_unknown: 0,
open_incidents: 0,
generated_at: "",
};
export function TopNav({ initialSummary }: { initialSummary: SystemSummaryData | null }) {
const { data } = usePolling(initialSummary ?? ZERO_SUMMARY, "/api/poll/system-summary");
return (
<header className="border-b border-neutral-800 px-4 py-3 flex flex-wrap items-center gap-4">
<Brand />
<nav className="flex flex-wrap gap-4 text-sm text-neutral-300">
<NavLink href="/agents" label="Agents">
<span className="text-emerald-400">{data.agents_online}</span>
<span className="text-neutral-600">/</span>
<span className="text-red-400">{data.agents_offline}</span>
</NavLink>
<NavLink href="/checks" label="Checks">
<span className="text-emerald-400">{data.checks_ok}</span>
<span className="text-neutral-600">/</span>
<span className="text-amber-400">{data.checks_warning}</span>
<span className="text-neutral-600">/</span>
<span className="text-red-400">{data.checks_critical}</span>
<span className="text-neutral-600">/</span>
<span className="text-neutral-400">{data.checks_unknown}</span>
</NavLink>
<NavLink href="/incidents" label="Incidents">
<span className="text-red-400">{data.open_incidents}</span>
</NavLink>
<Link href="/events" className="hover:text-white">
Events
</Link>
<Link href="/outbox" className="hover:text-white">
Outbox
</Link>
</nav>
<TimeZoneCarousel />
</header>
);
}
function Brand() {
return (
<Link href="/agents" aria-label="Monlet agents" className="group flex shrink-0 items-center gap-2">
<span className="relative h-8 w-8 overflow-hidden rounded-md border border-emerald-400/40 bg-neutral-900 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]">
<span className="absolute left-2 top-2 h-1.5 w-1.5 rounded-full bg-emerald-300 shadow-[0_0_12px_rgba(110,231,183,0.75)]" />
<span className="absolute bottom-2 left-2 h-2 w-0.5 rounded-full bg-cyan-300/80" />
<span className="absolute bottom-2 left-3.5 h-4 w-0.5 rounded-full bg-emerald-300" />
<span className="absolute bottom-2 left-5 h-2.5 w-0.5 rounded-full bg-sky-300/80" />
<span className="absolute right-1.5 top-1.5 h-1 w-1 rounded-full bg-neutral-500 group-hover:bg-emerald-200" />
</span>
<span className="text-xl font-semibold tracking-wide text-white">
mon<span className="text-emerald-300">let</span>
</span>
</Link>
);
}
function NavLink({
href,
label,
children,
}: {
href: string;
label: string;
children: ReactNode;
}) {
return (
<Link href={href} className="inline-flex items-baseline gap-1.5 hover:text-white">
<span>{label}</span>
<span className="inline-flex items-baseline gap-1 text-xs">
<span className="text-neutral-600">(</span>
{children}
<span className="text-neutral-600">)</span>
</span>
</Link>
);
}

View File

@@ -89,6 +89,92 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/agents/admission/blacklist": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List blocked agent keys */
get: operations["listAgentBlacklist"];
put?: never;
post?: never;
/** Clear blocked agent keys */
delete: operations["clearAgentBlacklist"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/agents/admission/blacklist/{key_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post?: never;
/** Remove one blocked agent key */
delete: operations["deleteAgentBlacklistItem"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/agents/admission/accept-all": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Accept all pending agents */
post: operations["acceptAllPendingAgents"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/agents/admission/block-all": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Block all pending agents */
post: operations["blockAllPendingAgents"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/agents/admission/remove-all": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Remove all pending agents */
post: operations["removeAllPendingAgents"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/agents/{agent_id}": {
parameters: {
query?: never;
@@ -100,6 +186,41 @@ export interface paths {
get: operations["getAgent"];
put?: never;
post?: never;
/** Remove agent and all owned state */
delete: operations["deleteAgent"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/agents/{agent_id}/accept": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Accept pending agent key */
post: operations["acceptAgent"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/agents/{agent_id}/block": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Block pending agent key */
post: operations["blockAgent"];
delete?: never;
options?: never;
head?: never;
@@ -147,7 +268,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Query stored events */
/** Query check status-change history */
get: operations["queryEvents"];
put?: never;
post?: never;
@@ -157,6 +278,27 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/system-summary": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* System navigation summary
* @description Bounded aggregate counts for the top navigation. Safe to poll from many
* open dashboards because response size is independent of row counts.
*/
get: operations["getSystemSummary"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/notifiers/outbox": {
parameters: {
query?: never;
@@ -185,7 +327,7 @@ export interface components {
ErrorResponse: {
error: {
/** @enum {string} */
code: "unauthorized" | "not_found" | "validation" | "payload_too_large" | "rate_limited" | "internal" | "not_ready";
code: "unauthorized" | "forbidden" | "conflict" | "agent_blocked" | "not_found" | "validation" | "payload_too_large" | "rate_limited" | "internal" | "not_ready";
message: string;
/** @description Server-generated request id, mirrored from X-Request-Id. */
request_id: string;
@@ -194,14 +336,21 @@ export interface components {
PageEnvelope: {
/** @description Pass back as `cursor` to fetch the next page. Null/absent if no more results. */
next_cursor?: string | null;
/** @description Pass back as `cursor` with `back=true` to fetch the previous page. Null on the first page. */
prev_cursor?: string | null;
};
AcceptedResponse: {
accepted: boolean;
};
EventBatchAcceptedResponse: {
/** @description Number of events stored on this request. */
/** @description Number of events that produced a new status-change row on this request. */
accepted: number;
/** @description Number of events whose `event_id` was already known. */
/**
* @description Number of events that were not stored as a change. Includes events whose
* `event_id` was already known, events whose `(status, exit_code)` matched
* the previously observed value for the same check, and events received late
* (older than the current `last_observed_at` of the check).
*/
deduplicated: number;
};
HeartbeatRequest: {
@@ -231,7 +380,7 @@ export interface components {
/**
* @description Event body used inside `EventBatchRequest.events`. The owning `agent_id`
* is supplied once at the batch envelope; per-event `agent_id` is intentionally
* absent so requests cannot mix or spoof other agents under a shared token.
* absent so one HTTP request cannot mix multiple agents.
* Server response endpoints (`GET /events/query`) include `agent_id` separately
* in their item schema.
*/
@@ -265,7 +414,12 @@ export interface components {
notifications_enabled: boolean;
incident_key?: string;
};
/** @description Stored event returned by `GET /events/query`. Includes the resolved `agent_id` and server-side `received_at`. */
/**
* @description Stored status-change event returned by `GET /events/query`. The server persists
* only events that change `(status, exit_code)` versus the previous observed value
* for the same `(agent_id, check_id)`. Repeated identical results are not stored.
* Includes the resolved `agent_id` and server-side `received_at`.
*/
StoredCheckResultEvent: components["schemas"]["CheckResultEvent"] & {
agent_id: string;
/** Format: date-time */
@@ -276,6 +430,14 @@ export interface components {
hostname: string;
/** @enum {string} */
status: "alive" | "stale" | "dead";
/** @enum {string} */
admission_state: "pending" | "accepted";
/**
* @description True when a pending key would replace an already accepted key for this agent.
* @default false
*/
rotation_pending: boolean;
key_fingerprint?: string | null;
/** Format: date-time */
last_seen_at: string;
features: components["schemas"]["AgentFeatures"];
@@ -283,6 +445,20 @@ export interface components {
[key: string]: string;
};
};
AgentBlacklistItem: {
/** @description Opaque blacklist row id. Use for delete operations; display `key_fingerprint` to humans. */
id: string;
key_fingerprint: string;
agent_id: string;
hostname: string;
/** Format: date-time */
created_at: string;
/** Format: date-time */
last_seen_at: string;
};
ActionCountResponse: {
count: number;
};
CheckState: {
agent_id: string;
check_id: string;
@@ -324,6 +500,18 @@ export interface components {
/** Format: date-time */
updated_at?: string;
};
/** @description Bounded aggregate counts for the web navigation. */
SystemSummaryResponse: {
agents_online: number;
agents_offline: number;
checks_ok: number;
checks_warning: number;
checks_critical: number;
checks_unknown: number;
open_incidents: number;
/** Format: date-time */
generated_at: string;
};
};
responses: {
/** @description Authentication failed. */
@@ -344,6 +532,15 @@ export interface components {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Request conflicts with current server state. */
Conflict: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Payload exceeds configured limit (1 MiB). */
PayloadTooLarge: {
headers: {
@@ -353,6 +550,15 @@ export interface components {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Request rejected by configured rate/volume limits. */
RateLimited: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
/** @description Request body or parameters failed validation. */
ValidationError: {
headers: {
@@ -374,8 +580,10 @@ export interface components {
};
parameters: {
AgentId: string;
/** @description Opaque pagination cursor returned in `next_cursor`. */
/** @description Opaque pagination cursor returned in `next_cursor` (or `prev_cursor` when `back=true`). */
Cursor: string;
/** @description When true, treat the `cursor` as a `prev_cursor` and return the previous page. */
Back: boolean;
Limit: number;
};
requestBodies: never;
@@ -457,7 +665,18 @@ export interface operations {
};
400: components["responses"]["ValidationError"];
401: components["responses"]["Unauthorized"];
/** @description Agent key is blocked. */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
409: components["responses"]["Conflict"];
413: components["responses"]["PayloadTooLarge"];
429: components["responses"]["RateLimited"];
500: components["responses"]["InternalError"];
};
};
@@ -485,6 +704,15 @@ export interface operations {
};
400: components["responses"]["ValidationError"];
401: components["responses"]["Unauthorized"];
/** @description Agent key is blocked. */
403: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ErrorResponse"];
};
};
413: components["responses"]["PayloadTooLarge"];
500: components["responses"]["InternalError"];
};
@@ -492,8 +720,10 @@ export interface operations {
listAgents: {
parameters: {
query?: {
/** @description Opaque pagination cursor returned in `next_cursor`. */
/** @description Opaque pagination cursor returned in `next_cursor` (or `prev_cursor` when `back=true`). */
cursor?: components["parameters"]["Cursor"];
/** @description When true, treat the `cursor` as a `prev_cursor` and return the previous page. */
back?: components["parameters"]["Back"];
limit?: components["parameters"]["Limit"];
};
header?: never;
@@ -516,6 +746,143 @@ export interface operations {
401: components["responses"]["Unauthorized"];
};
};
listAgentBlacklist: {
parameters: {
query?: {
/** @description Opaque pagination cursor returned in `next_cursor` (or `prev_cursor` when `back=true`). */
cursor?: components["parameters"]["Cursor"];
limit?: components["parameters"]["Limit"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Agent blacklist. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PageEnvelope"] & {
items: components["schemas"]["AgentBlacklistItem"][];
};
};
};
401: components["responses"]["Unauthorized"];
};
};
clearAgentBlacklist: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Number of removed blacklist rows. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ActionCountResponse"];
};
};
401: components["responses"]["Unauthorized"];
};
};
deleteAgentBlacklistItem: {
parameters: {
query?: never;
header?: never;
path: {
key_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Number of removed blacklist rows. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ActionCountResponse"];
};
};
401: components["responses"]["Unauthorized"];
};
};
acceptAllPendingAgents: {
parameters: {
query?: {
/** @description When false, accepted agents with pending replacement keys are skipped. */
include_rotation?: boolean;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Number of accepted agents. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ActionCountResponse"];
};
};
401: components["responses"]["Unauthorized"];
};
};
blockAllPendingAgents: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Number of blocked agent keys. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ActionCountResponse"];
};
};
401: components["responses"]["Unauthorized"];
};
};
removeAllPendingAgents: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Number of removed agents. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ActionCountResponse"];
};
};
401: components["responses"]["Unauthorized"];
};
};
getAgent: {
parameters: {
query?: never;
@@ -540,12 +907,86 @@ export interface operations {
404: components["responses"]["NotFound"];
};
};
deleteAgent: {
parameters: {
query?: never;
header?: never;
path: {
agent_id: components["parameters"]["AgentId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Number of removed agents. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ActionCountResponse"];
};
};
401: components["responses"]["Unauthorized"];
404: components["responses"]["NotFound"];
};
};
acceptAgent: {
parameters: {
query?: never;
header?: never;
path: {
agent_id: components["parameters"]["AgentId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Number of accepted agents. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ActionCountResponse"];
};
};
401: components["responses"]["Unauthorized"];
404: components["responses"]["NotFound"];
};
};
blockAgent: {
parameters: {
query?: never;
header?: never;
path: {
agent_id: components["parameters"]["AgentId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Number of blocked agent keys. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ActionCountResponse"];
};
};
401: components["responses"]["Unauthorized"];
404: components["responses"]["NotFound"];
};
};
listChecks: {
parameters: {
query?: {
agent_id?: string;
/** @description Opaque pagination cursor returned in `next_cursor`. */
/** @description Opaque pagination cursor returned in `next_cursor` (or `prev_cursor` when `back=true`). */
cursor?: components["parameters"]["Cursor"];
/** @description When true, treat the `cursor` as a `prev_cursor` and return the previous page. */
back?: components["parameters"]["Back"];
limit?: components["parameters"]["Limit"];
};
header?: never;
@@ -572,8 +1013,15 @@ export interface operations {
parameters: {
query?: {
state?: "open" | "resolved";
/** @description Opaque pagination cursor returned in `next_cursor`. */
severity?: "warning" | "critical" | "unknown";
agent_id?: string;
check_id?: string;
sort?: "status" | "opened_at" | "resolved_at";
direction?: "asc" | "desc";
/** @description Opaque pagination cursor returned in `next_cursor` (or `prev_cursor` when `back=true`). */
cursor?: components["parameters"]["Cursor"];
/** @description When true, treat the `cursor` as a `prev_cursor` and return the previous page. */
back?: components["parameters"]["Back"];
limit?: components["parameters"]["Limit"];
};
header?: never;
@@ -601,8 +1049,10 @@ export interface operations {
query?: {
agent_id?: string;
check_id?: string;
/** @description Opaque pagination cursor returned in `next_cursor`. */
/** @description Opaque pagination cursor returned in `next_cursor` (or `prev_cursor` when `back=true`). */
cursor?: components["parameters"]["Cursor"];
/** @description When true, treat the `cursor` as a `prev_cursor` and return the previous page. */
back?: components["parameters"]["Back"];
limit?: components["parameters"]["Limit"];
};
header?: never;
@@ -625,11 +1075,39 @@ export interface operations {
401: components["responses"]["Unauthorized"];
};
};
getSystemSummary: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description System summary snapshot. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SystemSummaryResponse"];
};
};
401: components["responses"]["Unauthorized"];
};
};
listOutbox: {
parameters: {
query?: {
/** @description Opaque pagination cursor returned in `next_cursor`. */
state?: "pending" | "sending" | "sent" | "retry" | "failed" | "discarded";
notifier?: "debug" | "telegram" | "webhook" | "alertmanager";
event_type?: "firing" | "resolved";
sort?: "created_at" | "updated_at";
direction?: "asc" | "desc";
/** @description Opaque pagination cursor returned in `next_cursor` (or `prev_cursor` when `back=true`). */
cursor?: components["parameters"]["Cursor"];
/** @description When true, treat the `cursor` as a `prev_cursor` and return the previous page. */
back?: components["parameters"]["Back"];
limit?: components["parameters"]["Limit"];
};
header?: never;

View File

@@ -3,6 +3,7 @@ import "server-only";
import type { components, operations } from "./api-types";
export type Agent = components["schemas"]["Agent"];
export type AgentBlacklistItem = components["schemas"]["AgentBlacklistItem"];
export type CheckState = components["schemas"]["CheckState"];
export type Incident = components["schemas"]["Incident"];
export type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
@@ -18,6 +19,9 @@ type ChecksBody = OkBody<operations["listChecks"]>;
type IncidentsBody = OkBody<operations["listIncidents"]>;
type EventsBody = OkBody<operations["queryEvents"]>;
type OutboxBody = OkBody<operations["listOutbox"]>;
type SystemSummaryBody = OkBody<operations["getSystemSummary"]>;
type AgentBlacklistBody = OkBody<operations["listAgentBlacklist"]>;
type ActionCountBody = components["schemas"]["ActionCountResponse"];
const BASE = process.env.MONLET_API_BASE_URL ?? "http://127.0.0.1:8000";
const TOKEN = process.env.MONLET_API_TOKEN ?? "";
@@ -28,11 +32,15 @@ export class ApiError extends Error {
}
}
async function get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
async function get<T>(
path: string,
params?: Record<string, string | number | boolean | undefined>,
): Promise<T> {
const url = new URL(path, BASE);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
if (v === undefined || v === "" || v === false) continue;
url.searchParams.set(k, String(v));
}
}
const headers: Record<string, string> = { Accept: "application/json" };
@@ -51,10 +59,51 @@ async function get<T>(path: string, params?: Record<string, string | number | un
return res.json() as Promise<T>;
}
async function send<T>(method: "POST" | "DELETE", path: string): Promise<T> {
const url = new URL(path, BASE);
const headers: Record<string, string> = { Accept: "application/json" };
if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
const res = await fetch(url, { method, headers, cache: "no-store" });
if (!res.ok) {
let code = "http_error";
let message = res.statusText;
try {
const body = await res.json();
code = body?.error?.code ?? code;
message = body?.error?.message ?? message;
} catch {}
throw new ApiError(res.status, code, message);
}
return res.json() as Promise<T>;
}
export const api = {
listAgents: (q: QueryOf<operations["listAgents"]> = {}) =>
get<AgentsBody>("/api/v1/agents", q),
getAgent: (agentId: string) => get<Agent>(`/api/v1/agents/${encodeURIComponent(agentId)}`),
acceptAgent: (agentId: string) =>
send<ActionCountBody>("POST", `/api/v1/agents/${encodeURIComponent(agentId)}/accept`),
blockAgent: (agentId: string) =>
send<ActionCountBody>("POST", `/api/v1/agents/${encodeURIComponent(agentId)}/block`),
removeAgent: (agentId: string) =>
send<ActionCountBody>("DELETE", `/api/v1/agents/${encodeURIComponent(agentId)}`),
acceptAllPendingAgents: (includeRotation = false) =>
send<ActionCountBody>(
"POST",
`/api/v1/agents/admission/accept-all${includeRotation ? "?include_rotation=true" : ""}`,
),
blockAllPendingAgents: () =>
send<ActionCountBody>("POST", "/api/v1/agents/admission/block-all"),
removeAllPendingAgents: () =>
send<ActionCountBody>("POST", "/api/v1/agents/admission/remove-all"),
listAgentBlacklist: (q: QueryOf<operations["listAgentBlacklist"]> = {}) =>
get<AgentBlacklistBody>("/api/v1/agents/admission/blacklist", q),
deleteAgentBlacklistItem: (id: string) =>
send<ActionCountBody>(
"DELETE",
`/api/v1/agents/admission/blacklist/${encodeURIComponent(id)}`,
),
clearAgentBlacklist: () => send<ActionCountBody>("DELETE", "/api/v1/agents/admission/blacklist"),
listChecks: (q: QueryOf<operations["listChecks"]> = {}) =>
get<ChecksBody>("/api/v1/checks", q),
listIncidents: (q: QueryOf<operations["listIncidents"]> = {}) =>
@@ -63,5 +112,6 @@ export const api = {
get<EventsBody>("/api/v1/events/query", q),
listOutbox: (q: QueryOf<operations["listOutbox"]> = {}) =>
get<OutboxBody>("/api/v1/notifiers/outbox", q),
getSystemSummary: () => get<SystemSummaryBody>("/api/v1/system-summary"),
health: () => get<{ status: string }>("/api/v1/health"),
};

View File

@@ -1,18 +1,33 @@
import "server-only";
import { api, type Agent, type CheckState, type OutboxItem, type StoredEvent } from "@/lib/api";
import type { components } from "@/lib/api-types";
import type { InventoryData, OverviewData } from "@/lib/view-types";
import {
api,
type Agent,
type AgentBlacklistItem,
type CheckState,
type OutboxItem,
type StoredEvent,
} from "@/lib/api";
import type { components, operations } from "@/lib/api-types";
import type { InventoryData, SystemSummaryData } from "@/lib/view-types";
type Incident = components["schemas"]["Incident"];
// PH-004: inventory is bounded current-state. Enforce a hard cap so a runaway
// agent inventory cannot drain the UI process.
const INVENTORY_PAGE = 500;
const INVENTORY_HARD_CAP_PAGES = 20; // 10_000 rows worst case for inventory.
export async function loadAgents(): Promise<Agent[]> {
const agents: Agent[] = [];
let cursor: string | undefined;
let pages = 0;
do {
const page = await api.listAgents({ cursor, limit: 500 });
const page = await api.listAgents({ cursor, limit: INVENTORY_PAGE });
agents.push(...page.items);
cursor = page.next_cursor ?? undefined;
pages += 1;
if (pages >= INVENTORY_HARD_CAP_PAGES) break;
} while (cursor);
return agents;
}
@@ -20,10 +35,13 @@ export async function loadAgents(): Promise<Agent[]> {
export async function loadChecks(agentId?: string): Promise<CheckState[]> {
const checks: CheckState[] = [];
let cursor: string | undefined;
let pages = 0;
do {
const page = await api.listChecks({ agent_id: agentId, cursor, limit: 500 });
const page = await api.listChecks({ agent_id: agentId, cursor, limit: INVENTORY_PAGE });
checks.push(...page.items);
cursor = page.next_cursor ?? undefined;
pages += 1;
if (pages >= INVENTORY_HARD_CAP_PAGES) break;
} while (cursor);
return checks;
}
@@ -33,72 +51,66 @@ export async function loadInventory(): Promise<InventoryData> {
return { agents, checks };
}
export async function loadAgentBlacklist(): Promise<AgentBlacklistItem[]> {
const rows: AgentBlacklistItem[] = [];
let cursor: string | undefined;
let pages = 0;
do {
const page = await api.listAgentBlacklist({ cursor, limit: INVENTORY_PAGE });
rows.push(...page.items);
cursor = page.next_cursor ?? undefined;
pages += 1;
if (pages >= INVENTORY_HARD_CAP_PAGES) break;
} while (cursor);
return rows;
}
export async function loadEvents({
agentId,
checkId,
cursor,
back,
limit,
}: {
agentId?: string;
checkId?: string;
cursor?: string;
back?: boolean;
limit?: number;
} = {}): Promise<{ items: StoredEvent[]; next_cursor?: string | null }> {
return api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, limit });
} = {}): Promise<{ items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null }> {
return api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, back, limit });
}
export async function loadIncidents(state?: "open" | "resolved"): Promise<Incident[]> {
const items: Incident[] = [];
let cursor: string | undefined;
do {
const page = await api.listIncidents({ state, cursor, limit: 500 });
items.push(...page.items);
cursor = page.next_cursor ?? undefined;
} while (cursor);
return items;
// PH-001: incidents are unbounded history. Always page through the server.
export async function loadIncidentsPage(
q: NonNullable<operations["listIncidents"]["parameters"]["query"]> = {},
): Promise<{ items: Incident[]; next_cursor?: string | null; prev_cursor?: string | null }> {
return api.listIncidents(q);
}
// PH-002: outbox is unbounded. Always page through the server.
export async function loadOutboxPage(
q: NonNullable<operations["listOutbox"]["parameters"]["query"]> = {},
): Promise<{ items: OutboxItem[]; next_cursor?: string | null; prev_cursor?: string | null }> {
return api.listOutbox(q);
}
// First-page convenience loaders for SSR. Replace the previous drain-all paths
// (PH-001, PH-002): they returned the entire table on every initial render and
// did not scale past a few thousand rows. Pages now show one page and the UI
// owns subsequent fetches via /api/poll/* with cursors.
export async function loadIncidents(
state?: "open" | "resolved",
): Promise<Incident[]> {
const page = await api.listIncidents({ state, limit: 100 });
return page.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;
const page = await api.listOutbox({ limit: 100 });
return page.items;
}
export async function loadOverview(): Promise<OverviewData> {
const [agents, checks, openIncidents] = await Promise.all([
loadAgents(),
loadChecks(),
countOpenIncidents(),
]);
return {
agents: tally(agents),
checks: tally(checks),
openIncidents,
totalAgents: agents.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";
acc[k] = (acc[k] ?? 0) + 1;
return acc;
}, {});
export async function loadSystemSummary(): Promise<SystemSummaryData> {
return api.getSystemSummary();
}

View File

@@ -21,3 +21,12 @@ export function normalizePageSize(value: unknown, fallback: PageSize = DEFAULT_P
const n = Number(value);
return (PAGE_SIZE_OPTIONS as readonly number[]).includes(n) ? (n as PageSize) : fallback;
}
export function normalizePageNumber(value: unknown): number {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : 1;
}
export function normalizeBack(value: unknown): boolean {
return value === "1" || value === "true";
}

View File

@@ -3,21 +3,51 @@
import { useEffect, useState } from "react";
const DEFAULT_POLL_MS = 10_000;
const BACKOFF_AFTER_ERRORS = 2;
const BACKOFF_CAP_MS = 60_000;
export type PollingState<T> = {
data: T;
error: string | null;
isStale: boolean;
lastUpdatedAt: number | null;
};
export function usePolling<T>(initialData: T, url: string, intervalMs = pollIntervalMs()) {
const [data, setData] = useState(initialData);
const [error, setError] = useState<string | null>(null);
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null);
useEffect(() => {
if (intervalMs <= 0) return;
let timer: ReturnType<typeof setInterval> | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
let inFlight = false;
let stopped = false;
let errors = 0;
let controller: AbortController | undefined;
// PH-005: small random jitter prevents synchronized poll spikes when many
// dashboards refresh together; bounded exponential backoff after repeated
// failures keeps a degraded server from being hot-looped.
const nextDelay = () => {
const base = errors <= BACKOFF_AFTER_ERRORS
? intervalMs
: Math.min(intervalMs * 2 ** (errors - BACKOFF_AFTER_ERRORS), BACKOFF_CAP_MS);
const jitter = base * (Math.random() * 0.2);
return base + jitter;
};
const schedule = () => {
if (stopped) return;
timer = setTimeout(refresh, nextDelay());
};
const refresh = () => {
if (stopped || inFlight || document.visibilityState !== "visible") return;
if (stopped || inFlight || document.visibilityState !== "visible") {
schedule();
return;
}
inFlight = true;
controller = new AbortController();
@@ -32,51 +62,44 @@ export function usePolling<T>(initialData: T, url: string, intervalMs = pollInte
.then((body) => {
setData(body);
setError(null);
setLastUpdatedAt(Date.now());
errors = 0;
})
.catch((e: unknown) => {
if (!controller?.signal.aborted) {
setError(e instanceof Error ? e.message : String(e));
errors += 1;
}
})
.finally(() => {
inFlight = false;
schedule();
});
};
const start = () => {
stopTimer();
timer = setInterval(refresh, intervalMs);
};
const stopTimer = () => {
if (timer !== undefined) {
clearInterval(timer);
timer = undefined;
}
};
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") {
start();
refresh();
} else {
stopTimer();
if (timer !== undefined) clearTimeout(timer);
controller?.abort();
}
};
if (document.visibilityState === "visible") start();
if (document.visibilityState === "visible") refresh();
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
stopped = true;
stopTimer();
if (timer !== undefined) clearTimeout(timer);
controller?.abort();
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [intervalMs, url]);
return { data, error };
// PH-005: surface stale data flag so pages can show "data is stale" badges.
const isStale = error !== null;
return { data, error, isStale, lastUpdatedAt } as PollingState<T>;
}
export function pollIntervalMs() {

View File

@@ -8,10 +8,13 @@ export type InventoryData = {
checks: CheckState[];
};
export type OverviewData = {
agents: Record<string, number>;
checks: Record<string, number>;
openIncidents: number;
totalAgents: number;
totalChecks: number;
export type SystemSummaryData = {
agents_online: number;
agents_offline: number;
checks_ok: number;
checks_warning: number;
checks_critical: number;
checks_unknown: number;
open_incidents: number;
generated_at: string;
};

48
web/src/proxy.ts Normal file
View File

@@ -0,0 +1,48 @@
import type { NextRequest } from "next/server";
const ACTION_PATH = "/api/agents/action";
const HEALTH_PATH = "/api/health";
export const config = {
matcher: "/((?!_next/static|_next/image|favicon.ico).*)",
};
export function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
if (path === HEALTH_PATH) return;
const user = process.env.MONLET_WEB_AUTH_USERNAME;
const password = process.env.MONLET_WEB_AUTH_PASSWORD;
if (user && password) {
if (validBasicAuth(request, user, password)) return;
return new Response("authentication required", {
status: 401,
headers: { "WWW-Authenticate": 'Basic realm="monlet"' },
});
}
if (process.env.MONLET_WEB_TRUST_PROXY_AUTH === "true") {
if (request.headers.get("x-forwarded-user")) return;
return new Response("proxy authentication required", { status: 401 });
}
if (path === ACTION_PATH) {
return Response.json(
{ error: { code: "forbidden", message: "web auth required" } },
{ status: 403 },
);
}
}
function validBasicAuth(request: NextRequest, user: string, password: string): boolean {
const header = request.headers.get("authorization");
if (!header?.startsWith("Basic ")) return false;
try {
const raw = atob(header.slice("Basic ".length));
const split = raw.indexOf(":");
if (split < 0) return false;
return raw.slice(0, split) === user && raw.slice(split + 1) === password;
} catch {
return false;
}
}