Add web auth, infinite-scroll, agent admission and review fixes across agent/server/web

This commit is contained in:
Stanislav Rossovskii
2026-05-28 23:45:12 +04:00
parent 37b1a1d6d6
commit 1026e9ebbe
75 changed files with 3021 additions and 916 deletions

View File

@@ -36,11 +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_AUTH_USERNAME` | _(none)_ | Optional local web login username |
| `MONLET_WEB_AUTH_PASSWORD` | _(none)_ | Optional local web login password |
| `MONLET_WEB_SESSION_SECRET` | _(none)_ | Secret used to sign the web session cookie; required with local web login; at least 32 bytes |
| `MONLET_WEB_SESSION_TTL_SEC` | `604800` | Local web session lifetime |
| `MONLET_WEB_TRUST_PROXY_AUTH` | `false` | Trust `X-Forwarded-User` from an upstream auth proxy |
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.
The API token never reaches the browser — all server calls happen in Server Components / route handlers. Agent admission actions are blocked unless local web login is configured or trusted proxy auth is enabled.
Production deployments should rate-limit `/login` at the reverse proxy. The app applies constant-time credential checks and a small fixed delay on failed login attempts, but it is not a full auth gateway.
The timezone switcher stores the selected mode in browser cookies, so each operator keeps their own default/browser/UTC preference.
## Pages

View File

@@ -1,8 +1,10 @@
import { redirect } from "next/navigation";
import { AgentDetailBrowser } from "@/components/AgentDetailBrowser";
import { ErrorPanel } from "@/components/DataPanel";
import { ApiError, api } from "@/lib/api";
import { loadChecks } from "@/lib/loaders";
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
export const dynamic = "force-dynamic";
@@ -38,22 +40,19 @@ export default async function AgentDetailPage({
const sp = await searchParams;
const sort = normalizeSort(sp.sort);
const tab = normalizeTab(sp.tab);
const eventsLimit = normalizePageSize(sp.events_limit, 50);
const eventsCursor = sp.cursor;
const eventsPage = normalizePageNumber(sp.page);
const eventsBack = normalizeBack(sp.back);
if (sp.cursor || sp.back || sp.page || sp.events_limit) {
redirect(agentDetailHref({ agentId: agent_id, tab, sort }));
}
let agent;
let checks;
let events: EventsPage = { items: [], next_cursor: null, prev_cursor: null };
let events: EventsPage = { items: [], next_cursor: null };
try {
[agent, checks] = await Promise.all([api.getAgent(agent_id), loadChecks(agent_id)]);
if (tab === "events") {
events = await api.queryEvents({
agent_id,
limit: eventsLimit,
cursor: eventsCursor,
back: eventsBack,
limit: INFINITE_BATCH_SIZE,
});
}
} catch (e) {
@@ -64,15 +63,24 @@ export default async function AgentDetailPage({
}
return (
<AgentDetailBrowser
key={`${agent_id}:${tab}:${sort}:${eventsLimit}:${eventsCursor ?? ""}:${eventsBack ? "b" : "f"}:${eventsPage}`}
key={`${agent_id}:${tab}:${sort}`}
initialData={{ agent, checks }}
initialEvents={events}
sort={sort}
tab={tab}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
eventsBack={eventsBack}
eventsPage={eventsPage}
/>
);
}
function agentDetailHref({
agentId,
tab,
sort,
}: {
agentId: string;
tab: TabKey;
sort: SortKey;
}) {
const qs = new URLSearchParams({ tab, sort });
return `/agents/${encodeURIComponent(agentId)}?${qs.toString()}`;
}

View File

@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { api } from "@/lib/api";
import { jsonError } from "@/lib/poll-response";
import { assertSameOrigin } from "@/lib/web-auth-server";
export const dynamic = "force-dynamic";
@@ -16,6 +17,12 @@ type Action =
| "blacklist_clear";
export async function POST(request: NextRequest) {
if (!assertSameOrigin(request)) {
return NextResponse.json(
{ error: { code: "forbidden", message: "cross-origin request rejected" } },
{ status: 403 },
);
}
try {
const body = (await request.json()) as {
action?: Action;

View File

@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import {
cookieSecure,
constantTimeEqualString,
createSessionToken,
delayLoginFailure,
localAuthEnabled,
proxyAuthEnabled,
sanitizeNext,
SESSION_COOKIE,
sessionTtlSec,
} from "@/lib/web-auth";
import { assertSameOrigin } from "@/lib/web-auth-server";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
if (!assertSameOrigin(request)) return new NextResponse("forbidden", { status: 403 });
const form = await request.formData();
const next = sanitizeNext(stringField(form, "next"));
if (proxyAuthEnabled() || !localAuthEnabled()) return redirectLogin(request, next, "config");
const username = stringField(form, "username");
const password = stringField(form, "password");
const [usernameOk, passwordOk] = await Promise.all([
constantTimeEqualString(username, process.env.MONLET_WEB_AUTH_USERNAME ?? ""),
constantTimeEqualString(password, process.env.MONLET_WEB_AUTH_PASSWORD ?? ""),
]);
if (!usernameOk || !passwordOk) {
await delayLoginFailure();
return redirectLogin(request, next, "invalid");
}
const ttl = sessionTtlSec();
const token = await createSessionToken(username, process.env.MONLET_WEB_SESSION_SECRET ?? "", ttl);
const response = NextResponse.redirect(new URL(next, request.url), { status: 303 });
response.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: "lax",
secure: cookieSecure(request),
path: "/",
maxAge: ttl,
});
return response;
}
function redirectLogin(request: NextRequest, next: string, error: string) {
const url = new URL("/login", request.url);
url.searchParams.set("next", next);
url.searchParams.set("error", error);
return NextResponse.redirect(url, { status: 303 });
}
function stringField(form: FormData, name: string): string {
const value = form.get(name);
return typeof value === "string" ? value : "";
}

View File

@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { cookieSecure, SESSION_COOKIE } from "@/lib/web-auth";
import { assertSameOrigin } from "@/lib/web-auth-server";
export const dynamic = "force-dynamic";
export async function POST(request: NextRequest) {
if (!assertSameOrigin(request)) return new NextResponse("forbidden", { status: 403 });
const response = NextResponse.redirect(new URL("/login", request.url), { status: 303 });
response.cookies.set(SESSION_COOKIE, "", {
httpOnly: true,
sameSite: "lax",
secure: cookieSecure(request),
path: "/",
maxAge: 0,
});
return response;
}

View File

@@ -1,21 +1,20 @@
import { NextResponse } from "next/server";
import { loadEvents } from "@/lib/loaders";
import { normalizePageSize } from "@/lib/pagination";
import { normalizeFetchLimit } from "@/lib/pagination";
import { jsonError } from "@/lib/poll-response";
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const url = new URL(request.url);
const limit = normalizePageSize(url.searchParams.get("limit"), 50);
const limit = normalizeFetchLimit(url.searchParams.get("limit"));
try {
const data = await loadEvents({
agentId: url.searchParams.get("agent_id") ?? undefined,
checkId: url.searchParams.get("check_id") ?? undefined,
cursor: url.searchParams.get("cursor") ?? undefined,
back: url.searchParams.get("back") === "1",
limit,
});
return NextResponse.json(data);

View File

@@ -15,7 +15,6 @@ export async function GET(request: Request) {
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;
@@ -28,7 +27,7 @@ export async function GET(request: Request) {
const direction = dirRaw && DIRS.has(dirRaw) ? (dirRaw as "asc" | "desc") : undefined;
try {
const page = await loadIncidentsPage({ state, cursor, back, limit, sort, direction });
const page = await loadIncidentsPage({ state, cursor, limit, sort, direction });
return NextResponse.json(page);
} catch (e) {
return jsonError(e);

View File

@@ -12,7 +12,6 @@ const DIRS = new Set(["asc", "desc"]);
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;
@@ -26,7 +25,6 @@ export async function GET(request: Request) {
try {
const page = await loadOutboxPage({
cursor,
back,
limit,
state: state as never,
sort,

View File

@@ -1,8 +1,10 @@
import { redirect } from "next/navigation";
import { CheckDetailBrowser } from "@/components/CheckDetailBrowser";
import { ErrorPanel } from "@/components/DataPanel";
import { api } from "@/lib/api";
import { loadInventory } from "@/lib/loaders";
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
export const dynamic = "force-dynamic";
@@ -29,21 +31,18 @@ export default async function CheckDetailPage({
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);
if (sp.cursor || sp.back || sp.page || sp.events_limit) {
redirect(checkDetailHref({ checkId: check_id, tab }));
}
let data;
let events: EventsPage = { items: [], next_cursor: null, prev_cursor: null };
let events: EventsPage = { items: [], next_cursor: null };
try {
data = await loadInventory();
if (tab === "events") {
events = await api.queryEvents({
check_id,
limit: eventsLimit,
cursor: eventsCursor,
back: eventsBack,
limit: INFINITE_BATCH_SIZE,
});
}
} catch (e) {
@@ -52,15 +51,22 @@ export default async function CheckDetailPage({
return (
<CheckDetailBrowser
key={`${check_id}:${tab}:${eventsLimit}:${eventsCursor ?? ""}:${eventsBack ? "b" : "f"}:${eventsPage}`}
key={`${check_id}:${tab}`}
checkId={check_id}
initialData={data}
initialEvents={events}
tab={tab}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
eventsBack={eventsBack}
eventsPage={eventsPage}
/>
);
}
function checkDetailHref({
checkId,
tab,
}: {
checkId: string;
tab: TabKey;
}) {
const qs = new URLSearchParams({ tab });
return `/checks/${encodeURIComponent(checkId)}?${qs.toString()}`;
}

View File

@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { ErrorPanel } from "@/components/DataPanel";
import { EventsBrowser } from "@/components/EventsBrowser";
import { loadEvents } from "@/lib/loaders";
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
export const dynamic = "force-dynamic";
@@ -18,31 +20,39 @@ export default async function EventsPage({
}>;
}) {
const sp = await searchParams;
const limit = normalizePageSize(sp.limit);
const page = normalizePageNumber(sp.page);
const back = normalizeBack(sp.back);
if (sp.cursor || sp.back || sp.page || sp.limit) {
redirect(eventsHref({ agentId: sp.agent_id, checkId: sp.check_id }));
}
let data;
try {
data = await loadEvents({
agentId: sp.agent_id,
checkId: sp.check_id,
cursor: sp.cursor,
back,
limit,
limit: INFINITE_BATCH_SIZE,
});
} catch (e) {
return <ErrorPanel error={e} />;
}
return (
<EventsBrowser
key={`${sp.cursor ?? ""}:${back ? "b" : "f"}:${page}:${sp.agent_id ?? ""}:${sp.check_id ?? ""}:${limit}`}
key={`${sp.agent_id ?? ""}:${sp.check_id ?? ""}`}
initialData={data}
cursor={sp.cursor}
back={back}
agentId={sp.agent_id}
checkId={sp.check_id}
limit={limit}
page={page}
/>
);
}
function eventsHref({
agentId,
checkId,
}: {
agentId?: string;
checkId?: string;
}) {
const qs = new URLSearchParams();
if (agentId) qs.set("agent_id", agentId);
if (checkId) qs.set("check_id", checkId);
const query = qs.toString();
return query ? `/events?${query}` : "/events";
}

View File

@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { ErrorPanel } from "@/components/DataPanel";
import { IncidentsBrowser } from "@/components/IncidentsBrowser";
import { loadIncidentsPage, loadInventory } from "@/lib/loaders";
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
export const dynamic = "force-dynamic";
@@ -25,14 +27,14 @@ export default async function IncidentsPage({
? 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);
if (sp.cursor || sp.back || sp.page || sp.limit) {
redirect(incidentsHref({ state, sort, direction }));
}
let page;
let inventory;
try {
[page, inventory] = await Promise.all([
loadIncidentsPage({ state, cursor: sp.cursor, back, limit, sort, direction }),
loadIncidentsPage({ state, limit: INFINITE_BATCH_SIZE, sort, direction }),
loadInventory(),
]);
} catch (e) {
@@ -40,16 +42,28 @@ export default async function IncidentsPage({
}
return (
<IncidentsBrowser
key={`${sp.cursor ?? ""}:${back ? "b" : "f"}:${pageNumber}:${state ?? ""}:${sort}:${direction}:${limit}`}
key={`${state ?? ""}:${sort}:${direction}`}
initialData={page}
initialInventory={inventory}
state={state}
cursor={sp.cursor}
back={back}
sort={sort}
direction={direction}
limit={limit}
page={pageNumber}
/>
);
}
function incidentsHref({
state,
sort,
direction,
}: {
state?: "open" | "resolved";
sort: "status" | "opened_at" | "resolved_at";
direction: "asc" | "desc";
}) {
const qs = new URLSearchParams();
if (state) qs.set("state", state);
qs.set("sort", sort);
qs.set("direction", direction);
return `/incidents?${qs.toString()}`;
}

View File

@@ -3,8 +3,9 @@ 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 { getInitialTimeZonePreference, getUiTimeZone } from "@/lib/timezone";
import type { SystemSummaryData } from "@/lib/view-types";
import { requestHasAppAccess, showLocalLogout } from "@/lib/web-auth-server";
import "./globals.css";
export const metadata: Metadata = {
@@ -24,13 +25,19 @@ async function safeLoadSystemSummary(): Promise<SystemSummaryData | null> {
export default async function RootLayout({ children }: { children: ReactNode }) {
const configuredTimeZone = getUiTimeZone();
const summary = await safeLoadSystemSummary();
const timezonePreference = await getInitialTimeZonePreference();
const hasAppAccess = await requestHasAppAccess();
const summary = hasAppAccess ? await safeLoadSystemSummary() : null;
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}>
<TopNav initialSummary={summary} />
<TimeZoneProvider
configuredTimeZone={configuredTimeZone}
initialMode={timezonePreference.mode}
initialBrowserTimeZone={timezonePreference.browserTimeZone}
>
{hasAppAccess ? <TopNav initialSummary={summary} showLogout={showLocalLogout()} /> : null}
<main className="flex-1 px-4 py-6">{children}</main>
</TimeZoneProvider>
</body>

View File

@@ -0,0 +1,96 @@
import { redirect } from "next/navigation";
import {
localAuthEnabled,
localAuthMisconfigured,
proxyAuthEnabled,
sanitizeNext,
} from "@/lib/web-auth";
import { requestHasAppAccess } from "@/lib/web-auth-server";
export const dynamic = "force-dynamic";
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ next?: string; error?: string }>;
}) {
const sp = await searchParams;
const next = sanitizeNext(sp.next);
if (await requestHasAppAccess()) redirect(next);
const proxyMode = proxyAuthEnabled();
const enabled = localAuthEnabled();
const misconfigured = localAuthMisconfigured();
const error =
sp.error === "invalid"
? "Invalid username or password."
: sp.error === "config" || misconfigured
? "Web authentication is not configured correctly."
: null;
return (
<div className="mx-auto mt-24 w-full max-w-sm">
<div className="mb-6 flex items-center gap-3">
<span className="relative h-9 w-9 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" />
<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>
<h1 className="text-2xl font-semibold">
mon<span className="text-emerald-300">let</span>
</h1>
</div>
<h2 className="mb-4 text-lg font-semibold">Sign in</h2>
{error ? (
<div className="mb-4 border border-red-900 bg-red-950/30 px-3 py-2 text-sm text-red-200">
{error}
</div>
) : null}
{misconfigured ? (
<div className="border border-red-900 bg-red-950/30 px-3 py-2 text-sm text-red-200">
Web authentication is misconfigured.
</div>
) : proxyMode ? (
<div className="border border-amber-900 bg-amber-950/20 px-3 py-2 text-sm text-amber-200">
Proxy authentication is enabled. This page is only shown when the proxy did not pass
an authenticated user.
</div>
) : enabled ? (
<form action="/api/auth/login" method="post" className="space-y-3">
<input type="hidden" name="next" value={next} />
<label className="block text-sm">
<span className="mb-1 block text-neutral-400">username</span>
<input
name="username"
autoComplete="username"
className="w-full border border-neutral-800 bg-neutral-950 px-3 py-2 text-neutral-100 outline-none focus:border-neutral-500"
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-neutral-400">password</span>
<input
name="password"
type="password"
autoComplete="current-password"
className="w-full border border-neutral-800 bg-neutral-950 px-3 py-2 text-neutral-100 outline-none focus:border-neutral-500"
/>
</label>
<button
type="submit"
className="w-full border border-emerald-900 bg-emerald-950/40 px-3 py-2 text-sm text-emerald-200 hover:border-emerald-600"
>
Sign in
</button>
</form>
) : (
<div className="border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm text-neutral-300">
Local web authentication is disabled.
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,9 @@
import { redirect } from "next/navigation";
import { ErrorPanel } from "@/components/DataPanel";
import { OutboxBrowser } from "@/components/OutboxBrowser";
import { loadOutboxPage } from "@/lib/loaders";
import { normalizeBack, normalizePageNumber, normalizePageSize } from "@/lib/pagination";
import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
export const dynamic = "force-dynamic";
@@ -18,27 +20,34 @@ export default async function OutboxPage({
}>;
}) {
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);
if (sp.cursor || sp.back || sp.page || sp.limit) redirect(outboxHref({ sort, direction }));
let page;
try {
page = await loadOutboxPage({ cursor: sp.cursor, back, limit, sort, direction });
page = await loadOutboxPage({ limit: INFINITE_BATCH_SIZE, sort, direction });
} catch (e) {
return <ErrorPanel error={e} />;
}
return (
<OutboxBrowser
key={`${sp.cursor ?? ""}:${back ? "b" : "f"}:${pageNumber}:${sort}:${direction}:${limit}`}
key={`${sort}:${direction}`}
initialData={page}
cursor={sp.cursor}
back={back}
sort={sort}
direction={direction}
limit={limit}
page={pageNumber}
/>
);
}
function outboxHref({
sort,
direction,
}: {
sort: "created_at" | "updated_at";
direction: "asc" | "desc";
}) {
const qs = new URLSearchParams();
qs.set("sort", sort);
qs.set("direction", direction);
return `/outbox?${qs.toString()}`;
}

View File

@@ -1,33 +1,25 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import { AgentFeatures } from "@/components/AgentFeatures";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, SummaryCell, Td, Th } from "@/components/DataPanel";
import { DetailTabs } from "@/components/DetailTabs";
import { InfiniteListFooter } from "@/components/InfiniteListFooter";
import { LabelChips } from "@/components/Labels";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { Pager } from "@/components/Pager";
import { DateTime } from "@/components/TimeZoneControls";
import type { components } from "@/lib/api-types";
import { statusBadgeClass } from "@/lib/format";
import {
DEFAULT_PAGE_SIZE,
clampPage,
pageCountOf,
paginate,
type PageSize,
} from "@/lib/pagination";
import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
import { useInfiniteClientList, useInfiniteCursorList } from "@/lib/useInfiniteList";
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; prev_cursor?: string | null };
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
type SortKey = "severity" | "check_id" | "last_seen";
type TabKey = "checks" | "events";
type AgentDetailData = { agent: Agent; checks: CheckState[] };
@@ -45,19 +37,11 @@ export function AgentDetailBrowser({
initialEvents,
sort,
tab,
eventsLimit,
eventsCursor,
eventsBack,
eventsPage,
}: {
initialData: AgentDetailData;
initialEvents: EventsPageData;
sort: SortKey;
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)}`;
@@ -102,22 +86,14 @@ export function AgentDetailBrowser({
{
key: "events",
label: "Events",
href: `/agents/${encodeURIComponent(agentId)}?tab=events&sort=${sort}&events_limit=${eventsLimit}`,
href: `/agents/${encodeURIComponent(agentId)}?tab=events&sort=${sort}`,
},
]}
/>
{tab === "checks" ? (
<ChecksTable agentId={agentId} checks={checks} sort={sort} />
) : (
<EventsTable
agentId={agentId}
sort={sort}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
eventsBack={eventsBack}
eventsPage={eventsPage}
initialEvents={initialEvents}
/>
<EventsTable agentId={agentId} initialEvents={initialEvents} />
)}
</div>
);
@@ -132,22 +108,19 @@ function ChecksTable({
checks: CheckState[];
sort: SortKey;
}) {
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
};
const pageCount = pageCountOf(checks.length, pageSize);
const currentPage = clampPage(page, pageCount);
const visible = paginate(checks, currentPage, pageSize);
const {
visibleItems: visible,
hasMore,
sentinelRef,
} = useInfiniteClientList({
items: checks,
batchSize: INFINITE_BATCH_SIZE,
resetKey: `${agentId}:${sort}`,
});
if (checks.length === 0) return <EmptyPanel label="No checks" />;
return (
<section>
<div className="mb-3 flex justify-end">
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
</div>
<table className="w-full text-sm">
<thead>
<tr>
@@ -184,48 +157,45 @@ function ChecksTable({
))}
</tbody>
</table>
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
<InfiniteListFooter sentinelRef={sentinelRef} hasMore={hasMore} />
</section>
);
}
function EventsTable({
agentId,
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");
const qs = new URLSearchParams({ agent_id: agentId, limit: String(INFINITE_BATCH_SIZE) });
return `/api/poll/events?${qs.toString()}`;
}, [agentId, eventsCursor, eventsBack, eventsLimit]);
const { data } = usePolling(initialEvents, pollUrl);
const onLimitChange = (next: PageSize) => {
const qs = new URLSearchParams({ tab: "events", sort, events_limit: String(next) });
router.push(`/agents/${encodeURIComponent(agentId)}?${qs.toString()}`);
};
}, [agentId]);
const getPageUrl = useCallback(
(cursor: string) => `${pollUrl}&cursor=${encodeURIComponent(cursor)}`,
[pollUrl],
);
const {
items,
hasMore,
loadingMore,
error,
sentinelRef,
hasNewItems,
showLatest,
} = useInfiniteCursorList<StoredEvent>({
initialData: initialEvents,
firstPageUrl: pollUrl,
getPageUrl,
getKey: eventKey,
resetKey: agentId,
});
return (
<section>
<div className="mb-3 flex justify-end">
<PageSizeSelect value={eventsLimit} onChange={onLimitChange} />
</div>
{data.items.length === 0 ? (
{items.length === 0 ? (
<EmptyPanel label="No events" />
) : (
<table className="w-full text-sm">
@@ -239,7 +209,7 @@ function EventsTable({
</tr>
</thead>
<tbody>
{data.items.map((e) => (
{items.map((e) => (
<tr key={e.event_id}>
<Td>
<DateTime iso={e.observed_at} />
@@ -255,12 +225,13 @@ function EventsTable({
</tbody>
</table>
)}
<Pager
basePath={`/agents/${encodeURIComponent(agentId)}`}
prevCursor={data.prev_cursor}
nextCursor={data.next_cursor}
page={eventsPage}
extra={{ tab: "events", sort, events_limit: String(eventsLimit) }}
<InfiniteListFooter
sentinelRef={sentinelRef}
hasMore={hasMore}
loading={loadingMore}
error={error}
hasNewItems={hasNewItems}
onShowLatest={showLatest}
/>
</section>
);
@@ -294,3 +265,7 @@ function sortChecks(checks: CheckState[], sort: SortKey): CheckState[] {
return severityRank[a.status] - severityRank[b.status] || a.check_id.localeCompare(b.check_id);
});
}
function eventKey(event: StoredEvent): string {
return event.event_id;
}

View File

@@ -5,21 +5,16 @@ import { useMemo, useState } from "react";
import { AgentFeatures } from "@/components/AgentFeatures";
import { AgentLink } from "@/components/AgentLink";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, Td, Th } from "@/components/DataPanel";
import { InfiniteListFooter } from "@/components/InfiniteListFooter";
import { LabelChips } from "@/components/Labels";
import { ListToolbar } from "@/components/ListToolbar";
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
import { DateTime } from "@/components/TimeZoneControls";
import type { components } from "@/lib/api-types";
import { statusBadgeClass } from "@/lib/format";
import {
DEFAULT_PAGE_SIZE,
clampPage,
pageCountOf,
paginate,
type PageSize,
} from "@/lib/pagination";
import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
import { useInfiniteClientList } from "@/lib/useInfiniteList";
import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types";
@@ -56,19 +51,18 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
({ agent }) => agent.rotation_pending,
).length;
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
const pageCount = pageCountOf(rows.length, pageSize);
const currentPage = clampPage(page, pageCount);
const visible = paginate(rows, currentPage, pageSize);
const {
visibleItems: visible,
hasMore,
sentinelRef,
} = useInfiniteClientList({
items: rows,
batchSize: INFINITE_BATCH_SIZE,
resetKey: `${query}:${sort}:${dir}`,
});
const onQueryChange = (value: string) => {
setQuery(value);
setPage(1);
};
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
};
const chooseSort = (field: SortField) => {
if (field === sort) {
@@ -77,7 +71,6 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
setSort(field);
setDir(fieldDefaultDir(field));
}
setPage(1);
};
const runAction = async (
action: string,
@@ -123,8 +116,6 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
query={query}
onQueryChange={onQueryChange}
placeholder="search agent"
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
/>
{rows.length === 0 ? (
<EmptyPanel />
@@ -140,7 +131,7 @@ export function AgentsBrowser({ initialData }: { initialData: InventoryData }) {
rotationPendingCount={allRotationPendingCount}
/>
)}
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
<InfiniteListFooter sentinelRef={sentinelRef} hasMore={hasMore} />
</>
);
}
@@ -173,6 +164,8 @@ function AgentsTable({
rotationPendingCount > 0
? `ROTATION: existing accepted keys will be replaced for ${rotationPendingCount} agents. Accept all pending agents including rotations?`
: undefined;
const visiblePendingRows = rows.filter(({ agent }) => agent.admission_state === "pending");
const visibleAcceptedRows = rows.filter(({ agent }) => agent.admission_state === "accepted");
return (
<table className="w-full text-sm">
<thead>
@@ -198,69 +191,105 @@ function AgentsTable({
</tr>
</thead>
<tbody>
{pendingCount > 0 ? (
{visiblePendingRows.length > 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">
<div className="flex flex-wrap items-center gap-3">
<span>{pendingCount} pending</span>
<ActionButton
label="✅"
title="Accept all"
{rotationPendingCount > 0 ? (
<span className="text-amber-500">{rotationPendingCount} replacements</span>
) : null}
</div>
</td>
</tr>
) : null}
{visiblePendingRows.map((row) => (
<AgentTableRow key={row.agent.agent_id} row={row} busy={busy} runAction={runAction} />
))}
{visiblePendingRows.length > 0 ? (
<tr>
<td colSpan={6} className="border-b border-neutral-900 bg-amber-950/10 px-2 py-3">
<div className="flex flex-wrap items-center gap-2">
<BulkActionButton
label="✅ Accept all"
title="Accept all pending agents"
disabled={busy !== null}
onClick={() =>
runAction("accept_all", undefined, acceptAllText, rotationPendingCount > 0)
}
/>
<ActionButton
label="⛔"
title="Block all"
<BulkActionButton
label="⛔ Block all"
title="Block all pending agents"
disabled={busy !== null}
onClick={() => runAction("block_all", undefined, "Block all pending agents?")}
/>
<ActionButton
label="🗑"
title="Remove all"
<BulkActionButton
label="🗑 Remove all"
title="Remove all pending agents"
disabled={busy !== null}
onClick={() => runAction("remove_all", undefined, "Remove all pending agents and their data?")}
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>
{visibleAcceptedRows.map((row) => (
<AgentTableRow key={row.agent.agent_id} row={row} busy={busy} runAction={runAction} />
))}
</tbody>
</table>
);
}
function AgentTableRow({
row,
busy,
runAction,
}: {
row: AgentRow;
busy: string | null;
runAction: (
action: string,
agentId?: string,
confirmText?: string,
includeRotation?: boolean,
) => void;
}) {
const { agent: a, checks } = row;
return (
<tr 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>
);
}
function AgentActions({
agent,
busy,
@@ -313,6 +342,31 @@ function AgentActions({
);
}
function BulkActionButton({
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-700 bg-neutral-950 px-3 py-1.5 text-sm text-neutral-100 hover:border-neutral-400 disabled:opacity-40"
>
{label}
</button>
);
}
function ActionButton({
label,
title,

View File

@@ -1,32 +1,24 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useCallback, useMemo } from "react";
import { AgentLink } from "@/components/AgentLink";
import { CheckStatusSummary } from "@/components/CheckStatusSummary";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, SummaryCell, Td, Th } from "@/components/DataPanel";
import { DetailTabs } from "@/components/DetailTabs";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { Pager } from "@/components/Pager";
import { InfiniteListFooter } from "@/components/InfiniteListFooter";
import { DateTime } from "@/components/TimeZoneControls";
import type { components } from "@/lib/api-types";
import { groupChecks, statusSeverity, type Agent, type CheckGroup } from "@/lib/check-groups";
import { statusBadgeClass } from "@/lib/format";
import {
DEFAULT_PAGE_SIZE,
clampPage,
pageCountOf,
paginate,
type PageSize,
} from "@/lib/pagination";
import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
import { useInfiniteClientList, useInfiniteCursorList } from "@/lib/useInfiniteList";
import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types";
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null };
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null };
type TabKey = "agents" | "events";
export function CheckDetailBrowser({
@@ -34,19 +26,11 @@ export function CheckDetailBrowser({
initialData,
initialEvents,
tab,
eventsLimit,
eventsCursor,
eventsBack,
eventsPage,
}: {
checkId: string;
initialData: InventoryData;
initialEvents: EventsPageData;
tab: TabKey;
eventsLimit: PageSize;
eventsCursor?: string;
eventsBack: boolean;
eventsPage: number;
}) {
const { data } = usePolling(initialData, "/api/poll/inventory");
const agentsById = useMemo(
@@ -101,7 +85,7 @@ export function CheckDetailBrowser({
{
key: "events",
label: "Events",
href: `/checks/${encodeURIComponent(checkId)}?tab=events&events_limit=${eventsLimit}`,
href: `/checks/${encodeURIComponent(checkId)}?tab=events`,
},
]}
/>
@@ -112,10 +96,6 @@ export function CheckDetailBrowser({
checkId={checkId}
initialData={initialEvents}
agentsById={agentsById}
eventsLimit={eventsLimit}
eventsCursor={eventsCursor}
eventsBack={eventsBack}
eventsPage={eventsPage}
/>
)}
</div>
@@ -133,21 +113,18 @@ function AgentsForCheck({ group }: { group: CheckGroup }) {
[group.checks],
);
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
};
const pageCount = pageCountOf(rows.length, pageSize);
const currentPage = clampPage(page, pageCount);
const visible = paginate(rows, currentPage, pageSize);
const {
visibleItems: visible,
hasMore,
sentinelRef,
} = useInfiniteClientList({
items: rows,
batchSize: INFINITE_BATCH_SIZE,
resetKey: group.checkId,
});
return (
<section>
<div className="mb-3 flex justify-end">
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
</div>
<table className="w-full text-sm">
<thead>
<tr>
@@ -172,7 +149,7 @@ function AgentsForCheck({ group }: { group: CheckGroup }) {
))}
</tbody>
</table>
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
<InfiniteListFooter sentinelRef={sentinelRef} hasMore={hasMore} />
</section>
);
}
@@ -181,43 +158,41 @@ function EventsForCheck({
checkId,
initialData,
agentsById,
eventsLimit,
eventsCursor,
eventsBack,
eventsPage,
}: {
checkId: string;
initialData: EventsPageData;
agentsById: Map<string, Agent>;
eventsLimit: PageSize;
eventsCursor?: string;
eventsBack: boolean;
eventsPage: number;
}) {
const router = useRouter();
const pollUrl = useMemo(() => {
const qs = new URLSearchParams({
check_id: checkId,
limit: String(eventsLimit),
limit: String(INFINITE_BATCH_SIZE),
});
if (eventsCursor) qs.set("cursor", eventsCursor);
if (eventsBack) qs.set("back", "1");
return `/api/poll/events?${qs.toString()}`;
}, [checkId, eventsCursor, eventsBack, eventsLimit]);
const { data } = usePolling(initialData, pollUrl);
const onLimitChange = (next: PageSize) => {
router.push(
`/checks/${encodeURIComponent(checkId)}?tab=events&events_limit=${next}`,
);
};
}, [checkId]);
const getPageUrl = useCallback(
(cursor: string) => `${pollUrl}&cursor=${encodeURIComponent(cursor)}`,
[pollUrl],
);
const {
items,
hasMore,
loadingMore,
error,
sentinelRef,
hasNewItems,
showLatest,
} = useInfiniteCursorList<StoredEvent>({
initialData,
firstPageUrl: pollUrl,
getPageUrl,
getKey: eventKey,
resetKey: checkId,
});
return (
<section>
<div className="mb-3 flex justify-end">
<PageSizeSelect value={eventsLimit} onChange={onLimitChange} />
</div>
{data.items.length === 0 ? (
{items.length === 0 ? (
<EmptyPanel label="No events" />
) : (
<table className="w-full text-sm">
@@ -232,7 +207,7 @@ function EventsForCheck({
</tr>
</thead>
<tbody>
{data.items.map((event) => {
{items.map((event) => {
const agent = agentsById.get(event.agent_id);
return (
<tr key={event.event_id}>
@@ -254,12 +229,13 @@ function EventsForCheck({
</tbody>
</table>
)}
<Pager
basePath={`/checks/${encodeURIComponent(checkId)}`}
prevCursor={data.prev_cursor}
nextCursor={data.next_cursor}
page={eventsPage}
extra={{ tab: "events", events_limit: String(eventsLimit) }}
<InfiniteListFooter
sentinelRef={sentinelRef}
hasMore={hasMore}
loading={loadingMore}
error={error}
hasNewItems={hasNewItems}
onShowLatest={showLatest}
/>
</section>
);
@@ -268,3 +244,7 @@ function EventsForCheck({
function hostName(row: { agent?: Agent; check: { agent_id: string } }): string {
return row.agent?.hostname || row.check.agent_id;
}
function eventKey(event: StoredEvent): string {
return event.event_id;
}

View File

@@ -4,8 +4,8 @@ import Link from "next/link";
import { useMemo, useState } from "react";
import { CheckStatusSummary } from "@/components/CheckStatusSummary";
import { ClientPager } from "@/components/ClientPager";
import { EmptyPanel, Td, Th } from "@/components/DataPanel";
import { InfiniteListFooter } from "@/components/InfiniteListFooter";
import { ListToolbar } from "@/components/ListToolbar";
import { SortHeaderButton, type SortDir } from "@/components/SortHeaderButton";
import { DateTime } from "@/components/TimeZoneControls";
@@ -14,13 +14,8 @@ import {
groupChecks,
type CheckGroup,
} from "@/lib/check-groups";
import {
DEFAULT_PAGE_SIZE,
clampPage,
pageCountOf,
paginate,
type PageSize,
} from "@/lib/pagination";
import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
import { useInfiniteClientList } from "@/lib/useInfiniteList";
import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types";
@@ -39,19 +34,18 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
[dir, groups, query, sort],
);
const [pageSize, setPageSize] = useState<PageSize>(DEFAULT_PAGE_SIZE);
const [page, setPage] = useState(1);
const pageCount = pageCountOf(rows.length, pageSize);
const currentPage = clampPage(page, pageCount);
const visible = paginate(rows, currentPage, pageSize);
const {
visibleItems: visible,
hasMore,
sentinelRef,
} = useInfiniteClientList({
items: rows,
batchSize: INFINITE_BATCH_SIZE,
resetKey: `${query}:${sort}:${dir}`,
});
const onQueryChange = (value: string) => {
setQuery(value);
setPage(1);
};
const onPageSizeChange = (next: PageSize) => {
setPageSize(next);
setPage(1);
};
const chooseSort = (field: SortField) => {
if (field === sort) {
@@ -60,7 +54,6 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
setSort(field);
setDir(fieldDefaultDir(field));
}
setPage(1);
};
return (
@@ -75,8 +68,6 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
query={query}
onQueryChange={onQueryChange}
placeholder="search check"
pageSize={pageSize}
onPageSizeChange={onPageSizeChange}
/>
{rows.length === 0 ? (
<EmptyPanel />
@@ -125,7 +116,7 @@ export function ChecksBrowser({ initialData }: { initialData: InventoryData }) {
</tbody>
</table>
)}
<ClientPager page={currentPage} pageCount={pageCount} onPageChange={setPage} />
<InfiniteListFooter sentinelRef={sentinelRef} hasMore={hasMore} />
</>
);
}

View File

@@ -1,39 +0,0 @@
"use client";
export function ClientPager({
page,
pageCount,
onPageChange,
}: {
page: number;
pageCount: number;
onPageChange: (page: number) => void;
}) {
const prev = () => onPageChange(Math.max(1, page - 1));
const next = () => onPageChange(Math.min(pageCount, page + 1));
const disabledPrev = page <= 1;
const disabledNext = page >= pageCount;
return (
<div className="mt-4 flex items-center justify-between text-sm">
<button
type="button"
onClick={prev}
disabled={disabledPrev}
className="text-blue-300 hover:text-blue-200 disabled:cursor-default disabled:text-neutral-600"
>
prev
</button>
<span className="text-neutral-500">
page {page} of {pageCount}
</span>
<button
type="button"
onClick={next}
disabled={disabledNext}
className="text-blue-300 hover:text-blue-200 disabled:cursor-default disabled:text-neutral-600"
>
next
</button>
</div>
);
}

View File

@@ -1,58 +1,60 @@
"use client";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { Pager } from "@/components/Pager";
import { InfiniteListFooter } from "@/components/InfiniteListFooter";
import { DateTime } from "@/components/TimeZoneControls";
import type { components } from "@/lib/api-types";
import { statusBadgeClass } from "@/lib/format";
import { type PageSize } from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
import { useInfiniteCursorList } from "@/lib/useInfiniteList";
type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
type EventsPageData = { items: StoredEvent[]; next_cursor?: string | null; prev_cursor?: string | null };
type EventsPageData = { items: StoredEvent[]; next_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, back, agentId, checkId, limit }),
[agentId, back, checkId, cursor, limit],
const firstPageUrl = useMemo(
() => eventsPollUrl({ agentId, checkId }),
[agentId, checkId],
);
const { data } = usePolling(initialData, url);
const onLimitChange = (next: PageSize) => {
router.push(eventsHref({ agent_id: agentId, check_id: checkId, limit: next }));
};
const getPageUrl = useCallback(
(cursor: string) => `${firstPageUrl}&cursor=${encodeURIComponent(cursor)}`,
[firstPageUrl],
);
const {
items,
hasMore,
loadingMore,
error,
sentinelRef,
hasNewItems,
showLatest,
} = useInfiniteCursorList<StoredEvent>({
initialData,
firstPageUrl,
getPageUrl,
getKey: eventKey,
resetKey: `${agentId ?? ""}:${checkId ?? ""}`,
});
return (
<>
<PageHeader title="Events" count={data.items.length} />
<div className="mb-4 flex items-center justify-between gap-3">
<ActiveFilters agentId={agentId} checkId={checkId} limit={limit} />
<PageSizeSelect value={limit} onChange={onLimitChange} />
<PageHeader title="Events" count={items.length} />
<div className="mb-4">
<ActiveFilters agentId={agentId} checkId={checkId} />
</div>
{data.items.length === 0 ? (
{items.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
@@ -68,7 +70,7 @@ export function EventsBrowser({
</tr>
</thead>
<tbody>
{data.items.map((e) => (
{items.map((e) => (
<tr key={e.event_id}>
<Td>
<DateTime iso={e.observed_at} />
@@ -88,12 +90,13 @@ export function EventsBrowser({
</tbody>
</table>
)}
<Pager
basePath="/events"
prevCursor={data.prev_cursor}
nextCursor={data.next_cursor}
page={page}
extra={{ agent_id: agentId, check_id: checkId, limit: String(limit) }}
<InfiniteListFooter
sentinelRef={sentinelRef}
hasMore={hasMore}
loading={loadingMore}
error={error}
hasNewItems={hasNewItems}
onShowLatest={showLatest}
/>
</>
);
@@ -102,11 +105,9 @@ export function EventsBrowser({
function ActiveFilters({
agentId,
checkId,
limit,
}: {
agentId?: string;
checkId?: string;
limit: PageSize;
}) {
if (!agentId && !checkId) return <div />;
@@ -116,18 +117,18 @@ function ActiveFilters({
<FilterChip
label="agent_id"
value={agentId}
href={eventsHref({ check_id: checkId, limit })}
href={eventsHref({ check_id: checkId })}
/>
) : null}
{checkId ? (
<FilterChip
label="check_id"
value={checkId}
href={eventsHref({ agent_id: agentId, limit })}
href={eventsHref({ agent_id: agentId })}
/>
) : null}
<Link
href={eventsHref({ limit })}
href={eventsHref({})}
className="text-neutral-500 hover:text-neutral-300"
>
clear
@@ -153,34 +154,28 @@ function FilterChip({ label, value, href }: { label: string; value: string; href
function eventsHref(params: {
agent_id?: string;
check_id?: string;
limit?: PageSize;
}): string {
const qs = new URLSearchParams();
if (params.agent_id) qs.set("agent_id", params.agent_id);
if (params.check_id) qs.set("check_id", params.check_id);
if (params.limit) qs.set("limit", String(params.limit));
const query = qs.toString();
return query ? `/events?${query}` : "/events";
}
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));
qs.set("limit", String(INFINITE_BATCH_SIZE));
return `/api/poll/events?${qs.toString()}`;
}
function eventKey(event: StoredEvent): string {
return event.event_id;
}

View File

@@ -2,18 +2,18 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { AgentLink } from "@/components/AgentLink";
import { CheckEventsLink } from "@/components/CheckEventsLink";
import { EmptyPanel, PageHeader, SummaryCell, Td, Th } from "@/components/DataPanel";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { Pager } from "@/components/Pager";
import { InfiniteListFooter } from "@/components/InfiniteListFooter";
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 { type PageSize } from "@/lib/pagination";
import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
import { useInfiniteCursorList } from "@/lib/useInfiniteList";
import { usePolling } from "@/lib/usePolling";
import type { InventoryData } from "@/lib/view-types";
@@ -21,51 +21,56 @@ const STATES = ["all", "open", "resolved"] as const;
type Incident = components["schemas"]["Incident"];
type Agent = components["schemas"]["Agent"];
type IncidentsData = { items: Incident[]; next_cursor?: string | null; prev_cursor?: string | null };
type IncidentsData = { items: Incident[]; next_cursor?: string | null };
type SortField = "status" | "opened_at" | "resolved_at";
export function IncidentsBrowser({
initialData,
initialInventory,
state,
cursor,
back,
sort,
direction,
limit,
page,
}: {
initialData: IncidentsData;
initialInventory: InventoryData;
state?: "open" | "resolved";
cursor?: string;
back: boolean;
sort: SortField;
direction: SortDir;
limit: PageSize;
page: number;
}) {
const router = useRouter();
const url = useMemo(() => {
const firstPageUrl = 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");
q.set("limit", String(INFINITE_BATCH_SIZE));
return `/api/poll/incidents?${q.toString()}`;
}, [cursor, back, direction, limit, state, sort]);
const { data } = usePolling<IncidentsData>(initialData, url);
}, [direction, state, sort]);
const getPageUrl = useCallback(
(cursor: string) => `${firstPageUrl}&cursor=${encodeURIComponent(cursor)}`,
[firstPageUrl],
);
const {
items,
hasMore,
loadingMore,
error,
sentinelRef,
hasNewItems,
showLatest,
} = useInfiniteCursorList<Incident>({
initialData,
firstPageUrl,
getPageUrl,
getKey: incidentKey,
resetKey: `${state ?? "all"}:${sort}:${direction}`,
});
const { data: inventory } = usePolling(initialInventory, "/api/poll/inventory");
const agentsById = useMemo(
() => new Map(inventory.agents.map((agent) => [agent.agent_id, agent])),
[inventory.agents],
);
const onPageSizeChange = (next: PageSize) => {
router.push(incidentsHref({ state, sort, direction, limit: next }));
};
const chooseSort = (field: SortField) => {
let nextSort = sort;
let nextDirection = direction;
@@ -75,21 +80,20 @@ export function IncidentsBrowser({
nextSort = field;
nextDirection = fieldDefaultDir();
}
router.push(incidentsHref({ state, sort: nextSort, direction: nextDirection, limit }));
router.push(incidentsHref({ state, sort: nextSort, direction: nextDirection }));
};
return (
<>
<PageHeader title="Incidents" count={data.items.length} />
<PageHeader title="Incidents" count={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 = incidentsHref({
state: s === "all" ? undefined : s,
sort: sort === "resolved_at" && s !== "resolved" ? "status" : sort,
direction,
limit,
});
const href = incidentsHref({
state: s === "all" ? undefined : s,
sort: sort === "resolved_at" && s !== "resolved" ? "status" : sort,
direction,
});
const active = (s === "all" && !state) || s === state;
return (
<Link
@@ -102,9 +106,8 @@ export function IncidentsBrowser({
);
})}
</div>
<PageSizeSelect value={limit} onChange={onPageSizeChange} />
</div>
{data.items.length === 0 ? (
{items.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
@@ -150,7 +153,7 @@ export function IncidentsBrowser({
</tr>
</thead>
<tbody>
{data.items.map((incident) => (
{items.map((incident) => (
<IncidentRow
key={incident.id}
incident={incident}
@@ -160,12 +163,13 @@ export function IncidentsBrowser({
</tbody>
</table>
)}
<Pager
basePath="/incidents"
prevCursor={data.prev_cursor}
nextCursor={data.next_cursor}
page={page}
extra={{ state, sort, direction, limit: String(limit) }}
<InfiniteListFooter
sentinelRef={sentinelRef}
hasMore={hasMore}
loading={loadingMore}
error={error}
hasNewItems={hasNewItems}
onShowLatest={showLatest}
/>
</>
);
@@ -220,6 +224,10 @@ function incidentCheckId(incident: Incident): string {
return incident.check_id ?? "";
}
function incidentKey(incident: Incident): string {
return String(incident.id);
}
function fieldDefaultDir(): SortDir {
return "desc";
}
@@ -228,18 +236,15 @@ function incidentsHref({
state,
sort,
direction,
limit,
}: {
state?: "open" | "resolved";
sort: SortField;
direction: SortDir;
limit: PageSize;
}) {
const qs = new URLSearchParams();
if (state) qs.set("state", state);
qs.set("sort", sort);
qs.set("direction", direction);
qs.set("limit", String(limit));
const query = qs.toString();
return query ? `/incidents?${query}` : "/incidents";
}

View File

@@ -0,0 +1,40 @@
"use client";
import type { RefObject } from "react";
export function InfiniteListFooter({
sentinelRef,
hasMore,
loading,
error,
hasNewItems,
onShowLatest,
}: {
sentinelRef: RefObject<HTMLDivElement | null>;
hasMore: boolean;
loading?: boolean;
error?: string | null;
hasNewItems?: boolean;
onShowLatest?: () => void;
}) {
if (!hasMore && !loading && !error && !hasNewItems) return null;
return (
<>
{hasNewItems && onShowLatest ? (
<button
type="button"
onClick={onShowLatest}
className="fixed bottom-4 right-4 z-40 border border-emerald-800 bg-neutral-950 px-3 py-2 text-xs text-emerald-200 shadow-lg hover:border-emerald-500"
>
show latest
</button>
) : null}
<div ref={sentinelRef} className="mt-4 min-h-10 py-2 text-center text-xs text-neutral-500">
{error ? <span className="text-red-300">{error}</span> : null}
{!error && loading ? "loading..." : null}
{!error && !loading && hasMore ? "scroll for more" : null}
</div>
</>
);
}

View File

@@ -1,20 +1,13 @@
"use client";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { type PageSize } from "@/lib/pagination";
export function ListToolbar({
query,
onQueryChange,
placeholder,
pageSize,
onPageSizeChange,
}: {
query: string;
onQueryChange: (value: string) => void;
placeholder: string;
pageSize: PageSize;
onPageSizeChange: (next: PageSize) => void;
}) {
return (
<div className="mb-4 flex items-center justify-between gap-3">
@@ -36,7 +29,6 @@ export function ListToolbar({
</button>
) : null}
</div>
<PageSizeSelect value={pageSize} onChange={onPageSizeChange} />
</div>
);
}

View File

@@ -1,66 +1,67 @@
"use client";
import { useRouter } from "next/navigation";
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import { EmptyPanel, PageHeader, Td, Th } from "@/components/DataPanel";
import { PageSizeSelect } from "@/components/PageSizeSelect";
import { Pager } from "@/components/Pager";
import { InfiniteListFooter } from "@/components/InfiniteListFooter";
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 { type PageSize } from "@/lib/pagination";
import { usePolling } from "@/lib/usePolling";
import { INFINITE_BATCH_SIZE } from "@/lib/pagination";
import { useInfiniteCursorList } from "@/lib/useInfiniteList";
type OutboxItem = components["schemas"]["NotificationOutboxItem"];
type OutboxData = { items: OutboxItem[]; next_cursor?: string | null; prev_cursor?: string | null };
type OutboxData = { items: OutboxItem[]; next_cursor?: string | null };
type SortField = "created_at" | "updated_at";
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 firstPageUrl = 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");
q.set("limit", String(INFINITE_BATCH_SIZE));
return `/api/poll/outbox?${q.toString()}`;
}, [cursor, back, direction, limit, sort]);
const { data } = usePolling<OutboxData>(initialData, url);
}, [direction, sort]);
const getPageUrl = useCallback(
(cursor: string) => `${firstPageUrl}&cursor=${encodeURIComponent(cursor)}`,
[firstPageUrl],
);
const {
items,
hasMore,
loadingMore,
error,
sentinelRef,
hasNewItems,
showLatest,
} = useInfiniteCursorList<OutboxItem>({
initialData,
firstPageUrl,
getPageUrl,
getKey: outboxKey,
resetKey: `${sort}:${direction}`,
});
const onPageSizeChange = (next: PageSize) => {
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 }));
router.push(outboxHref({ sort: field, direction: nextDirection }));
};
return (
<>
<PageHeader title="Notification outbox" count={data.items.length} />
<div className="mb-3 flex justify-end">
<PageSizeSelect value={limit} onChange={onPageSizeChange} />
</div>
{data.items.length === 0 ? (
<PageHeader title="Notification outbox" count={items.length} />
{items.length === 0 ? (
<EmptyPanel />
) : (
<table className="w-full text-sm">
@@ -80,7 +81,7 @@ export function OutboxBrowser({
</tr>
</thead>
<tbody>
{data.items.map((o) => (
{items.map((o) => (
<tr key={o.id}>
<Td>
<DateTime iso={o.updated_at} />
@@ -98,12 +99,13 @@ export function OutboxBrowser({
</tbody>
</table>
)}
<Pager
basePath="/outbox"
prevCursor={data.prev_cursor}
nextCursor={data.next_cursor}
page={page}
extra={{ sort, direction, limit: String(limit) }}
<InfiniteListFooter
sentinelRef={sentinelRef}
hasMore={hasMore}
loading={loadingMore}
error={error}
hasNewItems={hasNewItems}
onShowLatest={showLatest}
/>
</>
);
@@ -112,15 +114,16 @@ export function OutboxBrowser({
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()}`;
}
function outboxKey(item: OutboxItem): string {
return String(item.id);
}

View File

@@ -1,30 +0,0 @@
"use client";
import { PAGE_SIZE_OPTIONS, type PageSize } from "@/lib/pagination";
export function PageSizeSelect({
value,
onChange,
options = PAGE_SIZE_OPTIONS,
}: {
value: PageSize;
onChange: (size: PageSize) => void;
options?: readonly number[];
}) {
return (
<label className="inline-flex items-center gap-2 text-xs text-neutral-400">
<span>per page</span>
<select
value={value}
onChange={(e) => onChange(Number(e.target.value) as PageSize)}
className="border border-neutral-800 bg-neutral-950 px-2 py-1 text-sm text-neutral-100 outline-none focus:border-neutral-500"
>
{options.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
);
}

View File

@@ -1,61 +0,0 @@
import Link from "next/link";
export function Pager({
basePath,
prevCursor,
nextCursor,
page = 1,
extra = {},
}: {
basePath: string;
prevCursor?: string | null;
nextCursor?: string | null;
page?: number;
extra?: Record<string, string | undefined>;
}) {
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 (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">
{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>
) : (
<span className="text-neutral-600">end</span>
)}
</div>
);
}

View File

@@ -1,10 +1,15 @@
"use client";
import { createContext, useContext, useMemo, useState } from "react";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { DEFAULT_TIME_ZONE, fmtDate, normalizeTimeZone } from "@/lib/format";
type TimeZoneMode = "configured" | "browser" | "utc";
import {
normalizeTimeZoneMode,
TZ_BROWSER_COOKIE,
TZ_COOKIE_MAX_AGE_SEC,
TZ_MODE_COOKIE,
type TimeZoneMode,
} from "@/lib/timezone-preference";
const modes: TimeZoneMode[] = ["configured", "browser", "utc"];
@@ -20,14 +25,29 @@ const TimeZoneContext = createContext<TimeZoneContextValue | null>(null);
export function TimeZoneProvider({
configuredTimeZone,
initialMode,
initialBrowserTimeZone,
children,
}: {
configuredTimeZone: string;
initialMode: TimeZoneMode;
initialBrowserTimeZone: string | null;
children: React.ReactNode;
}) {
const configured = normalizeTimeZone(configuredTimeZone);
const [mode, setMode] = useState<TimeZoneMode>("configured");
const [browserTimeZone, setBrowserTimeZone] = useState<string | null>(null);
const [mode, setMode] = useState<TimeZoneMode>(normalizeTimeZoneMode(initialMode));
const [browserTimeZone, setBrowserTimeZone] = useState<string | null>(
initialBrowserTimeZone ? normalizeTimeZone(initialBrowserTimeZone) : null,
);
useEffect(() => {
if (mode !== "browser") return;
const detected = normalizeTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
writeCookie(TZ_BROWSER_COOKIE, detected);
if (detected === browserTimeZone) return;
const id = window.setTimeout(() => setBrowserTimeZone(detected), 0);
return () => window.clearTimeout(id);
}, [browserTimeZone, mode]);
const value = useMemo<TimeZoneContextValue>(() => {
const timeZone =
@@ -43,8 +63,13 @@ export function TimeZoneProvider({
timeZone,
cycleMode: () => {
const next = modes[(modes.indexOf(mode) + 1) % modes.length];
writeCookie(TZ_MODE_COOKIE, next);
if (next === "browser" && browserTimeZone === null) {
setBrowserTimeZone(normalizeTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone));
const detected = normalizeTimeZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
setBrowserTimeZone(detected);
writeCookie(TZ_BROWSER_COOKIE, detected);
} else if (next !== "browser") {
deleteCookie(TZ_BROWSER_COOKIE);
}
setMode(next);
},
@@ -54,6 +79,15 @@ export function TimeZoneProvider({
return <TimeZoneContext.Provider value={value}>{children}</TimeZoneContext.Provider>;
}
function writeCookie(name: string, value: string) {
const secure = window.location.protocol === "https:" ? "; Secure" : "";
document.cookie = `${name}=${encodeURIComponent(value)}; Path=/; SameSite=Lax; Max-Age=${TZ_COOKIE_MAX_AGE_SEC}${secure}`;
}
function deleteCookie(name: string) {
document.cookie = `${name}=; Path=/; SameSite=Lax; Max-Age=0`;
}
export function DateTime({ iso }: { iso?: string | null }) {
const { timeZone } = useDisplayTimeZone();
return <time dateTime={iso ?? undefined}>{fmtDate(iso, timeZone)}</time>;

View File

@@ -18,7 +18,13 @@ const ZERO_SUMMARY: SystemSummaryData = {
generated_at: "",
};
export function TopNav({ initialSummary }: { initialSummary: SystemSummaryData | null }) {
export function TopNav({
initialSummary,
showLogout,
}: {
initialSummary: SystemSummaryData | null;
showLogout: boolean;
}) {
const { data } = usePolling(initialSummary ?? ZERO_SUMMARY, "/api/poll/system-summary");
return (
@@ -50,6 +56,16 @@ export function TopNav({ initialSummary }: { initialSummary: SystemSummaryData |
</Link>
</nav>
<TimeZoneCarousel />
{showLogout ? (
<form action="/api/auth/logout" method="post">
<button
type="submit"
className="rounded border border-neutral-800 bg-neutral-950 px-2.5 py-1.5 text-xs text-neutral-300 hover:border-neutral-600 hover:text-neutral-100"
>
logout
</button>
</form>
) : null}
</header>
);
}

View File

@@ -327,7 +327,7 @@ export interface components {
ErrorResponse: {
error: {
/** @enum {string} */
code: "unauthorized" | "forbidden" | "conflict" | "agent_blocked" | "not_found" | "validation" | "payload_too_large" | "rate_limited" | "internal" | "not_ready";
code: "unauthorized" | "forbidden" | "conflict" | "agent_blocked" | "agent_not_accepted" | "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;
@@ -704,7 +704,7 @@ export interface operations {
};
400: components["responses"]["ValidationError"];
401: components["responses"]["Unauthorized"];
/** @description Agent key is blocked. */
/** @description Agent key is blocked (`agent_blocked`) or not yet accepted (`agent_not_accepted`). */
403: {
headers: {
[name: string]: unknown;

View File

@@ -69,48 +69,30 @@ 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; prev_cursor?: string | null }> {
return api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, back, limit });
} = {}): Promise<{ items: StoredEvent[]; next_cursor?: string | null }> {
return api.queryEvents({ agent_id: agentId, check_id: checkId, cursor, limit });
}
// 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 }> {
): Promise<{ items: Incident[]; next_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 }> {
): Promise<{ items: OutboxItem[]; next_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 page = await api.listOutbox({ limit: 100 });
return page.items;
}
export async function loadSystemSummary(): Promise<SystemSummaryData> {
return api.getSystemSummary();
}

View File

@@ -1,32 +1,6 @@
export const PAGE_SIZE_OPTIONS = [10, 50, 100] as const;
export type PageSize = (typeof PAGE_SIZE_OPTIONS)[number];
export const DEFAULT_PAGE_SIZE: PageSize = 10;
export const INFINITE_BATCH_SIZE = 50;
export function pageCountOf(total: number, pageSize: number): number {
return Math.max(1, Math.ceil(total / pageSize));
}
export function clampPage(page: number, pageCount: number): number {
if (page < 1) return 1;
if (page > pageCount) return pageCount;
return page;
}
export function paginate<T>(items: T[], page: number, pageSize: number): T[] {
const start = (page - 1) * pageSize;
return items.slice(start, start + pageSize);
}
export function normalizePageSize(value: unknown, fallback: PageSize = DEFAULT_PAGE_SIZE): PageSize {
export function normalizeFetchLimit(value: unknown, fallback = INFINITE_BATCH_SIZE): number {
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";
return Number.isFinite(n) ? Math.min(Math.max(Math.floor(n), 1), 500) : fallback;
}

View File

@@ -0,0 +1,5 @@
export function redirectToLogin() {
const next = `${window.location.pathname}${window.location.search}`;
const params = new URLSearchParams({ next });
window.location.assign(`/login?${params.toString()}`);
}

View File

@@ -0,0 +1,9 @@
export type TimeZoneMode = "configured" | "browser" | "utc";
export const TZ_MODE_COOKIE = "monlet_tz_mode";
export const TZ_BROWSER_COOKIE = "monlet_tz_browser";
export const TZ_COOKIE_MAX_AGE_SEC = 31_536_000;
export function normalizeTimeZoneMode(value?: string | null): TimeZoneMode {
return value === "browser" || value === "utc" ? value : "configured";
}

View File

@@ -1,7 +1,32 @@
import "server-only";
import { cookies } from "next/headers";
import { DEFAULT_TIME_ZONE, normalizeTimeZone } from "@/lib/format";
import {
normalizeTimeZoneMode,
TZ_BROWSER_COOKIE,
TZ_MODE_COOKIE,
type TimeZoneMode,
} from "@/lib/timezone-preference";
export function getUiTimeZone(): string {
return normalizeTimeZone(process.env.MONLET_WEB_TIME_ZONE ?? process.env.TZ ?? DEFAULT_TIME_ZONE);
}
export async function getInitialTimeZonePreference(): Promise<{
mode: TimeZoneMode;
browserTimeZone: string | null;
}> {
const store = await cookies();
const mode = normalizeTimeZoneMode(store.get(TZ_MODE_COOKIE)?.value);
const browserRaw = store.get(TZ_BROWSER_COOKIE)?.value;
const browserTimeZone = browserRaw ? normalizeTimeZone(browserRaw) : null;
return {
mode,
browserTimeZone:
browserTimeZone === DEFAULT_TIME_ZONE && browserRaw !== DEFAULT_TIME_ZONE
? null
: browserTimeZone,
};
}

View File

@@ -0,0 +1,235 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { redirectToLogin } from "@/lib/redirect-to-login";
import { usePolling } from "@/lib/usePolling";
export type CursorPage<T> = {
items: T[];
next_cursor?: string | null;
};
export function useInfiniteClientList<T>({
items,
batchSize,
resetKey,
}: {
items: T[];
batchSize: number;
resetKey: string;
}) {
const stateKey = `${resetKey}:${batchSize}`;
const [state, setState] = useState({ key: stateKey, visibleCount: batchSize });
const visibleCount = state.key === stateKey ? state.visibleCount : batchSize;
const hasMore = visibleCount < items.length;
const loadMore = useCallback(() => {
setState((current) => {
const currentCount = current.key === stateKey ? current.visibleCount : batchSize;
return {
key: stateKey,
visibleCount: Math.min(currentCount + batchSize, items.length),
};
});
}, [batchSize, items.length, stateKey]);
const sentinelRef = useInfiniteSentinel(hasMore, loadMore);
return {
visibleItems: items.slice(0, visibleCount),
hasMore,
sentinelRef,
shown: Math.min(visibleCount, items.length),
total: items.length,
};
}
export function useInfiniteCursorList<T>({
initialData,
firstPageUrl,
getPageUrl,
getKey,
resetKey,
}: {
initialData: CursorPage<T>;
firstPageUrl: string;
getPageUrl: (cursor: string) => string;
getKey: (item: T) => string;
resetKey: string;
}) {
const { data: firstPage, error, isStale, lastUpdatedAt } = usePolling(initialData, firstPageUrl);
const [windowState, setWindowState] = useState(() =>
initialWindowState(resetKey, initialData, getKey),
);
const [loadingMore, setLoadingMore] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const loadingMoreRef = useRef(false);
const abortRef = useRef<AbortController | null>(null);
const generationRef = useRef(0);
const nearTop = useNearTop();
const firstPageSignature = pageSignature(firstPage, getKey);
const activeWindow =
windowState.key === resetKey ? windowState : initialWindowState(resetKey, initialData, getKey);
// Persist the head rebase into state (single source of truth) so an in-flight
// loadMore cannot glue a stale tail onto an outdated base and revert a fresh
// head. Skip while a tail load is in flight: that request extends the current
// base and must resolve against it.
useEffect(() => {
if (loadingMoreRef.current) return;
setWindowState((current) => {
const w =
current.key === resetKey ? current : initialWindowState(resetKey, initialData, getKey);
if (w.tailItems.length === 0 && w.baseSignature !== firstPageSignature && nearTop) {
return initialWindowState(resetKey, firstPage, getKey);
}
return w;
});
}, [firstPageSignature, nearTop, resetKey, firstPage, initialData, getKey]);
const hasNewHead = activeWindow.baseSignature !== firstPageSignature;
const baseItems = activeWindow.baseItems;
const tailItems = activeWindow.tailItems;
const items = appendUnique(baseItems, tailItems, getKey);
const nextCursor = activeWindow.nextCursor;
const showLatest = useCallback(() => {
generationRef.current += 1;
abortRef.current?.abort();
abortRef.current = null;
loadingMoreRef.current = false;
setLoadingMore(false);
setLoadError(null);
setWindowState(initialWindowState(resetKey, firstPage, getKey));
if (typeof window !== "undefined") window.scrollTo({ top: 0, behavior: "smooth" });
}, [firstPage, getKey, resetKey]);
const loadMore = useCallback(() => {
if (!nextCursor || loadingMoreRef.current) return;
loadingMoreRef.current = true;
setLoadingMore(true);
setLoadError(null);
const controller = new AbortController();
abortRef.current = controller;
const generation = generationRef.current;
const requestBaseSignature = activeWindow.baseSignature;
fetch(getPageUrl(nextCursor), { cache: "no-store", signal: controller.signal })
.then(async (response) => {
if (response.status === 401) {
redirectToLogin();
throw new Error("authentication required");
}
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.error?.message ?? response.statusText);
}
return response.json() as Promise<CursorPage<T>>;
})
.then((page) => {
if (generation !== generationRef.current) return;
setWindowState((current) => {
if (current.key !== resetKey) return current;
// The cursor was computed against requestBaseSignature. If a poll
// rebased the base in state meanwhile, gluing this tail onto a
// different base would skip the rows between the new head and the old
// cursor. Drop the response instead; the user reloads via showLatest.
if (current.baseSignature !== requestBaseSignature) return current;
return {
key: resetKey,
baseItems: current.baseItems,
baseSignature: current.baseSignature,
tailItems: appendUnique(current.tailItems, page.items, getKey),
nextCursor: page.next_cursor ?? null,
};
});
})
.catch((e: unknown) => {
if (generation === generationRef.current && !controller.signal.aborted) {
setLoadError(e instanceof Error ? e.message : String(e));
}
})
.finally(() => {
if (generation !== generationRef.current) return;
if (abortRef.current === controller) abortRef.current = null;
loadingMoreRef.current = false;
setLoadingMore(false);
});
}, [activeWindow, getKey, getPageUrl, nextCursor, resetKey]);
useEffect(() => () => abortRef.current?.abort(), []);
const sentinelRef = useInfiniteSentinel(Boolean(nextCursor), loadMore);
return {
items,
hasMore: Boolean(nextCursor),
loadingMore,
error: loadError ?? error,
isStale,
lastUpdatedAt,
sentinelRef,
hasNewItems: hasNewHead,
showLatest,
};
}
function useInfiniteSentinel(enabled: boolean, onIntersect: () => void) {
const ref = useRef<HTMLDivElement | null>(null);
const callbackRef = useRef(onIntersect);
useEffect(() => {
callbackRef.current = onIntersect;
}, [onIntersect]);
useEffect(() => {
const node = ref.current;
if (!enabled || !node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) callbackRef.current();
},
{ rootMargin: "360px 0px" },
);
observer.observe(node);
return () => observer.disconnect();
}, [enabled]);
return ref;
}
function appendUnique<T>(current: T[], next: T[], getKey: (item: T) => string): T[] {
const keys = new Set(current.map(getKey));
return [...current, ...next.filter((item) => !keys.has(getKey(item)))];
}
function initialWindowState<T>(key: string, page: CursorPage<T>, getKey: (item: T) => string) {
return {
key,
baseItems: page.items,
baseSignature: pageSignature(page, getKey),
tailItems: [] as T[],
nextCursor: page.next_cursor ?? null,
};
}
function pageSignature<T>(page: CursorPage<T>, getKey: (item: T) => string): string {
return `${page.next_cursor ?? ""}:${page.items.map(getKey).join("\u0000")}`;
}
function useNearTop() {
const [nearTop, setNearTop] = useState(true);
useEffect(() => {
const update = () => setNearTop(window.scrollY < 120);
const frame = window.requestAnimationFrame(update);
window.addEventListener("scroll", update, { passive: true });
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("scroll", update);
};
}, []);
return nearTop;
}

View File

@@ -2,6 +2,8 @@
import { useEffect, useState } from "react";
import { redirectToLogin } from "@/lib/redirect-to-login";
const DEFAULT_POLL_MS = 10_000;
const BACKOFF_AFTER_ERRORS = 2;
const BACKOFF_CAP_MS = 60_000;
@@ -53,6 +55,10 @@ export function usePolling<T>(initialData: T, url: string, intervalMs = pollInte
fetch(url, { cache: "no-store", signal: controller.signal })
.then(async (response) => {
if (response.status === 401) {
redirectToLogin();
throw new Error("authentication required");
}
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(body?.error?.message ?? response.statusText);

View File

@@ -8,13 +8,4 @@ export type InventoryData = {
checks: CheckState[];
};
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;
};
export type SystemSummaryData = components["schemas"]["SystemSummaryResponse"];

View File

@@ -0,0 +1,52 @@
import "server-only";
import { cookies, headers } from "next/headers";
import {
localAuthEnabled,
localAuthMisconfigured,
proxyAuthEnabled,
SESSION_COOKIE,
verifySessionToken,
} from "@/lib/web-auth";
export async function requestHasAppAccess(): Promise<boolean> {
if (localAuthMisconfigured()) return false;
if (proxyAuthEnabled()) return Boolean((await headers()).get("x-forwarded-user"));
if (!localAuthEnabled()) return true;
return verifySessionToken(
(await cookies()).get(SESSION_COOKIE)?.value,
process.env.MONLET_WEB_SESSION_SECRET ?? "",
process.env.MONLET_WEB_AUTH_USERNAME,
);
}
export function showLocalLogout(): boolean {
return localAuthEnabled() && !proxyAuthEnabled() && !localAuthMisconfigured();
}
// CSRF defense for state-changing routes. Prefer the Fetch Metadata signal;
// fall back to Origin/Host comparison for clients that omit Sec-Fetch-Site.
export function assertSameOrigin(req: Request): boolean {
const fetchSite = req.headers.get("sec-fetch-site");
// Strict same-origin: same-site would let a sibling subdomain pass the guard.
if (fetchSite) return fetchSite === "same-origin";
const origin = req.headers.get("origin");
if (!origin) return false;
let originHost: string;
try {
originHost = new URL(origin).host;
} catch {
return false;
}
return originHost === requestHost(req);
}
// Trust X-Forwarded-Host like cookieSecure() trusts X-Forwarded-Proto: the app
// is deployed behind a reverse proxy that terminates the public origin.
function requestHost(req: Request): string {
const forwarded = req.headers.get("x-forwarded-host");
if (forwarded) return forwarded.split(",")[0]!.trim();
return req.headers.get("host") ?? new URL(req.url).host;
}

170
web/src/lib/web-auth.ts Normal file
View File

@@ -0,0 +1,170 @@
export const SESSION_COOKIE = "monlet_session";
export const DEFAULT_SESSION_TTL_SEC = 604_800;
export const MIN_SESSION_SECRET_BYTES = 32;
const LOGIN_FAILURE_DELAY_MS = 350;
type SessionPayload = {
u: string;
exp: number;
};
export function localAuthEnabled(): boolean {
return Boolean(
process.env.MONLET_WEB_AUTH_USERNAME &&
process.env.MONLET_WEB_AUTH_PASSWORD &&
sessionSecretStrong(process.env.MONLET_WEB_SESSION_SECRET),
);
}
export function localAuthMisconfigured(): boolean {
const hasAny = Boolean(
process.env.MONLET_WEB_AUTH_USERNAME ||
process.env.MONLET_WEB_AUTH_PASSWORD ||
process.env.MONLET_WEB_SESSION_SECRET,
);
const enabled = localAuthEnabled();
return (hasAny && !enabled) || (proxyAuthEnabled() && enabled);
}
export function proxyAuthEnabled(): boolean {
return process.env.MONLET_WEB_TRUST_PROXY_AUTH === "true";
}
export function sessionSecretStrong(secret?: string): boolean {
return new TextEncoder().encode(secret ?? "").byteLength >= MIN_SESSION_SECRET_BYTES;
}
export function sessionTtlSec(): number {
const raw = Number(process.env.MONLET_WEB_SESSION_TTL_SEC);
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_SESSION_TTL_SEC;
}
export function sanitizeNext(value?: string | null): string {
const fallback = "/agents";
const raw = value?.trim();
if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.includes("\\")) return fallback;
let decoded: string;
try {
decoded = decodeURIComponent(raw);
} catch {
return fallback;
}
if (
!decoded.startsWith("/") ||
decoded.startsWith("//") ||
decoded.includes("\\") ||
hasControlChar(decoded)
) {
return fallback;
}
let url: URL;
try {
url = new URL(raw, "https://monlet.local");
} catch {
return fallback;
}
if (url.origin !== "https://monlet.local") return fallback;
if (url.pathname === "/login" || url.pathname.startsWith("/api/auth/")) return fallback;
return `${url.pathname}${url.search}${url.hash}`;
}
export function cookieSecure(request: Request): boolean {
const forwarded = request.headers.get("x-forwarded-proto");
return forwarded === "https" || new URL(request.url).protocol === "https:";
}
export async function createSessionToken(username: string, secret: string, ttlSec: number) {
const payload: SessionPayload = {
u: username,
exp: Math.floor(Date.now() / 1000) + ttlSec,
};
const payloadBytes = new TextEncoder().encode(JSON.stringify(payload));
const signature = await sign(payloadBytes, secret);
return `${base64UrlEncode(payloadBytes)}.${base64UrlEncode(signature)}`;
}
export async function constantTimeEqualString(actual: string, expected: string): Promise<boolean> {
const [actualHash, expectedHash] = await Promise.all([sha256(actual), sha256(expected)]);
return equalBytes(actualHash, expectedHash);
}
export async function delayLoginFailure(): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, LOGIN_FAILURE_DELAY_MS));
}
export async function verifySessionToken(
token: string | undefined,
secret: string,
expectedUsername?: string,
): Promise<boolean> {
if (!token) return false;
const [payloadPart, signaturePart] = token.split(".");
if (!payloadPart || !signaturePart) return false;
const payloadBytes = base64UrlDecode(payloadPart);
const signature = base64UrlDecode(signaturePart);
if (!payloadBytes || !signature) return false;
const expected = await sign(payloadBytes, secret);
if (!equalBytes(signature, expected)) return false;
try {
const payload = JSON.parse(new TextDecoder().decode(payloadBytes)) as SessionPayload;
return (
typeof payload.u === "string" &&
(!expectedUsername || payload.u === expectedUsername) &&
payload.exp > Math.floor(Date.now() / 1000)
);
} catch {
return false;
}
}
async function sign(payload: Uint8Array, secret: string): Promise<Uint8Array> {
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
return new Uint8Array(await crypto.subtle.sign("HMAC", key, arrayBufferOf(payload)));
}
async function sha256(value: string): Promise<Uint8Array> {
const bytes = new TextEncoder().encode(value);
return new Uint8Array(await crypto.subtle.digest("SHA-256", arrayBufferOf(bytes)));
}
function arrayBufferOf(bytes: Uint8Array): ArrayBuffer {
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer;
}
function equalBytes(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
function hasControlChar(value: string): boolean {
return /[\u0000-\u001F\u007F]/.test(value);
}
function base64UrlEncode(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replaceAll("+", "-")
.replaceAll("/", "_")
.replaceAll("=", "");
}
function base64UrlDecode(value: string): Uint8Array | null {
try {
const padded = value.replaceAll("-", "+").replaceAll("_", "/").padEnd(
Math.ceil(value.length / 4) * 4,
"=",
);
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
} catch {
return null;
}
}

View File

@@ -1,29 +1,44 @@
import type { NextRequest } from "next/server";
import { NextRequest, NextResponse } from "next/server";
import {
localAuthEnabled,
localAuthMisconfigured,
proxyAuthEnabled,
sanitizeNext,
SESSION_COOKIE,
verifySessionToken,
} from "@/lib/web-auth";
const ACTION_PATH = "/api/agents/action";
const HEALTH_PATH = "/api/health";
const LOGIN_PATH = "/login";
const AUTH_PREFIX = "/api/auth/";
export const config = {
matcher: "/((?!_next/static|_next/image|favicon.ico).*)",
};
export function proxy(request: NextRequest) {
export async function proxy(request: NextRequest) {
const path = request.nextUrl.pathname;
if (path === HEALTH_PATH) return;
if (path === HEALTH_PATH || path === LOGIN_PATH || path.startsWith(AUTH_PREFIX)) 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 (localAuthMisconfigured()) {
return new Response("web auth is misconfigured", { status: 500 });
}
if (process.env.MONLET_WEB_TRUST_PROXY_AUTH === "true") {
if (proxyAuthEnabled()) {
if (request.headers.get("x-forwarded-user")) return;
return new Response("proxy authentication required", { status: 401 });
return unauthorized(request, "proxy authentication required");
}
if (localAuthEnabled()) {
const ok = await verifySessionToken(
request.cookies.get(SESSION_COOKIE)?.value,
process.env.MONLET_WEB_SESSION_SECRET ?? "",
process.env.MONLET_WEB_AUTH_USERNAME,
);
if (ok) return;
return unauthorized(request, "authentication required");
}
if (path === ACTION_PATH) {
@@ -34,15 +49,11 @@ export function proxy(request: NextRequest) {
}
}
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;
function unauthorized(request: NextRequest, message: string) {
if (request.nextUrl.pathname.startsWith("/api/")) {
return Response.json({ error: { code: "unauthorized", message } }, { status: 401 });
}
const login = new URL(LOGIN_PATH, request.url);
login.searchParams.set("next", sanitizeNext(`${request.nextUrl.pathname}${request.nextUrl.search}`));
return NextResponse.redirect(login, { status: 303 });
}

View File

@@ -3,6 +3,60 @@ import { createServer } from "node:http";
const PORT = Number(process.env.MOCK_PORT ?? 8765);
const now = new Date().toISOString();
const eventItems = [
{
event_id: "00000000-0000-7000-8000-000000000001",
agent_id: "agent-1",
check_id: "disk",
observed_at: now,
received_at: now,
status: "ok",
exit_code: 0,
duration_ms: 12,
output: "disk ok",
output_truncated: false,
notifications_enabled: true,
},
{
event_id: "00000000-0000-7000-8000-000000000002",
agent_id: "agent-1",
check_id: "load",
observed_at: now,
received_at: now,
status: "warning",
exit_code: 1,
duration_ms: 34,
output: "load high",
output_truncated: false,
notifications_enabled: true,
},
{
event_id: "00000000-0000-7000-8000-000000000003",
agent_id: "agent-2",
check_id: "agent_liveness",
observed_at: now,
received_at: now,
status: "critical",
exit_code: 2,
duration_ms: 0,
output: "agent is dead",
output_truncated: false,
notifications_enabled: true,
},
...Array.from({ length: 12 }, (_, i) => ({
event_id: `00000000-0000-7000-8000-0000000001${String(i).padStart(2, "0")}`,
agent_id: "agent-1",
check_id: "disk",
observed_at: now,
received_at: now,
status: "ok",
exit_code: 0,
duration_ms: 10 + i,
output: `disk extra ${i + 1}`,
output_truncated: false,
notifications_enabled: true,
})),
];
const fixtures = {
"/api/v1/health": { status: "ok" },
@@ -12,6 +66,7 @@ const fixtures = {
checks_ok: 2,
checks_warning: 1,
checks_critical: 1,
checks_unknown: 0,
open_incidents: 2,
generated_at: now,
},
@@ -128,48 +183,8 @@ const fixtures = {
next_cursor: null,
},
"/api/v1/events/query": {
items: [
{
event_id: "00000000-0000-7000-8000-000000000001",
agent_id: "agent-1",
check_id: "disk",
observed_at: now,
received_at: now,
status: "ok",
exit_code: 0,
duration_ms: 12,
output: "disk ok",
output_truncated: false,
notifications_enabled: true,
},
{
event_id: "00000000-0000-7000-8000-000000000002",
agent_id: "agent-1",
check_id: "load",
observed_at: now,
received_at: now,
status: "warning",
exit_code: 1,
duration_ms: 34,
output: "load high",
output_truncated: false,
notifications_enabled: true,
},
{
event_id: "00000000-0000-7000-8000-000000000003",
agent_id: "agent-2",
check_id: "agent_liveness",
observed_at: now,
received_at: now,
status: "critical",
exit_code: 2,
duration_ms: 0,
output: "agent is dead",
output_truncated: false,
notifications_enabled: true,
},
],
next_cursor: null,
items: eventItems,
next_cursor: "10",
},
"/api/v1/notifiers/outbox": {
items: [
@@ -205,15 +220,12 @@ const server = createServer((req, res) => {
if (url.pathname === "/api/v1/events/query" && body) {
const agentId = url.searchParams.get("agent_id");
const checkId = url.searchParams.get("check_id");
body = {
...body,
items: body.items.filter((item) => {
if (agentId && item.agent_id !== agentId) return false;
if (checkId && item.check_id !== checkId) return false;
return true;
}),
next_cursor: null,
};
const items = body.items.filter((item) => {
if (agentId && item.agent_id !== agentId) return false;
if (checkId && item.check_id !== checkId) return false;
return true;
});
body = paginate(body, items, url);
}
if (url.pathname === "/api/v1/incidents" && body) {
const state = url.searchParams.get("state");
@@ -239,3 +251,16 @@ const server = createServer((req, res) => {
server.listen(PORT, "127.0.0.1", () => {
console.log(`mock server on ${PORT}`);
});
function paginate(body, items, url) {
const limit = Number(url.searchParams.get("limit") ?? 100);
const start = Number(url.searchParams.get("cursor") ?? 0);
const safeLimit = Number.isFinite(limit) && limit > 0 ? limit : 100;
const safeStart = Number.isFinite(start) && start > 0 ? start : 0;
const next = safeStart + safeLimit;
return {
...body,
items: items.slice(safeStart, next),
next_cursor: next < items.length ? String(next) : null,
};
}

View File

@@ -107,6 +107,14 @@ test("events page", async ({ page }) => {
await expect(page.getByRole("link", { name: /check_id=.*disk/ })).toBeVisible();
});
test("events page loads the next cursor page on scroll", async ({ page }) => {
await page.goto("/events");
await expect(page.locator("tbody")).toContainText("disk extra 7");
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await expect(page.locator("tbody")).toContainText("disk extra 11");
await expect(page).toHaveURL(/\/events$/);
});
test("outbox page", async ({ page }) => {
await page.goto("/outbox");
await expect(page.locator("body")).toContainText("debug");