Implement Monlet MVP stack and UI updates

This commit is contained in:
Stanislav Rossovskii
2026-05-27 10:01:59 +04:00
parent dcea096327
commit edc51e9c59
145 changed files with 15618 additions and 248 deletions

8
web/.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
node_modules
.next
test-results
playwright-report
.env*
README.md
Dockerfile
.dockerignore

45
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# playwright
/test-results/
/playwright-report/

22
web/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "run", "start"]

View File

@@ -1,32 +1,53 @@
# Monlet Web
Placeholder for the read-only dashboard.
Read-only dashboard for Monlet.
Planned stack:
## Stack
- Next.js App Router;
- TypeScript;
- Tailwind CSS;
- read-only API client;
- reverse-proxy/basic-auth friendly deployment.
- Next.js 16 (App Router)
- React 19
- TypeScript
- Tailwind CSS v4
- Typed API client generated from `api/openapi.yaml`
No web UI is implemented in Stage 0.
## Planned Commands
## Commands
```sh
cd web
npm_config_cache=../.cache/npm npm install
npm_config_cache=../.cache/npm npm run dev
```
Build / lint / typecheck / smoke:
```sh
npm_config_cache=../.cache/npm npm run build
npm_config_cache=../.cache/npm npm run lint
npm_config_cache=../.cache/npm npm run typecheck
npm_config_cache=../.cache/npm npm run smoke
```
Do not install Node packages globally. Project dependencies live in `web/node_modules`; npm cache lives under repository `.cache/npm`.
## Planned Pages
## Environment
- overview;
- agents;
- agent detail;
- checks;
- incidents;
- events;
- notifiers/outbox.
| Var | Default | Purpose |
|---|---|---|
| `MONLET_API_BASE_URL` | `http://127.0.0.1:8000` | Server base URL (server-side fetch) |
| `MONLET_API_TOKEN` | _(none)_ | Bearer token sent to server |
The token never reaches the browser — all server calls happen in Server Components / route handlers.
## Pages
- `/` — overview
- `/agents` — agent list
- `/agents/[id]` — agent detail
- `/checks` — current check states
- `/incidents` — incident list
- `/events` — events query
- `/outbox` — notification outbox
## API client
Types are generated from `../api/openapi.yaml` into `src/lib/api-types.ts` via `npm run gen:api`.

18
web/eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
web/next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: ["127.0.0.1"],
};
export default nextConfig;

6969
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
web/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"gen:api": "openapi-typescript ../api/openapi.yaml -o src/lib/api-types.ts",
"smoke": "playwright test"
},
"dependencies": {
"next": "16.2.6",
"react": "19.2.4",
"react-dom": "19.2.4",
"server-only": "^0.0.1"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"openapi-typescript": "^7.13.0",
"tailwindcss": "^4",
"typescript": "^5"
}
}

33
web/playwright.config.ts Normal file
View File

@@ -0,0 +1,33 @@
import { defineConfig } from "@playwright/test";
const WEB_PORT = process.env.WEB_SMOKE_PORT ?? "3100";
const MOCK_PORT = process.env.MOCK_PORT ?? "8765";
export default defineConfig({
testDir: "./tests",
fullyParallel: false,
timeout: 30_000,
retries: 0,
use: {
baseURL: `http://127.0.0.1:${WEB_PORT}`,
},
webServer: [
{
command: `node tests/mock-server.mjs`,
url: `http://127.0.0.1:${MOCK_PORT}/api/v1/health`,
reuseExistingServer: true,
timeout: 10_000,
env: { MOCK_PORT },
},
{
command: `next dev -p ${WEB_PORT}`,
url: `http://127.0.0.1:${WEB_PORT}`,
reuseExistingServer: true,
timeout: 60_000,
env: {
MONLET_API_BASE_URL: `http://127.0.0.1:${MOCK_PORT}`,
MONLET_API_TOKEN: "smoke",
},
},
],
});

7
web/postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
web/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
web/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
web/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
web/public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
web/public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View 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>
);
}

View 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} />;
}

View 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} />
</>
);
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

26
web/src/app/globals.css Normal file
View 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;
}

View 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
View 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>
);
}

View 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
View 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>
);
}

View 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;
}

View 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";
}
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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";
}
}

133
web/tests/mock-server.mjs Normal file
View File

