|
|
|
@@ -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("_", " ");
|
|
|
|
|
}
|