Harden production workflows and agent admission
This commit is contained in:
@@ -20,5 +20,9 @@ COPY --from=builder /app/.next ./.next
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
# PH-015: run as the prebuilt non-root `node` user shipped by the base image.
|
||||
RUN chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
STOPSIGNAL SIGTERM
|
||||
CMD ["npm", "run", "start"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Monlet Web
|
||||
|
||||
Read-only dashboard for Monlet.
|
||||
Dashboard for Monlet. Monitoring pages are read-only; agent admission and blacklist controls can mutate server state.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -36,13 +36,17 @@ Do not install Node packages globally. Project dependencies live in `web/node_mo
|
||||
| `MONLET_API_BASE_URL` | `http://127.0.0.1:8000` | Server base URL (server-side fetch) |
|
||||
| `MONLET_API_TOKEN` | _(none)_ | Bearer token sent to server |
|
||||
| `MONLET_WEB_TIME_ZONE` | `UTC` | IANA timezone used to render timestamps |
|
||||
| `MONLET_WEB_AUTH_USERNAME` | _(none)_ | Optional Basic Auth username for the web UI |
|
||||
| `MONLET_WEB_AUTH_PASSWORD` | _(none)_ | Optional Basic Auth password for the web UI |
|
||||
| `MONLET_WEB_TRUST_PROXY_AUTH` | `false` | Trust `X-Forwarded-User` from an upstream auth proxy |
|
||||
|
||||
The token never reaches the browser — all server calls happen in Server Components / route handlers.
|
||||
The API token never reaches the browser — all server calls happen in Server Components / route handlers. Agent admission actions are blocked unless Basic Auth is configured or trusted proxy auth is enabled.
|
||||
|
||||
## Pages
|
||||
|
||||
- `/` — overview
|
||||
- `/` — redirects to `/agents`
|
||||
- `/agents` — agent list
|
||||
- `/agents/blacklist` — blocked agent keys
|
||||
- `/agents/[id]` — agent detail
|
||||
- `/checks` — current check states
|
||||
- `/incidents` — incident list
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
15
web/src/app/agents/blacklist/page.tsx
Normal file
15
web/src/app/agents/blacklist/page.tsx
Normal 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} />;
|
||||
}
|
||||
56
web/src/app/api/agents/action/route.ts
Normal file
56
web/src/app/api/agents/action/route.ts
Normal 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;
|
||||
}
|
||||
7
web/src/app/api/health/route.ts
Normal file
7
web/src/app/api/health/route.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
104
web/src/components/AgentBlacklistBrowser.tsx
Normal file
104
web/src/components/AgentBlacklistBrowser.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
93
web/src/components/TopNav.tsx
Normal file
93
web/src/components/TopNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
48
web/src/proxy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -6,12 +6,23 @@ const now = new Date().toISOString();
|
||||
|
||||
const fixtures = {
|
||||
"/api/v1/health": { status: "ok" },
|
||||
"/api/v1/system-summary": {
|
||||
agents_online: 1,
|
||||
agents_offline: 1,
|
||||
checks_ok: 2,
|
||||
checks_warning: 1,
|
||||
checks_critical: 1,
|
||||
open_incidents: 2,
|
||||
generated_at: now,
|
||||
},
|
||||
"/api/v1/agents": {
|
||||
items: [
|
||||
{
|
||||
agent_id: "agent-1",
|
||||
hostname: "host-1",
|
||||
status: "alive",
|
||||
admission_state: "accepted",
|
||||
key_fingerprint: "1111111111111111",
|
||||
last_seen_at: now,
|
||||
features: { push: true, metrics: false },
|
||||
labels: { env: "dev", monlet_agent_version: "0.1.0" },
|
||||
@@ -20,6 +31,8 @@ const fixtures = {
|
||||
agent_id: "agent-2",
|
||||
hostname: "host-2",
|
||||
status: "dead",
|
||||
admission_state: "accepted",
|
||||
key_fingerprint: "2222222222222222",
|
||||
last_seen_at: now,
|
||||
features: { push: true, metrics: false },
|
||||
labels: {},
|
||||
@@ -31,6 +44,8 @@ const fixtures = {
|
||||
agent_id: "agent-1",
|
||||
hostname: "host-1",
|
||||
status: "alive",
|
||||
admission_state: "accepted",
|
||||
key_fingerprint: "1111111111111111",
|
||||
last_seen_at: now,
|
||||
features: { push: true, metrics: false },
|
||||
labels: { env: "dev", monlet_agent_version: "0.1.0" },
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("overview renders tallies", async ({ page }) => {
|
||||
test("root redirects to agents with nav counters", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Overview" })).toBeVisible();
|
||||
await expect(page.locator("body")).toContainText("Agents");
|
||||
await expect(page.locator("body")).toContainText("Checks");
|
||||
await expect(page).toHaveURL(/\/agents$/);
|
||||
await expect(page.getByRole("heading", { name: "Agents" })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /Agents.*1.*1/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /Checks.*2.*1.*1/ })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /Incidents.*2/ })).toBeVisible();
|
||||
});
|
||||
|
||||
test("agents list links to detail", async ({ page }) => {
|
||||
|
||||
Reference in New Issue
Block a user