Add SNMP credential profiles
This commit is contained in:
@@ -10,6 +10,9 @@ import type {
|
||||
NotificationChannelCreate,
|
||||
NotificationChannelUpdate,
|
||||
PingMonitorCreate,
|
||||
SnmpCredentialProfile,
|
||||
SnmpCredentialProfileCreate,
|
||||
SnmpCredentialProfileUpdate,
|
||||
TcpMonitorCreate,
|
||||
User,
|
||||
WebsiteMonitorCreate,
|
||||
@@ -121,4 +124,19 @@ export const api = {
|
||||
request<void>(`/notifications/channels/${channelId}`, token, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
snmpCredentialProfiles: (token: string) => request<SnmpCredentialProfile[]>("/credentials/snmp", token),
|
||||
createSnmpCredentialProfile: (token: string, payload: SnmpCredentialProfileCreate) =>
|
||||
request<SnmpCredentialProfile>("/credentials/snmp", token, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
updateSnmpCredentialProfile: (token: string, profileId: number, payload: SnmpCredentialProfileUpdate) =>
|
||||
request<SnmpCredentialProfile>(`/credentials/snmp/${profileId}`, token, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
deleteSnmpCredentialProfile: (token: string, profileId: number) =>
|
||||
request<void>(`/credentials/snmp/${profileId}`, token, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { api } from "../api/client";
|
||||
import { Shell, type PageId } from "../components/Shell";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import { AlertsPage } from "../pages/AlertsPage";
|
||||
import { CredentialsPage } from "../pages/CredentialsPage";
|
||||
import { DashboardPage } from "../pages/DashboardPage";
|
||||
import { ListPage } from "../pages/ListPage";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
@@ -88,7 +89,7 @@ export function App() {
|
||||
) : null}
|
||||
{page === "discovery" ? <ListPage title="Discovery" description="Guided target discovery with monitor and alert choices." /> : null}
|
||||
{page === "graphs" ? <ListPage title="Graphs" description="Metric history and dashboard-ready charts." /> : null}
|
||||
{page === "credentials" ? <ListPage title="Credentials" description="Encrypted reusable credentials with masked secrets." /> : null}
|
||||
{page === "credentials" ? <CredentialsPage token={auth.token} /> : null}
|
||||
{page === "notifications" ? <NotificationsPage token={auth.token} /> : null}
|
||||
{page === "admin" ? <ListPage title="Admin" description="Users, roles, authentication settings, and global configuration." /> : null}
|
||||
</Shell>
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { FormEvent, useEffect, useState } from "react";
|
||||
import { KeyRound, Pencil, Plus, RefreshCw, Trash2, X } from "lucide-react";
|
||||
|
||||
import { api } from "../api/client";
|
||||
import { Button } from "../components/Button";
|
||||
import type { SnmpCredentialProfile } from "../types/api";
|
||||
|
||||
interface CredentialsPageProps {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function CredentialsPage({ token }: CredentialsPageProps) {
|
||||
const [profiles, setProfiles] = useState<SnmpCredentialProfile[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [community, setCommunity] = useState("");
|
||||
const [port, setPort] = useState(161);
|
||||
const [timeoutSeconds, setTimeoutSeconds] = useState(5);
|
||||
const [retries, setRetries] = useState(1);
|
||||
const [editingProfileId, setEditingProfileId] = useState<number | null>(null);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
setProfiles(await api.snmpCredentialProfiles(token));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => setProfiles([]));
|
||||
}, [token]);
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
setSubmitting(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
if (editingProfileId) {
|
||||
await api.updateSnmpCredentialProfile(token, editingProfileId, {
|
||||
name,
|
||||
version: "2c",
|
||||
community: community.trim() ? community : undefined,
|
||||
port,
|
||||
timeout_seconds: timeoutSeconds,
|
||||
retries,
|
||||
});
|
||||
} else {
|
||||
await api.createSnmpCredentialProfile(token, {
|
||||
name,
|
||||
version: "2c",
|
||||
community,
|
||||
port,
|
||||
timeout_seconds: timeoutSeconds,
|
||||
retries,
|
||||
});
|
||||
}
|
||||
resetForm();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Could not save SNMP profile");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(profile: SnmpCredentialProfile) {
|
||||
setEditingProfileId(profile.id);
|
||||
setName(profile.name);
|
||||
setCommunity("");
|
||||
setPort(profile.port);
|
||||
setTimeoutSeconds(profile.timeout_seconds);
|
||||
setRetries(profile.retries);
|
||||
setMessage(null);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
setEditingProfileId(null);
|
||||
setName("");
|
||||
setCommunity("");
|
||||
setPort(161);
|
||||
setTimeoutSeconds(5);
|
||||
setRetries(1);
|
||||
}
|
||||
|
||||
async function deleteProfile(profileId: number) {
|
||||
setBusyId(profileId);
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.deleteSnmpCredentialProfile(token, profileId);
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setMessage(err instanceof Error ? err.message : "Could not delete SNMP profile");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
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">Credentials</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">Reusable SNMP profiles for guided device discovery.</p>
|
||||
</div>
|
||||
<Button variant="ghost" onClick={refresh}>
|
||||
<RefreshCw size={16} />
|
||||
Refresh
|
||||
</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">
|
||||
<KeyRound size={18} className="text-pulse" />
|
||||
<h2 className="text-base font-semibold">{editingProfileId ? "Edit SNMP Profile" : "Add SNMP Profile"}</h2>
|
||||
</div>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Name</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={name} onChange={(event) => setName(event.target.value)} required />
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Version</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="2c" disabled>
|
||||
<option value="2c">SNMPv2c</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Community</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={community} onChange={(event) => setCommunity(event.target.value)} required={!editingProfileId} type="password" />
|
||||
{editingProfileId ? <span className="text-xs text-slate-500">Leave blank to keep the saved community string.</span> : null}
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Port</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={port} onChange={(event) => setPort(Number(event.target.value))} min={1} max={65535} type="number" />
|
||||
</label>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Timeout</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={timeoutSeconds} onChange={(event) => setTimeoutSeconds(Number(event.target.value))} min={1} max={120} type="number" />
|
||||
</label>
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm text-slate-300">Retries</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={retries} onChange={(event) => setRetries(Number(event.target.value))} min={0} max={10} type="number" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{message ? <div className="rounded-md border border-line bg-slate-950 p-3 text-sm text-slate-300">{message}</div> : null}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{editingProfileId ? (
|
||||
<Button className="flex-1" onClick={resetForm} type="button" variant="ghost">
|
||||
<X size={16} />
|
||||
Cancel
|
||||
</Button>
|
||||
) : null}
|
||||
<Button className="flex-1" disabled={submitting} type="submit">
|
||||
<Plus size={16} />
|
||||
{submitting ? "Saving..." : editingProfileId ? "Save Profile" : "Create Profile"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="rounded-md border border-line bg-[#0d131c]">
|
||||
<div className="border-b border-line p-4">
|
||||
<h2 className="text-base font-semibold">SNMP Profiles</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-line">
|
||||
{profiles.length ? (
|
||||
profiles.map((profile) => (
|
||||
<div key={profile.id} className="grid gap-3 p-4 md:grid-cols-[1fr_90px_160px_190px] md:items-center">
|
||||
<div>
|
||||
<div className="font-medium">{profile.name}</div>
|
||||
<div className="text-sm text-slate-400">SNMPv{profile.version} on port {profile.port}</div>
|
||||
<div className="text-xs text-slate-500">{profile.has_secret ? "Community stored" : "No community stored"}</div>
|
||||
</div>
|
||||
<span className="text-sm uppercase text-slate-400">SNMP</span>
|
||||
<div className="text-sm text-slate-300">{profile.timeout_seconds}s timeout, {profile.retries} retries</div>
|
||||
<div className="flex flex-wrap gap-2 md:justify-end">
|
||||
<Button className="h-8 px-3" disabled={busyId === profile.id} onClick={() => startEdit(profile)} title="Edit profile" type="button" variant="ghost">
|
||||
<Pencil className="text-slate-100" size={15} />
|
||||
Edit
|
||||
</Button>
|
||||
<Button className="h-8 px-3 text-red-100" disabled={busyId === profile.id} onClick={() => deleteProfile(profile.id)} title="Delete profile" type="button" variant="ghost">
|
||||
<Trash2 className="text-red-200" size={15} />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-6 text-sm text-slate-400">No SNMP credential profiles yet.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -109,6 +109,37 @@ export interface NotificationChannelUpdate {
|
||||
is_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SnmpCredentialProfile {
|
||||
id: number;
|
||||
name: string;
|
||||
credential_type: string;
|
||||
version: string;
|
||||
port: number;
|
||||
timeout_seconds: number;
|
||||
retries: number;
|
||||
has_secret: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SnmpCredentialProfileCreate {
|
||||
name: string;
|
||||
version: "2c";
|
||||
community: string;
|
||||
port: number;
|
||||
timeout_seconds: number;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
export interface SnmpCredentialProfileUpdate {
|
||||
name?: string;
|
||||
version?: "2c";
|
||||
community?: string;
|
||||
port?: number;
|
||||
timeout_seconds?: number;
|
||||
retries?: number;
|
||||
}
|
||||
|
||||
export interface WebsiteMonitorCreate {
|
||||
name: string;
|
||||
url: string;
|
||||
|
||||
Reference in New Issue
Block a user