Implement Monlet MVP stack and UI updates
This commit is contained in:
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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user