@@ -0,0 +1,133 @@
import { createServer } from "node:http";
const PORT = Number(process.env.MOCK_PORT ?? 8765);
const now = new Date().toISOString();
const fixtures = {
"/api/v1/health": { status: "ok" },
"/api/v1/agents": {
items: [
{
agent_id: "agent-1",
hostname: "host-1",
status: "alive",
last_seen_at: now,
features: { push: true, metrics: false },
labels: { env: "dev", monlet_agent_version: "0.1.0" },
},
{
agent_id: "agent-2",
hostname: "host-2",
status: "stale",
last_seen_at: now,
features: { push: true, metrics: false },
labels: {},
},
],
next_cursor: null,
},
"/api/v1/agents/agent-1": {
agent_id: "agent-1",
hostname: "host-1",
status: "alive",
last_seen_at: now,
features: { push: true, metrics: false },
labels: { env: "dev", monlet_agent_version: "0.1.0" },
},
"/api/v1/checks": {
items: [
{
agent_id: "agent-1",
check_id: "disk",
status: "ok",
exit_code: 0,
last_observed_at: now,
incident_key: "agent-1:disk",
},
{
agent_id: "agent-1",
check_id: "load",
status: "warning",
exit_code: 1,
last_observed_at: now,
incident_key: "agent-1:load",
},
],
next_cursor: null,
},
"/api/v1/incidents": {
items: [
{
id: "00000000-0000-0000-0000-000000000001",
incident_key: "agent-1:load",
state: "open",
severity: "warning",
agent_id: "agent-1",
check_id: "load",
opened_at: now,
summary: "load high",
},
],
next_cursor: null,
},
"/api/v1/events/query": {
items: [
{
event_id: "00000000-0000-7000-8000-000000000001",
agent_id: "agent-1",
check_id: "disk",
observed_at: now,
received_at: now,
status: "ok",
exit_code: 0,
duration_ms: 12,
},
],
next_cursor: null,
},
"/api/v1/notifiers/outbox": {
items: [
{
id: "00000000-0000-0000-0000-000000000002",
notifier: "debug",
state: "sent",
incident_id: "00000000-0000-0000-0000-000000000001",
event_type: "firing",
attempts: 1,
next_attempt_at: null,
last_error: null,
updated_at: now,
},
],
next_cursor: null,
},
};
const server = createServer((req, res) => {
const url = new URL(req.url ?? "/", `http://x`);
let body = fixtures[url.pathname];
if (url.pathname === "/api/v1/checks" && body) {
const agentId = url.searchParams.get("agent_id");
if (agentId) {
body = {
...body,
items: body.items.filter((item) => item.agent_id === agentId),
next_cursor: null,
};
}
}
res.setHeader("Content-Type", "application/json");
if (!body) {
res.statusCode = 404;
res.end(
JSON.stringify({ error: { code: "not_found", message: "not in fixtures", request_id: "mock" } }),
);
return;
}
res.end(JSON.stringify(body));
});
server.listen(PORT, "127.0.0.1", () => {
console.log(`mock server on ${PORT}`);
});

63
web/tests/smoke.spec.ts Normal file
View File

@@ -0,0 +1,63 @@
import { expect, test } from "@playwright/test";
test("overview renders tallies", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Overview" })).toBeVisible();
await expect(page.locator("body")).toContainText("Agents");
await expect(page.locator("body")).toContainText("Checks");
});
test("agents list links to detail", async ({ page }) => {
await page.goto("/agents?sort=status&dir=desc");
await expect(page).toHaveURL(/\/agents$/);
await page.goto("/agents");
await expect(page.getByRole("heading", { name: "Agents" })).toBeVisible();
await expect(page.locator("thead")).not.toContainText("hostname");
await expect(page.locator("thead")).toContainText("features");
await expect(page.locator("thead")).not.toContainText("asc");
await expect(page.locator("thead")).not.toContainText("desc");
await expect(page.locator("thead")).toContainText("↓");
await expect(page.locator("body")).toContainText("checks 2");
await expect(page.locator("body")).toContainText("warn 1");
await page.getByPlaceholder("search agent").fill("host-1");
await expect(page).toHaveURL(/\/agents$/);
await expect(page.locator("tbody")).toContainText("host-1");
await expect(page.locator("tbody")).not.toContainText("host-2");
await page.getByRole("button", { name: "host" }).click();
await expect(page).toHaveURL(/\/agents$/);
await page.getByRole("button", { name: "clear" }).click();
await page.getByRole("link", { name: "agent-1" }).first().click();
await expect(page).toHaveURL(/\/agents\/agent-1$/);
await expect(page.locator("body")).toContainText("hostname");
await expect(page.getByRole("link", { name: "Checks" }).last()).toBeVisible();
await expect(page.locator("tbody").first().locator("tr").first()).toContainText("warning");
await expect(page.locator("thead").first()).not.toContainText("incident_key");
await page.getByRole("link", { name: "check_id" }).click();
await expect(page).toHaveURL(/sort=check_id/);
await page.getByRole("link", { name: "Events" }).last().click();
await expect(page).toHaveURL(/tab=events.*sort=check_id|sort=check_id.*tab=events/);
await expect(page.locator("thead").first()).not.toContainText("exit");
await page.getByRole("link", { name: "Checks" }).last().click();
await expect(page).toHaveURL(/tab=checks.*sort=check_id|sort=check_id.*tab=checks/);
});
test("checks page lists rows", async ({ page }) => {
await page.goto("/checks");
await expect(page.locator("body")).toContainText("disk");
await expect(page.locator("body")).toContainText("load");
});
test("incidents filter renders", async ({ page }) => {
await page.goto("/incidents");
await expect(page.locator("body")).toContainText("load high");
});
test("events page", async ({ page }) => {
await page.goto("/events");
await expect(page.getByRole("heading", { name: "Events" })).toBeVisible();
});
test("outbox page", async ({ page }) => {
await page.goto("/outbox");
await expect(page.locator("body")).toContainText("debug");
});

34
web/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}