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
+1 -1
View File
@@ -76,7 +76,7 @@ Issue source docs:
- `docs/progress.md`
- `docs/roadmap.md`
Current completed items include TLS expiry monitor support, HTTP/website checks, ping and TCP port checks, basic alert evaluation, alert rule editing UI, incident actions, webhook notification channels, SNMPv2c credential profiles, and the SNMP device discovery API. The next recommended implementation issue is guided SNMP discovery UI, followed by SNMP monitor selection.
Current completed items include TLS expiry monitor support, HTTP/website checks, ping and TCP port checks, basic alert evaluation, alert rule editing UI, incident actions, webhook notification channels, SNMPv2c credential profiles, the SNMP device discovery API, and guided SNMP discovery UI. The next recommended implementation issue is creating monitors from SNMP discovery selections.
## Guardrails
+19 -9
View File
@@ -73,6 +73,13 @@ Implemented SNMP device discovery API slice:
- Initial monitorable items include device uptime, interface status, interface traffic, and interface errors/discards.
- Backend tests cover successful discovery, missing profiles, unusable secrets, probe failures, secret masking, and raw OID avoidance.
Implemented guided SNMP discovery UI slice:
- Discovery page can run SNMP discovery against a host using a saved SNMP profile.
- UI shows friendly device summary details and discovered interfaces.
- UI displays friendly monitorable item groups and supports selecting items for the next monitor-creation step.
- Normal discovery UI avoids raw SNMP OIDs and saved secret values.
## Known Gaps
- General credential vault workflows beyond SNMP profiles are not complete.
@@ -80,8 +87,8 @@ Implemented SNMP device discovery API slice:
- User management UI is not implemented.
- Role management is basic and needs full admin flows.
- Richer alert condition editing is not implemented yet.
- Guided SNMP device discovery and friendly SNMP monitor selection are not implemented yet.
- SNMP monitor creation and collection for interface status, traffic counters, errors, uptime, CPU, and memory checks are not implemented yet.
- SNMP monitor creation from selected discovery items is not implemented yet.
- SNMP collection for interface status, traffic counters, errors, uptime, CPU, and memory checks is not implemented yet.
- Notification routing/policies are not implemented; all enabled webhook channels receive incident notifications.
- Email/SMTP notifications are not implemented yet.
- Graphing exists only as placeholders; metric visualization is not implemented.
@@ -91,13 +98,16 @@ Implemented SNMP device discovery API slice:
## Recommended Next Work
1. Add guided SNMP discovery UI.
2. Create monitors from SNMP discovery selections.
3. Add SNMP interface status, traffic, errors, uptime, CPU, and memory collection.
4. Add notification policy/routing controls.
5. Add email/SMTP notification channel.
6. Add audit event writes for auth, monitor, credential, notification, and incident actions.
7. Build general credential vault workflows with masked secret handling.
1. Create monitors from SNMP discovery selections.
2. Add SNMP interface status, traffic, errors, uptime, CPU, and memory collection.
3. Add notification policy/routing controls.
4. Add email/SMTP notification channel.
5. Add audit event writes for auth, monitor, credential, notification, and incident actions.
6. Build general credential vault workflows with masked secret handling.
7. Add user administration UI.
8. Add graphs for website response time and monitor status history.
9. Add richer alert condition editing.
10. Add frontend coverage for monitor, alert, and notification workflows.
8. Add user administration UI.
9. Add graphs for website response time and monitor status history.
10. Add richer alert condition editing.
+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;