Implement Monlet MVP stack and UI updates
This commit is contained in:
8
web/.dockerignore
Normal file
8
web/.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
test-results
|
||||
playwright-report
|
||||
.env*
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
45
web/.gitignore
vendored
Normal file
45
web/.gitignore
vendored
Normal 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
22
web/Dockerfile
Normal 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"]
|
||||
@@ -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
18
web/eslint.config.mjs
Normal 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
7
web/next.config.ts
Normal 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
6969
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
web/package.json
Normal file
32
web/package.json
Normal 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
33
web/playwright.config.ts
Normal 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
7
web/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
web/public/file.svg
Normal file
1
web/public/file.svg
Normal 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
1
web/public/globe.svg
Normal 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
1
web/public/next.svg
Normal 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
1
web/public/vercel.svg
Normal 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
1
web/public/window.svg
Normal 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 |
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";
|
||||
}
|
||||
}
|
||||
133
web/tests/mock-server.mjs
Normal file
133
web/tests/mock-server.mjs
Normal 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
63
web/tests/smoke.spec.ts
Normal 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
34
web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user