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

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