Implement Monlet MVP stack and UI updates
This commit is contained in:
231
web/src/app/agents/[agent_id]/page.tsx
Normal file
231
web/src/app/agents/[agent_id]/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||
import { ErrorPanel, Td, Th } from "@/components/DataPanel";
|
||||
import { LabelChips } from "@/components/Labels";
|
||||
import { ApiError, type Agent, type CheckState, type StoredEvent, api } from "@/lib/api";
|
||||
import { fmtDate, statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type SortKey = "severity" | "check_id" | "last_seen";
|
||||
type TabKey = "checks" | "events";
|
||||
|
||||
type LoadResult =
|
||||
| { kind: "ok"; agent: Agent; checks: CheckState[]; events: StoredEvent[] }
|
||||
| { kind: "not_found" }
|
||||
| { kind: "error"; error: unknown };
|
||||
|
||||
const severityRank: Record<CheckState["status"], number> = {
|
||||
critical: 0,
|
||||
warning: 1,
|
||||
unknown: 2,
|
||||
ok: 3,
|
||||
};
|
||||
|
||||
async function load(agentId: string, sort: SortKey, tab: TabKey): Promise<LoadResult> {
|
||||
try {
|
||||
const [agent, checks, events] = await Promise.all([
|
||||
api.getAgent(agentId),
|
||||
tab === "checks" ? loadChecks(agentId).then((items) => sortChecks(items, sort)) : [],
|
||||
tab === "events" ? api.queryEvents({ agent_id: agentId, limit: 50 }).then((page) => page.items) : [],
|
||||
]);
|
||||
return { kind: "ok", agent, checks, events };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 404) return { kind: "not_found" };
|
||||
return { kind: "error", error: e };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadChecks(agentId: string): Promise<CheckState[]> {
|
||||
const checks: CheckState[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listChecks({ agent_id: agentId, cursor, limit: 500 });
|
||||
checks.push(...page.items);
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return checks;
|
||||
}
|
||||
|
||||
function normalizeSort(sort?: string): SortKey {
|
||||
return sort === "check_id" || sort === "last_seen" ? sort : "severity";
|
||||
}
|
||||
|
||||
function normalizeTab(tab?: string): TabKey {
|
||||
return tab === "events" ? "events" : "checks";
|
||||
}
|
||||
|
||||
function sortChecks(checks: CheckState[], sort: SortKey): CheckState[] {
|
||||
return [...checks].sort((a, b) => {
|
||||
if (sort === "check_id") return a.check_id.localeCompare(b.check_id);
|
||||
if (sort === "last_seen") return b.last_observed_at.localeCompare(a.last_observed_at);
|
||||
return severityRank[a.status] - severityRank[b.status] || a.check_id.localeCompare(b.check_id);
|
||||
});
|
||||
}
|
||||
|
||||
export default async function AgentDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ agent_id: string }>;
|
||||
searchParams: Promise<{ sort?: string; tab?: string }>;
|
||||
}) {
|
||||
const { agent_id } = await params;
|
||||
const sp = await searchParams;
|
||||
const sort = normalizeSort(sp.sort);
|
||||
const tab = normalizeTab(sp.tab);
|
||||
const r = await load(agent_id, sort, tab);
|
||||
if (r.kind === "not_found") return <div className="text-sm text-neutral-400">Agent not found.</div>;
|
||||
if (r.kind === "error") return <ErrorPanel error={r.error} />;
|
||||
const { agent, checks, events } = r;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<Link href="/agents" className="text-blue-300 hover:text-blue-200 text-sm">
|
||||
← agents
|
||||
</Link>
|
||||
<h1 className="mt-2 text-lg font-semibold">{agent.agent_id}</h1>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||
<dt className="text-neutral-400">hostname</dt>
|
||||
<dd>{agent.hostname}</dd>
|
||||
<dt className="text-neutral-400">status</dt>
|
||||
<dd className={statusBadgeClass(agent.status)}>{agent.status}</dd>
|
||||
<dt className="text-neutral-400">last_seen_at</dt>
|
||||
<dd>{fmtDate(agent.last_seen_at)}</dd>
|
||||
<dt className="text-neutral-400">features</dt>
|
||||
<dd>
|
||||
<AgentFeatures features={agent.features} />
|
||||
</dd>
|
||||
<dt className="text-neutral-400">labels</dt>
|
||||
<dd>
|
||||
<LabelChips labels={agent.labels} />
|
||||
</dd>
|
||||
</dl>
|
||||
<TabNav agentId={agent.agent_id} current={tab} sort={sort} />
|
||||
{tab === "checks" ? (
|
||||
<ChecksTable agentId={agent.agent_id} checks={checks} sort={sort} />
|
||||
) : (
|
||||
<EventsTable events={events} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TabNav({ agentId, current, sort }: { agentId: string; current: TabKey; sort: SortKey }) {
|
||||
const base = `/agents/${encodeURIComponent(agentId)}`;
|
||||
return (
|
||||
<div className="flex gap-4 border-b border-neutral-800 text-sm">
|
||||
<Link
|
||||
href={`${base}?tab=checks&sort=${sort}`}
|
||||
className={`pb-2 ${current === "checks" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
|
||||
>
|
||||
Checks
|
||||
</Link>
|
||||
<Link
|
||||
href={`${base}?tab=events&sort=${sort}`}
|
||||
className={`pb-2 ${current === "events" ? "border-b border-neutral-200 text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}`}
|
||||
>
|
||||
Events
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecksTable({
|
||||
agentId,
|
||||
checks,
|
||||
sort,
|
||||
}: {
|
||||
agentId: string;
|
||||
checks: CheckState[];
|
||||
sort: SortKey;
|
||||
}) {
|
||||
if (checks.length === 0) return <div className="text-sm text-neutral-500">No checks.</div>;
|
||||
return (
|
||||
<section>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>
|
||||
<SortLink agentId={agentId} sort="check_id" current={sort}>
|
||||
check_id
|
||||
</SortLink>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortLink agentId={agentId} sort="severity" current={sort}>
|
||||
status
|
||||
</SortLink>
|
||||
</Th>
|
||||
<Th>exit</Th>
|
||||
<Th>
|
||||
<SortLink agentId={agentId} sort="last_seen" current={sort}>
|
||||
last_observed_at
|
||||
</SortLink>
|
||||
</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{checks.map((c) => (
|
||||
<tr key={c.check_id}>
|
||||
<Td>{c.check_id}</Td>
|
||||
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
|
||||
<Td>{c.exit_code ?? "—"}</Td>
|
||||
<Td>{fmtDate(c.last_observed_at)}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EventsTable({ events }: { events: StoredEvent[] }) {
|
||||
if (events.length === 0) return <div className="text-sm text-neutral-500">No events.</div>;
|
||||
return (
|
||||
<section>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>observed_at</Th>
|
||||
<Th>check_id</Th>
|
||||
<Th>status</Th>
|
||||
<Th>duration_ms</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((e) => (
|
||||
<tr key={e.event_id}>
|
||||
<Td>{fmtDate(e.observed_at)}</Td>
|
||||
<Td>{e.check_id}</Td>
|
||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
||||
<Td>{e.duration_ms}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SortLink({
|
||||
agentId,
|
||||
sort,
|
||||
current,
|
||||
children,
|
||||
}: {
|
||||
agentId: string;
|
||||
sort: SortKey;
|
||||
current: SortKey;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(agentId)}?tab=checks&sort=${sort}`}
|
||||
className={sort === current ? "text-neutral-100" : "text-neutral-400 hover:text-neutral-200"}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
71
web/src/app/agents/page.tsx
Normal file
71
web/src/app/agents/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { AgentsBrowser } from "@/components/AgentsBrowser";
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { api, type Agent, type CheckState } from "@/lib/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type CheckStatus = CheckState["status"];
|
||||
type CheckCounts = Record<CheckStatus, number>;
|
||||
|
||||
async function load() {
|
||||
const [agents, checksByAgent] = await Promise.all([
|
||||
loadAgents(),
|
||||
loadCheckCounts(),
|
||||
]);
|
||||
return { agents, checksByAgent };
|
||||
}
|
||||
|
||||
async function loadAgents(): Promise<Agent[]> {
|
||||
const agents: Agent[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listAgents({ cursor, limit: 500 });
|
||||
agents.push(...page.items);
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return agents;
|
||||
}
|
||||
|
||||
async function loadCheckCounts(): Promise<Map<string, CheckCounts>> {
|
||||
const byAgent = new Map<string, CheckCounts>();
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const page = await api.listChecks({ cursor, limit: 500 });
|
||||
for (const c of page.items) {
|
||||
const counts = byAgent.get(c.agent_id) ?? emptyCheckCounts();
|
||||
counts[c.status] += 1;
|
||||
byAgent.set(c.agent_id, counts);
|
||||
}
|
||||
cursor = page.next_cursor ?? undefined;
|
||||
} while (cursor);
|
||||
return byAgent;
|
||||
}
|
||||
|
||||
function emptyCheckCounts(): CheckCounts {
|
||||
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
|
||||
}
|
||||
|
||||
export default async function AgentsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
if (Object.keys(sp).length > 0) redirect("/agents");
|
||||
|
||||
let data: Awaited<ReturnType<typeof load>>;
|
||||
try {
|
||||
data = await load();
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
|
||||
const rows = data.agents.map((agent) => ({
|
||||
agent,
|
||||
checks: data.checksByAgent.get(agent.agent_id) ?? emptyCheckCounts(),
|
||||
}));
|
||||
|
||||
return <AgentsBrowser rows={rows} />;
|
||||
}
|
||||
63
web/src/app/checks/page.tsx
Normal file
63
web/src/app/checks/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { api } from "@/lib/api";
|
||||
import { fmtDate, statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function ChecksPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ cursor?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
let data;
|
||||
try {
|
||||
data = await api.listChecks({ cursor: sp.cursor });
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Checks" count={data.items.length} />
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>agent_id</Th>
|
||||
<Th>check_id</Th>
|
||||
<Th>status</Th>
|
||||
<Th>exit</Th>
|
||||
<Th>last_observed_at</Th>
|
||||
<Th>incident_key</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((c) => (
|
||||
<tr key={`${c.agent_id}:${c.check_id}`}>
|
||||
<Td>
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(c.agent_id)}`}
|
||||
className="text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
{c.agent_id}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>{c.check_id}</Td>
|
||||
<Td className={statusBadgeClass(c.status)}>{c.status}</Td>
|
||||
<Td>{c.exit_code ?? "—"}</Td>
|
||||
<Td>{fmtDate(c.last_observed_at)}</Td>
|
||||
<Td className="text-xs text-neutral-400">{c.incident_key ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager basePath="/checks" cursor={sp.cursor} nextCursor={data.next_cursor} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
web/src/app/events/page.tsx
Normal file
94
web/src/app/events/page.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { api } from "@/lib/api";
|
||||
import { fmtDate, statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function EventsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ cursor?: string; agent_id?: string; check_id?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
let data;
|
||||
try {
|
||||
data = await api.queryEvents({
|
||||
agent_id: sp.agent_id,
|
||||
check_id: sp.check_id,
|
||||
cursor: sp.cursor,
|
||||
});
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Events" count={data.items.length} />
|
||||
<form
|
||||
action="/events"
|
||||
method="get"
|
||||
className="mb-4 flex flex-wrap gap-3 text-sm items-end"
|
||||
>
|
||||
<Field label="agent_id" name="agent_id" defaultValue={sp.agent_id} />
|
||||
<Field label="check_id" name="check_id" defaultValue={sp.check_id} />
|
||||
<button
|
||||
type="submit"
|
||||
className="border border-neutral-700 px-3 py-1 hover:border-neutral-500"
|
||||
>
|
||||
query
|
||||
</button>
|
||||
</form>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>observed_at</Th>
|
||||
<Th>received_at</Th>
|
||||
<Th>agent_id</Th>
|
||||
<Th>check_id</Th>
|
||||
<Th>status</Th>
|
||||
<Th>exit</Th>
|
||||
<Th>duration_ms</Th>
|
||||
<Th>output</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((e) => (
|
||||
<tr key={e.event_id}>
|
||||
<Td>{fmtDate(e.observed_at)}</Td>
|
||||
<Td>{fmtDate(e.received_at)}</Td>
|
||||
<Td>{e.agent_id}</Td>
|
||||
<Td>{e.check_id}</Td>
|
||||
<Td className={statusBadgeClass(e.status)}>{e.status}</Td>
|
||||
<Td>{e.exit_code}</Td>
|
||||
<Td>{e.duration_ms}</Td>
|
||||
<Td className="text-xs text-neutral-400 max-w-md truncate">{e.output ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager
|
||||
basePath="/events"
|
||||
cursor={sp.cursor}
|
||||
nextCursor={data.next_cursor}
|
||||
extra={{ agent_id: sp.agent_id, check_id: sp.check_id }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, name, defaultValue }: { label: string; name: string; defaultValue?: string }) {
|
||||
return (
|
||||
<label className="flex flex-col gap-1 text-xs uppercase tracking-wide text-neutral-400">
|
||||
{label}
|
||||
<input
|
||||
name={name}
|
||||
defaultValue={defaultValue ?? ""}
|
||||
className="bg-neutral-900 border border-neutral-800 px-2 py-1 text-sm text-neutral-100 font-mono"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
BIN
web/src/app/favicon.ico
Normal file
BIN
web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
web/src/app/globals.css
Normal file
26
web/src/app/globals.css
Normal file
@@ -0,0 +1,26 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
81
web/src/app/incidents/page.tsx
Normal file
81
web/src/app/incidents/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { api } from "@/lib/api";
|
||||
import { fmtDate, statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const STATES = ["all", "open", "resolved"] as const;
|
||||
|
||||
export default async function IncidentsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ cursor?: string; state?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const state = sp.state === "open" || sp.state === "resolved" ? sp.state : undefined;
|
||||
let data;
|
||||
try {
|
||||
data = await api.listIncidents({ state, cursor: sp.cursor });
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Incidents" count={data.items.length} />
|
||||
<div className="mb-3 flex gap-3 text-sm">
|
||||
{STATES.map((s) => {
|
||||
const href = s === "all" ? "/incidents" : `/incidents?state=${s}`;
|
||||
const active = (s === "all" && !state) || s === state;
|
||||
return (
|
||||
<Link
|
||||
key={s}
|
||||
href={href}
|
||||
className={active ? "text-white underline" : "text-neutral-400 hover:text-white"}
|
||||
>
|
||||
{s}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>opened_at</Th>
|
||||
<Th>state</Th>
|
||||
<Th>severity</Th>
|
||||
<Th>agent_id</Th>
|
||||
<Th>check_id</Th>
|
||||
<Th>resolved_at</Th>
|
||||
<Th>summary</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((i) => (
|
||||
<tr key={i.id}>
|
||||
<Td>{fmtDate(i.opened_at)}</Td>
|
||||
<Td className={statusBadgeClass(i.state)}>{i.state}</Td>
|
||||
<Td className={statusBadgeClass(i.severity)}>{i.severity}</Td>
|
||||
<Td>{i.agent_id ?? "—"}</Td>
|
||||
<Td>{i.check_id ?? "—"}</Td>
|
||||
<Td>{fmtDate(i.resolved_at)}</Td>
|
||||
<Td className="text-neutral-400">{i.summary ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager
|
||||
basePath="/incidents"
|
||||
cursor={sp.cursor}
|
||||
nextCursor={data.next_cursor}
|
||||
extra={{ state }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
web/src/app/layout.tsx
Normal file
68
web/src/app/layout.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { AutoRefresh } from "@/components/AutoRefresh";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Monlet",
|
||||
description: "Read-only monitoring dashboard",
|
||||
};
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Overview" },
|
||||
{ href: "/agents", label: "Agents" },
|
||||
{ href: "/checks", label: "Checks" },
|
||||
{ href: "/incidents", label: "Incidents" },
|
||||
{ href: "/events", label: "Events" },
|
||||
{ href: "/outbox", label: "Outbox" },
|
||||
];
|
||||
|
||||
const DEFAULT_REFRESH_MS = 10_000;
|
||||
|
||||
function getRefreshIntervalMs(): number {
|
||||
const raw = process.env.NEXT_PUBLIC_MONLET_REFRESH_MS?.trim();
|
||||
if (!raw) return DEFAULT_REFRESH_MS;
|
||||
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : DEFAULT_REFRESH_MS;
|
||||
}
|
||||
|
||||
function Brand() {
|
||||
return (
|
||||
<Link href="/" aria-label="Monlet overview" className="group flex shrink-0 items-center gap-2">
|
||||
<span className="relative h-8 w-8 overflow-hidden rounded-md border border-emerald-400/40 bg-neutral-900 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.05)]">
|
||||
<span className="absolute left-2 top-2 h-1.5 w-1.5 rounded-full bg-emerald-300 shadow-[0_0_12px_rgba(110,231,183,0.75)]" />
|
||||
<span className="absolute bottom-2 left-2 h-2 w-0.5 rounded-full bg-cyan-300/80" />
|
||||
<span className="absolute bottom-2 left-3.5 h-4 w-0.5 rounded-full bg-emerald-300" />
|
||||
<span className="absolute bottom-2 left-5 h-2.5 w-0.5 rounded-full bg-sky-300/80" />
|
||||
<span className="absolute right-1.5 top-1.5 h-1 w-1 rounded-full bg-neutral-500 group-hover:bg-emerald-200" />
|
||||
</span>
|
||||
<span className="text-xl font-semibold tracking-wide text-white">
|
||||
mon<span className="text-emerald-300">let</span>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const refreshIntervalMs = getRefreshIntervalMs();
|
||||
|
||||
return (
|
||||
<html lang="en" className="h-full antialiased">
|
||||
<body className="min-h-full flex flex-col bg-neutral-950 text-neutral-100 font-mono">
|
||||
<header className="border-b border-neutral-800 px-4 py-3 flex items-center gap-6">
|
||||
<Brand />
|
||||
<nav className="flex gap-4 text-sm text-neutral-300">
|
||||
{NAV.map((n) => (
|
||||
<Link key={n.href} href={n.href} className="hover:text-white">
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
<main className="flex-1 px-4 py-6">{children}</main>
|
||||
{refreshIntervalMs > 0 && <AutoRefresh intervalMs={refreshIntervalMs} />}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
56
web/src/app/outbox/page.tsx
Normal file
56
web/src/app/outbox/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { EmptyPanel, ErrorPanel, PageHeader, Td, Th } from "@/components/DataPanel";
|
||||
import { Pager } from "@/components/Pager";
|
||||
import { api } from "@/lib/api";
|
||||
import { fmtDate, statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function OutboxPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ cursor?: string }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
let data;
|
||||
try {
|
||||
data = await api.listOutbox({ cursor: sp.cursor });
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Notification outbox" count={data.items.length} />
|
||||
{data.items.length === 0 ? (
|
||||
<EmptyPanel />
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>updated_at</Th>
|
||||
<Th>notifier</Th>
|
||||
<Th>event</Th>
|
||||
<Th>state</Th>
|
||||
<Th>attempts</Th>
|
||||
<Th>next_attempt_at</Th>
|
||||
<Th>last_error</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((o) => (
|
||||
<tr key={o.id}>
|
||||
<Td>{fmtDate(o.updated_at)}</Td>
|
||||
<Td>{o.notifier}</Td>
|
||||
<Td>{o.event_type}</Td>
|
||||
<Td className={statusBadgeClass(o.state)}>{o.state}</Td>
|
||||
<Td>{o.attempts}</Td>
|
||||
<Td>{fmtDate(o.next_attempt_at)}</Td>
|
||||
<Td className="text-xs text-red-300">{o.last_error ?? "—"}</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
<Pager basePath="/outbox" cursor={sp.cursor} nextCursor={data.next_cursor} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
web/src/app/page.tsx
Normal file
81
web/src/app/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { ErrorPanel } from "@/components/DataPanel";
|
||||
import { api } from "@/lib/api";
|
||||
import { statusBadgeClass } from "@/lib/format";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
async function load() {
|
||||
const [agents, checks, incidents] = await Promise.all([
|
||||
api.listAgents({ limit: 500 }),
|
||||
api.listChecks({ limit: 500 }),
|
||||
api.listIncidents({ state: "open", limit: 500 }),
|
||||
]);
|
||||
const tally = (xs: { status?: string }[]) =>
|
||||
xs.reduce<Record<string, number>>((acc, x) => {
|
||||
const k = x.status ?? "unknown";
|
||||
acc[k] = (acc[k] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
return {
|
||||
agents: tally(agents.items),
|
||||
checks: tally(checks.items),
|
||||
openIncidents: incidents.items.length,
|
||||
totalAgents: agents.items.length,
|
||||
totalChecks: checks.items.length,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function OverviewPage() {
|
||||
let data: Awaited<ReturnType<typeof load>>;
|
||||
try {
|
||||
data = await load();
|
||||
} catch (e) {
|
||||
return <ErrorPanel error={e} />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-lg font-semibold">Overview</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card title="Agents" total={data.totalAgents} href="/agents" tally={data.agents} />
|
||||
<Card title="Checks" total={data.totalChecks} href="/checks" tally={data.checks} />
|
||||
<Card
|
||||
title="Open incidents"
|
||||
total={data.openIncidents}
|
||||
href="/incidents"
|
||||
tally={{}}
|
||||
highlight={data.openIncidents > 0 ? "open" : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({
|
||||
title,
|
||||
total,
|
||||
href,
|
||||
tally,
|
||||
highlight,
|
||||
}: {
|
||||
title: string;
|
||||
total: number;
|
||||
href: string;
|
||||
tally: Record<string, number>;
|
||||
highlight?: string;
|
||||
}) {
|
||||
return (
|
||||
<Link href={href} className="block border border-neutral-800 hover:border-neutral-600 p-4">
|
||||
<div className="text-xs uppercase tracking-wide text-neutral-400">{title}</div>
|
||||
<div className={`mt-1 text-3xl ${highlight ? statusBadgeClass(highlight) : ""}`}>{total}</div>
|
||||
<div className="mt-2 flex flex-wrap gap-3 text-xs">
|
||||
{Object.entries(tally).map(([k, v]) => (
|
||||
<span key={k} className={statusBadgeClass(k)}>
|
||||
{k} {v}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
26
web/src/components/AgentFeatures.tsx
Normal file
26
web/src/components/AgentFeatures.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { Agent } from "@/lib/api";
|
||||
|
||||
export function AgentFeatures({ features }: { features?: Agent["features"] }) {
|
||||
const enabled = enabledFeatures(features);
|
||||
if (enabled.length === 0) return <span className="text-neutral-500">—</span>;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{enabled.map((f) => (
|
||||
<span
|
||||
key={f}
|
||||
className="rounded border border-neutral-800 bg-neutral-900 px-1.5 py-0.5 text-xs text-neutral-300"
|
||||
>
|
||||
{f}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function enabledFeatures(features?: Agent["features"]): string[] {
|
||||
if (!features) return [];
|
||||
const out: string[] = [];
|
||||
if (features.push) out.push("push");
|
||||
if (features.metrics) out.push("metrics");
|
||||
return out;
|
||||
}
|
||||
250
web/src/components/AgentsBrowser.tsx
Normal file
250
web/src/components/AgentsBrowser.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { AgentFeatures } from "@/components/AgentFeatures";
|
||||
import { LabelChips } from "@/components/Labels";
|
||||
import type { Agent, CheckState } from "@/lib/api";
|
||||
import { fmtDate, statusBadgeClass } from "@/lib/format";
|
||||
|
||||
type CheckStatus = CheckState["status"];
|
||||
type CheckCounts = Record<CheckStatus, number>;
|
||||
type SortField = "host" | "status" | "last_seen_at";
|
||||
type SortDir = "asc" | "desc";
|
||||
|
||||
export type AgentRow = { agent: Agent; checks: CheckCounts };
|
||||
|
||||
const checkStatuses: CheckStatus[] = ["ok", "warning", "critical", "unknown"];
|
||||
const statusRank: Record<Agent["status"], number> = { alive: 0, stale: 1, dead: 2 };
|
||||
const checkSortStatuses: CheckStatus[] = ["critical", "warning", "unknown", "ok"];
|
||||
|
||||
export function AgentsBrowser({ rows: initialRows }: { rows: AgentRow[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [sort, setSort] = useState<SortField>("status");
|
||||
const [dir, setDir] = useState<SortDir>("desc");
|
||||
|
||||
const rows = useMemo(
|
||||
() => sortAgents(filterAgents(initialRows, query), sort, dir),
|
||||
[dir, initialRows, query, sort],
|
||||
);
|
||||
|
||||
const chooseSort = (field: SortField) => {
|
||||
if (field === sort) {
|
||||
setDir((current) => (current === "asc" ? "desc" : "asc"));
|
||||
return;
|
||||
}
|
||||
setSort(field);
|
||||
setDir(fieldDefaultDir(field));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex items-baseline gap-3">
|
||||
<h1 className="text-lg font-semibold">Agents</h1>
|
||||
<span className="text-sm text-neutral-400">{rows.length} items</span>
|
||||
</div>
|
||||
<div className="mb-4 flex max-w-md gap-2">
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="search agent"
|
||||
className="min-w-0 flex-1 border border-neutral-800 bg-neutral-950 px-2 py-1.5 text-sm text-neutral-100 outline-none focus:border-neutral-500"
|
||||
/>
|
||||
{query ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQuery("")}
|
||||
className="border border-neutral-900 px-3 py-1.5 text-sm text-neutral-500 hover:text-neutral-300"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-neutral-500 italic">No data.</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<Th>
|
||||
<SortButton field="host" current={sort} dir={dir} onClick={chooseSort}>
|
||||
HOST
|
||||
</SortButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="status" current={sort} dir={dir} onClick={chooseSort}>
|
||||
STATUS
|
||||
</SortButton>
|
||||
</Th>
|
||||
<Th>
|
||||
<SortButton field="last_seen_at" current={sort} dir={dir} onClick={chooseSort}>
|
||||
LAST_SEEN_AT
|
||||
</SortButton>
|
||||
</Th>
|
||||
<Th>features</Th>
|
||||
<Th>labels</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ agent: a, checks }) => (
|
||||
<tr key={a.agent_id}>
|
||||
<Td>
|
||||
<Link
|
||||
href={`/agents/${encodeURIComponent(a.agent_id)}`}
|
||||
className="inline-flex flex-col text-blue-300 hover:text-blue-200"
|
||||
>
|
||||
<span>{a.hostname}</span>
|
||||
{a.agent_id !== a.hostname ? (
|
||||
<span className="text-xs text-neutral-500">{a.agent_id}</span>
|
||||
) : null}
|
||||
</Link>
|
||||
</Td>
|
||||
<Td>
|
||||
<AgentStatus status={a.status} checks={checks} />
|
||||
</Td>
|
||||
<Td>{fmtDate(a.last_seen_at)}</Td>
|
||||
<Td>
|
||||
<AgentFeatures features={a.features} />
|
||||
</Td>
|
||||
<Td>
|
||||
<LabelChips labels={a.labels} limit={4} />
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="text-left text-xs font-semibold uppercase tracking-wide text-neutral-400 border-b border-neutral-800 px-2 py-2">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<td className={`px-2 py-1.5 border-b border-neutral-900 align-top ${className}`}>{children}</td>
|
||||
);
|
||||
}
|
||||
|
||||
function SortButton({
|
||||
field,
|
||||
current,
|
||||
dir,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
field: SortField;
|
||||
current: SortField;
|
||||
dir: SortDir;
|
||||
onClick: (field: SortField) => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const active = field === current;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClick(field)}
|
||||
className={`inline-flex items-center gap-1 hover:text-neutral-100 ${active ? "text-neutral-100" : ""}`}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{active ? <span className="text-neutral-500">{dir === "asc" ? "↑" : "↓"}</span> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function filterAgents(rows: AgentRow[], query: string): AgentRow[] {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return rows;
|
||||
return rows.filter(({ agent }) =>
|
||||
`${agent.hostname} ${agent.agent_id}`.toLowerCase().includes(needle),
|
||||
);
|
||||
}
|
||||
|
||||
function fieldDefaultDir(field: SortField): SortDir {
|
||||
return field === "host" ? "asc" : "desc";
|
||||
}
|
||||
|
||||
function sortAgents(rows: AgentRow[], sort: SortField, dir: SortDir): AgentRow[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
const primary = compareByField(a, b, sort);
|
||||
if (primary !== 0) return dir === "asc" ? primary : -primary;
|
||||
return hostName(a.agent).localeCompare(hostName(b.agent)) || a.agent.agent_id.localeCompare(b.agent.agent_id);
|
||||
});
|
||||
}
|
||||
|
||||
function compareByField(a: AgentRow, b: AgentRow, sort: SortField): number {
|
||||
switch (sort) {
|
||||
case "host":
|
||||
return hostName(a.agent).localeCompare(hostName(b.agent)) || a.agent.agent_id.localeCompare(b.agent.agent_id);
|
||||
case "last_seen_at":
|
||||
return Date.parse(a.agent.last_seen_at) - Date.parse(b.agent.last_seen_at);
|
||||
case "status":
|
||||
return compareStatusImportance(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
function compareStatusImportance(a: AgentRow, b: AgentRow): number {
|
||||
const agentStatus = statusRank[a.agent.status] - statusRank[b.agent.status];
|
||||
if (agentStatus !== 0) return agentStatus;
|
||||
for (const status of checkSortStatuses) {
|
||||
const checkStatus = a.checks[status] - b.checks[status];
|
||||
if (checkStatus !== 0) return checkStatus;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function hostName(agent: Agent): string {
|
||||
return agent.hostname || agent.agent_id;
|
||||
}
|
||||
|
||||
function AgentStatus({
|
||||
status,
|
||||
checks,
|
||||
}: {
|
||||
status: "alive" | "stale" | "dead";
|
||||
checks?: CheckCounts;
|
||||
}) {
|
||||
const counts = checks ?? emptyCheckCounts();
|
||||
const total = checkStatuses.reduce((sum, s) => sum + counts[s], 0);
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 whitespace-nowrap">
|
||||
<span className={statusBadgeClass(status)}>{status}</span>
|
||||
<span className="text-neutral-600">checks {total}</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded border border-neutral-800 bg-neutral-900 px-2 py-0.5 text-xs">
|
||||
{checkStatuses.map((s, i) => (
|
||||
<span key={s} className="inline-flex items-center gap-1.5">
|
||||
{i > 0 ? <span className="text-neutral-700">/</span> : null}
|
||||
<span className={statusBadgeClass(s)}>
|
||||
{shortStatus(s)} {counts[s]}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function emptyCheckCounts(): CheckCounts {
|
||||
return { ok: 0, warning: 0, critical: 0, unknown: 0 };
|
||||
}
|
||||
|
||||
function shortStatus(status: CheckStatus): string {
|
||||
switch (status) {
|
||||
case "ok":
|
||||
return "ok";
|
||||
case "warning":
|
||||
return "warn";
|
||||
case "critical":
|
||||
return "crit";
|
||||
case "unknown":
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
51
web/src/components/AutoRefresh.tsx
Normal file
51
web/src/components/AutoRefresh.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function AutoRefresh({ intervalMs }: { intervalMs: number }) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalMs <= 0) return;
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
const stop = () => {
|
||||
if (timer !== undefined) {
|
||||
clearInterval(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const start = () => {
|
||||
stop();
|
||||
timer = setInterval(() => {
|
||||
if (document.visibilityState === "visible") {
|
||||
router.refresh();
|
||||
}
|
||||
}, intervalMs);
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
router.refresh();
|
||||
start();
|
||||
} else {
|
||||
stop();
|
||||
}
|
||||
};
|
||||
|
||||
if (document.visibilityState === "visible") {
|
||||
start();
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
return () => {
|
||||
stop();
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
};
|
||||
}, [intervalMs, router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
39
web/src/components/DataPanel.tsx
Normal file
39
web/src/components/DataPanel.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ApiError } from "@/lib/api";
|
||||
|
||||
export function PageHeader({ title, count }: { title: string; count?: number }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-baseline gap-3">
|
||||
<h1 className="text-lg font-semibold">{title}</h1>
|
||||
{count !== undefined && <span className="text-sm text-neutral-400">{count} items</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorPanel({ error }: { error: unknown }) {
|
||||
const e = error instanceof Error ? error : new Error(String(error));
|
||||
const status = error instanceof ApiError ? error.status : "—";
|
||||
return (
|
||||
<div className="border border-red-700 bg-red-950/40 p-3 text-sm">
|
||||
<div className="font-semibold text-red-300">API error ({String(status)})</div>
|
||||
<div className="text-red-200">{e.message}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyPanel({ label = "No data" }: { label?: string }) {
|
||||
return <div className="text-sm text-neutral-500 italic">{label}.</div>;
|
||||
}
|
||||
|
||||
export function Th({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<th className="text-left text-xs font-semibold uppercase tracking-wide text-neutral-400 border-b border-neutral-800 px-2 py-2">
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
export function Td({ children, className = "" }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<td className={`px-2 py-1.5 border-b border-neutral-900 align-top ${className}`}>{children}</td>
|
||||
);
|
||||
}
|
||||
29
web/src/components/Labels.tsx
Normal file
29
web/src/components/Labels.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
type Labels = Record<string, string> | null | undefined;
|
||||
|
||||
export function LabelChips({ labels, limit }: { labels: Labels; limit?: number }) {
|
||||
const entries = Object.entries(labels ?? {}).sort(([a], [b]) => a.localeCompare(b));
|
||||
if (entries.length === 0) return <span className="text-neutral-500">—</span>;
|
||||
|
||||
const visible = limit === undefined ? entries : entries.slice(0, limit);
|
||||
const hidden = entries.length - visible.length;
|
||||
|
||||
return (
|
||||
<div className="flex max-w-xl flex-wrap gap-1">
|
||||
{visible.map(([k, v]) => (
|
||||
<span
|
||||
key={k}
|
||||
title={`${k}=${v}`}
|
||||
className="max-w-[18rem] truncate rounded border border-neutral-800 bg-neutral-900 px-1.5 py-0.5 text-xs text-neutral-300"
|
||||
>
|
||||
<span className="text-neutral-500">{k}=</span>
|
||||
{v}
|
||||
</span>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<span className="rounded border border-neutral-800 bg-neutral-900 px-1.5 py-0.5 text-xs text-neutral-500">
|
||||
+{hidden}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
web/src/components/Pager.tsx
Normal file
33
web/src/components/Pager.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export function Pager({
|
||||
basePath,
|
||||
cursor,
|
||||
nextCursor,
|
||||
extra = {},
|
||||
}: {
|
||||
basePath: string;
|
||||
cursor?: string;
|
||||
nextCursor?: string | null;
|
||||
extra?: Record<string, string | undefined>;
|
||||
}) {
|
||||
const make = (c?: string) => {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(extra)) if (v) sp.set(k, v);
|
||||
if (c) sp.set("cursor", c);
|
||||
const qs = sp.toString();
|
||||
return qs ? `${basePath}?${qs}` : basePath;
|
||||
};
|
||||
return (
|
||||
<div className="mt-4 flex justify-between text-sm">
|
||||
<span className="text-neutral-500">{cursor ? `cursor: ${cursor.slice(0, 12)}…` : ""}</span>
|
||||
{nextCursor ? (
|
||||
<Link href={make(nextCursor)} className="text-blue-300 hover:text-blue-200">
|
||||
next →
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-neutral-600">end</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
648
web/src/lib/api-types.ts
Normal file
648
web/src/lib/api-types.ts
Normal file
@@ -0,0 +1,648 @@
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/v1/health": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Health check */
|
||||
get: operations["getHealth"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/ready": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Readiness check */
|
||||
get: operations["getReady"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/heartbeat": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Ingest agent heartbeat */
|
||||
post: operations["ingestHeartbeat"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/events": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Ingest agent check result events */
|
||||
post: operations["ingestEvents"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/agents": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List agents */
|
||||
get: operations["listAgents"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/agents/{agent_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get agent detail */
|
||||
get: operations["getAgent"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/checks": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List current check states */
|
||||
get: operations["listChecks"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/incidents": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List incidents */
|
||||
get: operations["listIncidents"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/events/query": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Query stored events */
|
||||
get: operations["queryEvents"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/notifiers/outbox": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List notification outbox items */
|
||||
get: operations["listOutbox"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
HealthResponse: {
|
||||
/** @enum {string} */
|
||||
status: "ok";
|
||||
};
|
||||
ErrorResponse: {
|
||||
error: {
|
||||
/** @enum {string} */
|
||||
code: "unauthorized" | "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;
|
||||
};
|
||||
};
|
||||
PageEnvelope: {
|
||||
/** @description Pass back as `cursor` to fetch the next page. Null/absent if no more results. */
|
||||
next_cursor?: string | null;
|
||||
};
|
||||
AcceptedResponse: {
|
||||
accepted: boolean;
|
||||
};
|
||||
EventBatchAcceptedResponse: {
|
||||
/** @description Number of events stored on this request. */
|
||||
accepted: number;
|
||||
/** @description Number of events whose `event_id` was already known. */
|
||||
deduplicated: number;
|
||||
};
|
||||
HeartbeatRequest: {
|
||||
agent_id: string;
|
||||
/** Format: date-time */
|
||||
observed_at: string;
|
||||
hostname: string;
|
||||
features: components["schemas"]["AgentFeatures"];
|
||||
/** @description Up to 32 labels. Key max 64, value max 256. Monlet agents reserve `monlet_agent_version`. Secret-like keys are rejected. */
|
||||
labels?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
AgentFeatures: {
|
||||
/** @description Agent pushes heartbeats/events to the server. */
|
||||
push: boolean;
|
||||
/** @description Agent exposes a Prometheus-compatible metrics endpoint. */
|
||||
metrics: boolean;
|
||||
};
|
||||
EventBatchRequest: {
|
||||
agent_id: string;
|
||||
events: components["schemas"]["CheckResultEvent"][];
|
||||
};
|
||||
/**
|
||||
* @description Event body used inside `EventBatchRequest.events`. The owning `agent_id`
|
||||
* is supplied once at the batch envelope; per-event `agent_id` is intentionally
|
||||
* absent so requests cannot mix or spoof other agents under a shared token.
|
||||
* Server response endpoints (`GET /events/query`) include `agent_id` separately
|
||||
* in their item schema.
|
||||
*/
|
||||
CheckResultEvent: {
|
||||
/** @description UUIDv7 string (lower-case, with dashes). See ADR-0006. */
|
||||
event_id: string;
|
||||
check_id: string;
|
||||
/** Format: date-time */
|
||||
observed_at: string;
|
||||
/** @enum {string} */
|
||||
status: "ok" | "warning" | "critical" | "unknown";
|
||||
exit_code: number;
|
||||
duration_ms: number;
|
||||
/**
|
||||
* @description Limit is 8 KiB measured in UTF-8 **bytes** (not characters). Agent truncates
|
||||
* on a UTF-8 boundary before send and appends marker `...[truncated N bytes]`.
|
||||
* `maxLength: 8192` here is a coarse schema-level upper bound; servers MUST
|
||||
* additionally reject events whose UTF-8 byte length exceeds 8192 with
|
||||
* `400 validation`.
|
||||
*/
|
||||
output?: string;
|
||||
/** @default false */
|
||||
output_truncated: boolean;
|
||||
/**
|
||||
* @description If false, the server stores state/incidents but does not enqueue notifications for this event.
|
||||
* @default true
|
||||
*/
|
||||
notifications_enabled: boolean;
|
||||
incident_key?: string;
|
||||
};
|
||||
/** @description Stored event returned by `GET /events/query`. Includes the resolved `agent_id` and server-side `received_at`. */
|
||||
StoredCheckResultEvent: components["schemas"]["CheckResultEvent"] & {
|
||||
agent_id: string;
|
||||
/** Format: date-time */
|
||||
received_at: string;
|
||||
};
|
||||
Agent: {
|
||||
agent_id: string;
|
||||
hostname: string;
|
||||
/** @enum {string} */
|
||||
status: "alive" | "stale" | "dead";
|
||||
/** Format: date-time */
|
||||
last_seen_at: string;
|
||||
features: components["schemas"]["AgentFeatures"];
|
||||
labels?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
CheckState: {
|
||||
agent_id: string;
|
||||
check_id: string;
|
||||
/** @enum {string} */
|
||||
status: "ok" | "warning" | "critical" | "unknown";
|
||||
/** Format: date-time */
|
||||
last_observed_at: string;
|
||||
exit_code?: number;
|
||||
incident_key?: string;
|
||||
};
|
||||
Incident: {
|
||||
id: string;
|
||||
incident_key: string;
|
||||
/** @enum {string} */
|
||||
state: "open" | "resolved";
|
||||
/** @enum {string} */
|
||||
severity: "warning" | "critical" | "unknown";
|
||||
agent_id?: string;
|
||||
check_id?: string;
|
||||
/** Format: date-time */
|
||||
opened_at: string;
|
||||
/** Format: date-time */
|
||||
resolved_at?: string | null;
|
||||
summary?: string;
|
||||
};
|
||||
NotificationOutboxItem: {
|
||||
id: string;
|
||||
notifier: string;
|
||||
/** @enum {string} */
|
||||
state: "pending" | "sending" | "sent" | "retry" | "failed" | "discarded";
|
||||
incident_id: string;
|
||||
/** @enum {string} */
|
||||
event_type: "firing" | "resolved";
|
||||
attempts: number;
|
||||
/** Format: date-time */
|
||||
next_attempt_at?: string | null;
|
||||
last_error?: string | null;
|
||||
/** Format: date-time */
|
||||
updated_at?: string;
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Authentication failed. */
|
||||
Unauthorized: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Resource not found. */
|
||||
NotFound: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Payload exceeds configured limit (1 MiB). */
|
||||
PayloadTooLarge: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Request body or parameters failed validation. */
|
||||
ValidationError: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Unexpected server error. */
|
||||
InternalError: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
parameters: {
|
||||
AgentId: string;
|
||||
/** @description Opaque pagination cursor returned in `next_cursor`. */
|
||||
Cursor: string;
|
||||
Limit: number;
|
||||
};
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
getHealth: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Service is alive. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HealthResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
getReady: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Service is ready. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HealthResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Service is not ready. */
|
||||
503: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ErrorResponse"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
ingestHeartbeat: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["HeartbeatRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Heartbeat accepted. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AcceptedResponse"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["ValidationError"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
413: components["responses"]["PayloadTooLarge"];
|
||||
500: components["responses"]["InternalError"];
|
||||
};
|
||||
};
|
||||
ingestEvents: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["EventBatchRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Events accepted. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["EventBatchAcceptedResponse"];
|
||||
};
|
||||
};
|
||||
400: components["responses"]["ValidationError"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
413: components["responses"]["PayloadTooLarge"];
|
||||
500: components["responses"]["InternalError"];
|
||||
};
|
||||
};
|
||||
listAgents: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Opaque pagination cursor returned in `next_cursor`. */
|
||||
cursor?: components["parameters"]["Cursor"];
|
||||
limit?: components["parameters"]["Limit"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Agent list. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PageEnvelope"] & {
|
||||
items: components["schemas"]["Agent"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
};
|
||||
};
|
||||
getAgent: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
agent_id: components["parameters"]["AgentId"];
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Agent detail. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Agent"];
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
404: components["responses"]["NotFound"];
|
||||
};
|
||||
};
|
||||
listChecks: {
|
||||
parameters: {
|
||||
query?: {
|
||||
agent_id?: string;
|
||||
/** @description Opaque pagination cursor returned in `next_cursor`. */
|
||||
cursor?: components["parameters"]["Cursor"];
|
||||
limit?: components["parameters"]["Limit"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Check state list. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PageEnvelope"] & {
|
||||
items: components["schemas"]["CheckState"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
};
|
||||
};
|
||||
listIncidents: {
|
||||
parameters: {
|
||||
query?: {
|
||||
state?: "open" | "resolved";
|
||||
/** @description Opaque pagination cursor returned in `next_cursor`. */
|
||||
cursor?: components["parameters"]["Cursor"];
|
||||
limit?: components["parameters"]["Limit"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Incident list. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PageEnvelope"] & {
|
||||
items: components["schemas"]["Incident"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
};
|
||||
};
|
||||
queryEvents: {
|
||||
parameters: {
|
||||
query?: {
|
||||
agent_id?: string;
|
||||
check_id?: string;
|
||||
/** @description Opaque pagination cursor returned in `next_cursor`. */
|
||||
cursor?: components["parameters"]["Cursor"];
|
||||
limit?: components["parameters"]["Limit"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Event list. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PageEnvelope"] & {
|
||||
items: components["schemas"]["StoredCheckResultEvent"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
};
|
||||
};
|
||||
listOutbox: {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Opaque pagination cursor returned in `next_cursor`. */
|
||||
cursor?: components["parameters"]["Cursor"];
|
||||
limit?: components["parameters"]["Limit"];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Outbox list. */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PageEnvelope"] & {
|
||||
items: components["schemas"]["NotificationOutboxItem"][];
|
||||
};
|
||||
};
|
||||
};
|
||||
401: components["responses"]["Unauthorized"];
|
||||
};
|
||||
};
|
||||
}
|
||||
67
web/src/lib/api.ts
Normal file
67
web/src/lib/api.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import "server-only";
|
||||
|
||||
import type { components, operations } from "./api-types";
|
||||
|
||||
export type Agent = components["schemas"]["Agent"];
|
||||
export type CheckState = components["schemas"]["CheckState"];
|
||||
export type Incident = components["schemas"]["Incident"];
|
||||
export type StoredEvent = components["schemas"]["StoredCheckResultEvent"];
|
||||
export type OutboxItem = components["schemas"]["NotificationOutboxItem"];
|
||||
|
||||
type OkBody<O> = O extends { responses: { 200: { content: { "application/json": infer B } } } }
|
||||
? B
|
||||
: never;
|
||||
type QueryOf<O> = O extends { parameters: { query?: infer Q } } ? NonNullable<Q> : never;
|
||||
|
||||
type AgentsBody = OkBody<operations["listAgents"]>;
|
||||
type ChecksBody = OkBody<operations["listChecks"]>;
|
||||
type IncidentsBody = OkBody<operations["listIncidents"]>;
|
||||
type EventsBody = OkBody<operations["queryEvents"]>;
|
||||
type OutboxBody = OkBody<operations["listOutbox"]>;
|
||||
|
||||
const BASE = process.env.MONLET_API_BASE_URL ?? "http://127.0.0.1:8000";
|
||||
const TOKEN = process.env.MONLET_API_TOKEN ?? "";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public code: string, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> {
|
||||
const url = new URL(path, BASE);
|
||||
if (params) {
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== "") url.searchParams.set(k, String(v));
|
||||
}
|
||||
}
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (TOKEN) headers.Authorization = `Bearer ${TOKEN}`;
|
||||
const res = await fetch(url, { headers, cache: "no-store" });
|
||||
if (!res.ok) {
|
||||
let code = "http_error";
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const body = await res.json();
|
||||
code = body?.error?.code ?? code;
|
||||
message = body?.error?.message ?? message;
|
||||
} catch {}
|
||||
throw new ApiError(res.status, code, message);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
listAgents: (q: QueryOf<operations["listAgents"]> = {}) =>
|
||||
get<AgentsBody>("/api/v1/agents", q),
|
||||
getAgent: (agentId: string) => get<Agent>(`/api/v1/agents/${encodeURIComponent(agentId)}`),
|
||||
listChecks: (q: QueryOf<operations["listChecks"]> = {}) =>
|
||||
get<ChecksBody>("/api/v1/checks", q),
|
||||
listIncidents: (q: QueryOf<operations["listIncidents"]> = {}) =>
|
||||
get<IncidentsBody>("/api/v1/incidents", q),
|
||||
queryEvents: (q: QueryOf<operations["queryEvents"]> = {}) =>
|
||||
get<EventsBody>("/api/v1/events/query", q),
|
||||
listOutbox: (q: QueryOf<operations["listOutbox"]> = {}) =>
|
||||
get<OutboxBody>("/api/v1/notifiers/outbox", q),
|
||||
health: () => get<{ status: string }>("/api/v1/health"),
|
||||
};
|
||||
28
web/src/lib/format.ts
Normal file
28
web/src/lib/format.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export function fmtDate(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? iso : d.toISOString().replace("T", " ").replace(/\..+$/, "Z");
|
||||
}
|
||||
|
||||
export function statusBadgeClass(s: string): string {
|
||||
switch (s) {
|
||||
case "ok":
|
||||
case "alive":
|
||||
case "resolved":
|
||||
case "sent":
|
||||
return "text-emerald-400";
|
||||
case "warning":
|
||||
case "stale":
|
||||
case "retry":
|
||||
return "text-amber-400";
|
||||
case "critical":
|
||||
case "dead":
|
||||
case "open":
|
||||
case "failed":
|
||||
return "text-red-400";
|
||||
case "discarded":
|
||||
return "text-neutral-500";
|
||||
default:
|
||||
return "text-neutral-300";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user