Add web auth, infinite-scroll, agent admission and review fixes across agent/server/web
This commit is contained in:
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
58
web/src/app/api/auth/login/route.ts
Normal file
58
web/src/app/api/auth/login/route.ts
Normal 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 : "";
|
||||
}
|
||||
19
web/src/app/api/auth/logout/route.ts
Normal file
19
web/src/app/api/auth/logout/route.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
96
web/src/app/login/page.tsx
Normal file
96
web/src/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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()}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
40
web/src/components/InfiniteListFooter.tsx
Normal file
40
web/src/components/InfiniteListFooter.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
5
web/src/lib/redirect-to-login.ts
Normal file
5
web/src/lib/redirect-to-login.ts
Normal 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()}`);
|
||||
}
|
||||
9
web/src/lib/timezone-preference.ts
Normal file
9
web/src/lib/timezone-preference.ts
Normal 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";
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
235
web/src/lib/useInfiniteList.ts
Normal file
235
web/src/lib/useInfiniteList.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"];
|
||||
|
||||
52
web/src/lib/web-auth-server.ts
Normal file
52
web/src/lib/web-auth-server.ts
Normal 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
170
web/src/lib/web-auth.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user