Add guided SNMP discovery UI

This commit is contained in:
Keith Smith
2026-05-23 20:33:28 -06:00
parent a38438e7f1
commit 8b5dea152e
6 changed files with 328 additions and 11 deletions
+7
View File
@@ -13,6 +13,8 @@ import type {
SnmpCredentialProfile,
SnmpCredentialProfileCreate,
SnmpCredentialProfileUpdate,
SnmpDiscoveryRequest,
SnmpDiscoveryResult,
TcpMonitorCreate,
User,
WebsiteMonitorCreate,
@@ -139,4 +141,9 @@ export const api = {
request<void>(`/credentials/snmp/${profileId}`, token, {
method: "DELETE",
}),
discoverSnmpDevice: (token: string, payload: SnmpDiscoveryRequest) =>
request<SnmpDiscoveryResult>("/discovery/snmp", token, {
method: "POST",
body: JSON.stringify(payload),
}),
};
+2 -1
View File
@@ -6,6 +6,7 @@ import { useAuth } from "../hooks/useAuth";
import { AlertsPage } from "../pages/AlertsPage";
import { CredentialsPage } from "../pages/CredentialsPage";
import { DashboardPage } from "../pages/DashboardPage";
import { DiscoveryPage } from "../pages/DiscoveryPage";
import { ListPage } from "../pages/ListPage";
import { LoginPage } from "../pages/LoginPage";
import { NetworkChecksPage } from "../pages/NetworkChecksPage";
@@ -87,7 +88,7 @@ export function App() {
{page === "alerts" ? (
<AlertsPage token={auth.token} monitors={monitors} incidents={incidents} selectedIncidentId={selectedIncidentId} onChanged={refreshData} />
) : null}
{page === "discovery" ? <ListPage title="Discovery" description="Guided target discovery with monitor and alert choices." /> : null}
{page === "discovery" ? <DiscoveryPage token={auth.token} /> : null}
{page === "graphs" ? <ListPage title="Graphs" description="Metric history and dashboard-ready charts." /> : null}
{page === "credentials" ? <CredentialsPage token={auth.token} /> : null}
{page === "notifications" ? <NotificationsPage token={auth.token} /> : null}
+267
View File
@@ -0,0 +1,267 @@
import { FormEvent, useEffect, useMemo, useState } from "react";
import { CheckSquare, Network, RefreshCw, Router, Search, Square } from "lucide-react";
import { api } from "../api/client";
import { Button } from "../components/Button";
import type { SnmpCredentialProfile, SnmpDiscoveryItem, SnmpDiscoveryResult } from "../types/api";
interface DiscoveryPageProps {
token: string;
}
export function DiscoveryPage({ token }: DiscoveryPageProps) {
const [profiles, setProfiles] = useState<SnmpCredentialProfile[]>([]);
const [host, setHost] = useState("");
const [profileId, setProfileId] = useState<number | "">("");
const [result, setResult] = useState<SnmpDiscoveryResult | null>(null);
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
const [loadingProfiles, setLoadingProfiles] = useState(false);
const [discovering, setDiscovering] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const selectedItems = useMemo(
() => (result?.monitorable_items ?? []).filter((item) => selectedItemIds.has(item.item_id)),
[result, selectedItemIds]
);
const groupedItems = useMemo(() => groupItems(result?.monitorable_items ?? []), [result]);
async function refreshProfiles() {
setLoadingProfiles(true);
try {
const nextProfiles = await api.snmpCredentialProfiles(token);
setProfiles(nextProfiles);
setProfileId((current) => current || nextProfiles[0]?.id || "");
} finally {
setLoadingProfiles(false);
}
}
useEffect(() => {
refreshProfiles().catch(() => setProfiles([]));
}, [token]);
async function submit(event: FormEvent) {
event.preventDefault();
if (!profileId) return;
setDiscovering(true);
setMessage(null);
setResult(null);
setSelectedItemIds(new Set());
try {
const discovered = await api.discoverSnmpDevice(token, {
host,
credential_profile_id: profileId,
});
setResult(discovered);
} catch (err) {
setMessage(err instanceof Error ? err.message : "SNMP discovery failed");
} finally {
setDiscovering(false);
}
}
function toggleItem(itemId: string) {
setSelectedItemIds((current) => {
const next = new Set(current);
if (next.has(itemId)) {
next.delete(itemId);
} else {
next.add(itemId);
}
return next;
});
}
function selectGroup(items: SnmpDiscoveryItem[]) {
setSelectedItemIds((current) => {
const next = new Set(current);
const allSelected = items.every((item) => next.has(item.item_id));
for (const item of items) {
if (allSelected) {
next.delete(item.item_id);
} else {
next.add(item.item_id);
}
}
return next;
});
}
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
<div>
<h1 className="text-3xl font-semibold">Discovery</h1>
<p className="mt-2 text-sm text-slate-400">Find SNMP devices and choose friendly items for monitoring.</p>
</div>
<Button variant="ghost" onClick={refreshProfiles} disabled={loadingProfiles}>
<RefreshCw size={16} />
Refresh Profiles
</Button>
</div>
<section className="grid gap-5 xl:grid-cols-[420px_minmax(0,1fr)]">
<form className="space-y-4 rounded-md border border-line bg-[#0d131c] p-5" onSubmit={submit}>
<div className="flex items-center gap-2">
<Search size={18} className="text-pulse" />
<h2 className="text-base font-semibold">SNMP Device Discovery</h2>
</div>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Device Host</span>
<input className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={host} onChange={(event) => setHost(event.target.value)} placeholder="192.168.1.1 or switch.local" required />
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">SNMP Profile</span>
<select className="h-10 w-full rounded-md border border-line bg-slate-950 px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={profileId} onChange={(event) => setProfileId(Number(event.target.value))} required>
<option value="" disabled>
Select a profile
</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name} - SNMPv{profile.version}
</option>
))}
</select>
</label>
{message ? <div className="rounded-md border border-red-500/40 bg-red-950/40 p-3 text-sm text-red-200">{message}</div> : null}
{!profiles.length ? <div className="rounded-md border border-line bg-slate-950 p-3 text-sm text-slate-400">Add an SNMP credential profile before running discovery.</div> : null}
<Button className="w-full" disabled={discovering || !profiles.length} type="submit">
<Search size={16} />
{discovering ? "Discovering..." : "Run Discovery"}
</Button>
</form>
<div className="space-y-5">
<div className="rounded-md border border-line bg-[#0d131c]">
<div className="border-b border-line p-4">
<h2 className="text-base font-semibold">Device Summary</h2>
</div>
{result ? (
<div className="grid gap-4 p-4 md:grid-cols-3">
<SummaryItem label="Name" value={result.device_name || result.host} />
<SummaryItem label="Host" value={result.host} />
<SummaryItem label="Uptime" value={formatDuration(result.uptime_seconds)} />
<div className="md:col-span-3">
<div className="text-xs uppercase text-slate-500">Description</div>
<div className="mt-1 text-sm text-slate-300">{result.description || "No description reported"}</div>
</div>
</div>
) : (
<div className="p-6 text-sm text-slate-400">Run discovery to see device details.</div>
)}
</div>
<div className="rounded-md border border-line bg-[#0d131c]">
<div className="flex items-center gap-2 border-b border-line p-4">
<Network size={18} className="text-pulse" />
<h2 className="text-base font-semibold">Interfaces</h2>
</div>
<div className="divide-y divide-line">
{result?.interfaces.length ? (
result.interfaces.map((item) => (
<div key={item.index} className="grid gap-2 p-4 md:grid-cols-[1fr_120px_120px_120px] md:items-center">
<div>
<div className="font-medium">{item.name}</div>
<div className="text-sm text-slate-400">{item.description || "No description"}</div>
</div>
<Status value={item.admin_status || "unknown"} />
<Status value={item.oper_status || "unknown"} />
<div className="text-sm text-slate-300">{formatSpeed(item.speed_bps)}</div>
</div>
))
) : (
<div className="p-6 text-sm text-slate-400">{result ? "No interfaces reported." : "No discovery results yet."}</div>
)}
</div>
</div>
<div className="rounded-md border border-line bg-[#0d131c]">
<div className="flex flex-col gap-2 border-b border-line p-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-2">
<Router size={18} className="text-pulse" />
<h2 className="text-base font-semibold">Monitorable Items</h2>
</div>
<div className="text-sm text-slate-400">{selectedItems.length} selected</div>
</div>
<div className="divide-y divide-line">
{result ? (
groupedItems.map(({ group, items }) => (
<div key={group} className="p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-200">{group}</h3>
<Button className="h-8 px-3" onClick={() => selectGroup(items)} type="button" variant="ghost">
{items.every((item) => selectedItemIds.has(item.item_id)) ? <CheckSquare size={15} /> : <Square size={15} />}
Group
</Button>
</div>
<div className="grid gap-2 md:grid-cols-2">
{items.map((item) => (
<button key={item.item_id} className="flex min-h-12 items-center gap-3 rounded-md border border-line bg-slate-950 px-3 py-2 text-left transition hover:bg-slate-900" onClick={() => toggleItem(item.item_id)} type="button">
{selectedItemIds.has(item.item_id) ? <CheckSquare className="shrink-0 text-pulse" size={18} /> : <Square className="shrink-0 text-slate-500" size={18} />}
<span className="min-w-0">
<span className="block text-sm font-medium text-slate-200">{item.label}</span>
<span className="block text-xs text-slate-500">{friendlyItemType(item.item_type)}{item.unit ? `, ${item.unit}` : ""}</span>
</span>
</button>
))}
</div>
</div>
))
) : (
<div className="p-6 text-sm text-slate-400">Run discovery to choose monitorable items.</div>
)}
</div>
</div>
</div>
</section>
</div>
);
}
function groupItems(items: SnmpDiscoveryItem[]) {
const groups = new Map<string, SnmpDiscoveryItem[]>();
for (const item of items) {
groups.set(item.group, [...(groups.get(item.group) ?? []), item]);
}
return Array.from(groups, ([group, groupItems]) => ({ group, items: groupItems }));
}
function SummaryItem({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-xs uppercase text-slate-500">{label}</div>
<div className="mt-1 text-sm font-medium text-slate-200">{value}</div>
</div>
);
}
function Status({ value }: { value: string }) {
const classes = value === "up" ? "border-teal-500/40 bg-teal-950/40 text-teal-200" : value === "down" ? "border-red-500/40 bg-red-950/40 text-red-200" : "border-slate-600 bg-slate-900 text-slate-300";
return <span className={`inline-flex h-7 w-fit items-center rounded-md border px-2 text-xs font-medium ${classes}`}>{value}</span>;
}
function formatDuration(seconds?: number | null) {
if (!seconds) return "Unknown";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
if (days) return `${days}d ${hours}h`;
return `${hours}h ${Math.floor((seconds % 3600) / 60)}m`;
}
function formatSpeed(value?: number | null) {
if (!value) return "Unknown speed";
if (value >= 1_000_000_000) return `${value / 1_000_000_000} Gbps`;
if (value >= 1_000_000) return `${value / 1_000_000} Mbps`;
if (value >= 1_000) return `${value / 1_000} Kbps`;
return `${value} bps`;
}
function friendlyItemType(value: string) {
return value.replaceAll("_", " ");
}
+32
View File
@@ -140,6 +140,38 @@ export interface SnmpCredentialProfileUpdate {
retries?: number;
}
export interface SnmpDiscoveryRequest {
host: string;
credential_profile_id: number;
}
export interface SnmpDiscoveredInterface {
index: number;
name: string;
description?: string | null;
admin_status?: string | null;
oper_status?: string | null;
speed_bps?: number | null;
}
export interface SnmpDiscoveryItem {
item_id: string;
item_type: string;
group: string;
label: string;
unit?: string | null;
}
export interface SnmpDiscoveryResult {
host: string;
credential_profile_id: number;
device_name?: string | null;
description?: string | null;
uptime_seconds?: number | null;
interfaces: SnmpDiscoveredInterface[];
monitorable_items: SnmpDiscoveryItem[];
}
export interface WebsiteMonitorCreate {
name: string;
url: string;