Add SNMP credential profiles
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth.dependencies import get_current_user, require_role
|
||||
from app.core.secrets import encrypt_secret
|
||||
from app.db.session import get_db
|
||||
from app.models import Credential, User
|
||||
from app.schemas.core import SnmpCredentialProfileCreate, SnmpCredentialProfileRead, SnmpCredentialProfileUpdate
|
||||
|
||||
router = APIRouter(prefix="/credentials", tags=["credentials"])
|
||||
|
||||
SNMP_CREDENTIAL_TYPE = "snmp"
|
||||
|
||||
|
||||
def _snmp_profile_to_read(profile: Credential) -> SnmpCredentialProfileRead:
|
||||
extra = dict(profile.extra or {})
|
||||
return SnmpCredentialProfileRead(
|
||||
id=profile.id,
|
||||
name=profile.name,
|
||||
credential_type=profile.credential_type,
|
||||
version=str(extra.get("version") or "2c"),
|
||||
port=int(extra.get("port") or 161),
|
||||
timeout_seconds=int(extra.get("timeout_seconds") or 5),
|
||||
retries=int(extra.get("retries") or 1),
|
||||
has_secret=bool(profile.encrypted_secret),
|
||||
created_at=profile.created_at,
|
||||
updated_at=profile.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/snmp", response_model=list[SnmpCredentialProfileRead])
|
||||
def list_snmp_profiles(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[SnmpCredentialProfileRead]:
|
||||
profiles = db.scalars(
|
||||
select(Credential).where(Credential.credential_type == SNMP_CREDENTIAL_TYPE).order_by(Credential.name)
|
||||
).all()
|
||||
return [_snmp_profile_to_read(profile) for profile in profiles]
|
||||
|
||||
|
||||
@router.post("/snmp", response_model=SnmpCredentialProfileRead)
|
||||
def create_snmp_profile(
|
||||
payload: SnmpCredentialProfileCreate,
|
||||
_: User = Depends(require_role("admin")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SnmpCredentialProfileRead:
|
||||
profile = Credential(
|
||||
name=payload.name,
|
||||
credential_type=SNMP_CREDENTIAL_TYPE,
|
||||
encrypted_secret=encrypt_secret(payload.community),
|
||||
extra={
|
||||
"version": payload.version,
|
||||
"port": payload.port,
|
||||
"timeout_seconds": payload.timeout_seconds,
|
||||
"retries": payload.retries,
|
||||
},
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
db.refresh(profile)
|
||||
return _snmp_profile_to_read(profile)
|
||||
|
||||
|
||||
@router.patch("/snmp/{profile_id}", response_model=SnmpCredentialProfileRead)
|
||||
def update_snmp_profile(
|
||||
profile_id: int,
|
||||
payload: SnmpCredentialProfileUpdate,
|
||||
_: User = Depends(require_role("admin")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> SnmpCredentialProfileRead:
|
||||
profile = db.get(Credential, profile_id)
|
||||
if profile is None or profile.credential_type != SNMP_CREDENTIAL_TYPE:
|
||||
raise HTTPException(status_code=404, detail="SNMP credential profile not found")
|
||||
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
community = changes.pop("community", None)
|
||||
if community is not None:
|
||||
profile.encrypted_secret = encrypt_secret(community)
|
||||
if "name" in changes:
|
||||
profile.name = changes.pop("name")
|
||||
|
||||
next_extra = dict(profile.extra or {})
|
||||
for field, value in changes.items():
|
||||
next_extra[field] = value
|
||||
profile.extra = next_extra
|
||||
|
||||
db.commit()
|
||||
db.refresh(profile)
|
||||
return _snmp_profile_to_read(profile)
|
||||
|
||||
|
||||
@router.delete("/snmp/{profile_id}", status_code=204)
|
||||
def delete_snmp_profile(
|
||||
profile_id: int,
|
||||
_: User = Depends(require_role("admin")),
|
||||
db: Session = Depends(get_db),
|
||||
) -> None:
|
||||
profile = db.get(Credential, profile_id)
|
||||
if profile is None or profile.credential_type != SNMP_CREDENTIAL_TYPE:
|
||||
raise HTTPException(status_code=404, detail="SNMP credential profile not found")
|
||||
db.delete(profile)
|
||||
db.commit()
|
||||
+2
-1
@@ -6,7 +6,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
|
||||
from app.api import alerts, assets, auth, health, monitors, notifications
|
||||
from app.api import alerts, assets, auth, credentials, health, monitors, notifications
|
||||
from app.core.config import settings
|
||||
from app.db.session import SessionLocal
|
||||
from app.services.bootstrap import ensure_initial_admin
|
||||
@@ -45,3 +45,4 @@ app.include_router(assets.router)
|
||||
app.include_router(monitors.router)
|
||||
app.include_router(alerts.router)
|
||||
app.include_router(notifications.router)
|
||||
app.include_router(credentials.router)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
@@ -179,3 +180,34 @@ class NotificationChannelRead(BaseModel):
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
class SnmpCredentialProfileCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=160)
|
||||
version: Literal["2c"] = "2c"
|
||||
community: str = Field(min_length=1, max_length=255)
|
||||
port: int = Field(default=161, ge=1, le=65535)
|
||||
timeout_seconds: int = Field(default=5, ge=1, le=120)
|
||||
retries: int = Field(default=1, ge=0, le=10)
|
||||
|
||||
|
||||
class SnmpCredentialProfileUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=160)
|
||||
version: Literal["2c"] | None = None
|
||||
community: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
timeout_seconds: int | None = Field(default=None, ge=1, le=120)
|
||||
retries: int | None = Field(default=None, ge=0, le=10)
|
||||
|
||||
|
||||
class SnmpCredentialProfileRead(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
credential_type: str
|
||||
version: str
|
||||
port: int
|
||||
timeout_seconds: int
|
||||
retries: int
|
||||
has_secret: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.secrets import decrypt_secret
|
||||
from app.models import Credential
|
||||
|
||||
|
||||
def test_create_snmp_profile_encrypts_community_and_masks_secret(client: TestClient, db_session: Session) -> None:
|
||||
response = client.post(
|
||||
"/credentials/snmp",
|
||||
json={
|
||||
"name": "Core Switch Read Only",
|
||||
"version": "2c",
|
||||
"community": "public-read",
|
||||
"port": 161,
|
||||
"timeout_seconds": 7,
|
||||
"retries": 2,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["name"] == "Core Switch Read Only"
|
||||
assert body["credential_type"] == "snmp"
|
||||
assert body["version"] == "2c"
|
||||
assert body["port"] == 161
|
||||
assert body["timeout_seconds"] == 7
|
||||
assert body["retries"] == 2
|
||||
assert body["has_secret"] is True
|
||||
assert "community" not in body
|
||||
assert "encrypted_secret" not in body
|
||||
|
||||
profile = db_session.get(Credential, body["id"])
|
||||
assert profile is not None
|
||||
assert profile.credential_type == "snmp"
|
||||
assert profile.encrypted_secret != "public-read"
|
||||
assert decrypt_secret(profile.encrypted_secret) == "public-read"
|
||||
assert profile.extra == {"version": "2c", "port": 161, "timeout_seconds": 7, "retries": 2}
|
||||
|
||||
|
||||
def test_update_snmp_profile_without_community_preserves_saved_secret(client: TestClient, db_session: Session) -> None:
|
||||
create_response = client.post(
|
||||
"/credentials/snmp",
|
||||
json={
|
||||
"name": "Access Switch",
|
||||
"community": "access-read",
|
||||
},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
original_secret = db_session.get(Credential, profile_id).encrypted_secret
|
||||
|
||||
update_response = client.patch(
|
||||
f"/credentials/snmp/{profile_id}",
|
||||
json={
|
||||
"name": "Access Switches",
|
||||
"timeout_seconds": 9,
|
||||
"retries": 3,
|
||||
},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
body = update_response.json()
|
||||
assert body["name"] == "Access Switches"
|
||||
assert body["timeout_seconds"] == 9
|
||||
assert body["retries"] == 3
|
||||
assert body["has_secret"] is True
|
||||
assert "community" not in body
|
||||
assert "encrypted_secret" not in body
|
||||
|
||||
profile = db_session.get(Credential, profile_id)
|
||||
assert profile is not None
|
||||
assert profile.encrypted_secret == original_secret
|
||||
assert decrypt_secret(profile.encrypted_secret) == "access-read"
|
||||
|
||||
|
||||
def test_update_snmp_profile_can_rotate_community(client: TestClient, db_session: Session) -> None:
|
||||
create_response = client.post(
|
||||
"/credentials/snmp",
|
||||
json={
|
||||
"name": "Router",
|
||||
"community": "old-community",
|
||||
},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
|
||||
update_response = client.patch(
|
||||
f"/credentials/snmp/{profile_id}",
|
||||
json={
|
||||
"community": "new-community",
|
||||
},
|
||||
)
|
||||
|
||||
assert update_response.status_code == 200
|
||||
assert update_response.json()["has_secret"] is True
|
||||
|
||||
profile = db_session.get(Credential, profile_id)
|
||||
assert profile is not None
|
||||
assert decrypt_secret(profile.encrypted_secret) == "new-community"
|
||||
|
||||
|
||||
def test_list_and_delete_snmp_profiles(client: TestClient) -> None:
|
||||
create_response = client.post(
|
||||
"/credentials/snmp",
|
||||
json={
|
||||
"name": "Distribution Switch",
|
||||
"community": "dist-read",
|
||||
},
|
||||
)
|
||||
profile_id = create_response.json()["id"]
|
||||
|
||||
list_response = client.get("/credentials/snmp")
|
||||
assert list_response.status_code == 200
|
||||
profiles = list_response.json()
|
||||
assert len(profiles) == 1
|
||||
assert profiles[0]["id"] == profile_id
|
||||
assert profiles[0]["has_secret"] is True
|
||||
assert "community" not in profiles[0]
|
||||
assert "encrypted_secret" not in profiles[0]
|
||||
|
||||
delete_response = client.delete(f"/credentials/snmp/{profile_id}")
|
||||
assert delete_response.status_code == 204
|
||||
|
||||
list_after_delete_response = client.get("/credentials/snmp")
|
||||
assert list_after_delete_response.status_code == 200
|
||||
assert list_after_delete_response.json() == []
|
||||
@@ -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, and webhook notification channels. The next recommended implementation issue is SNMP credential profiles and guided SNMP discovery, 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, and SNMPv2c credential profiles. The next recommended implementation issue is SNMP device discovery API, followed by guided SNMP discovery UI and monitor selection.
|
||||
|
||||
## Guardrails
|
||||
|
||||
|
||||
+22
-13
@@ -57,15 +57,23 @@ Implemented monitor and notification test coverage:
|
||||
- Notification channel tests verify saved webhook URLs are encrypted and are not returned by create, list, or update responses.
|
||||
- Worker scheduler tests cover alert threshold incident opening, recovery resolution, notification history deduplication, and alert cooldown behavior.
|
||||
|
||||
Implemented SNMP credential profile slice:
|
||||
|
||||
- Backend API supports reusable SNMP credential profiles at `/credentials/snmp`.
|
||||
- Initial profile support is SNMPv2c community credentials with port, timeout, and retry settings.
|
||||
- Community strings are encrypted at rest and are not returned by create, list, or update responses.
|
||||
- Credentials page can create, edit, rotate, and delete SNMP profiles.
|
||||
- Backend tests cover SNMP profile secret masking, encryption, update preservation, rotation, listing, and deletion.
|
||||
|
||||
## Known Gaps
|
||||
|
||||
- Credential vault UI and real credential encryption workflows are not complete.
|
||||
- General credential vault workflows beyond SNMP profiles are not complete.
|
||||
- Audit logging tables exist, but events are not consistently written yet.
|
||||
- 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 credential profiles, interface status, traffic counters, errors, uptime, CPU, and memory checks are not implemented yet.
|
||||
- SNMP interface status, traffic counters, errors, uptime, CPU, and memory checks are 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.
|
||||
@@ -75,17 +83,18 @@ Implemented monitor and notification test coverage:
|
||||
|
||||
## Recommended Next Work
|
||||
|
||||
1. Add SNMP credential profiles and guided SNMP device discovery.
|
||||
2. Add SNMP discovery selection UI to choose what to monitor and alert on.
|
||||
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 credential vault UI with masked secret handling.
|
||||
8. Add user administration UI.
|
||||
9. Add graphs for website response time and monitor status history.
|
||||
10. Add richer alert condition editing.
|
||||
11. Add frontend coverage for monitor, alert, and notification workflows.
|
||||
1. Add SNMP device discovery API.
|
||||
2. Add guided SNMP discovery UI.
|
||||
3. Create monitors from SNMP discovery selections.
|
||||
4. Add SNMP interface status, traffic, errors, uptime, CPU, and memory collection.
|
||||
5. Add notification policy/routing controls.
|
||||
6. Add email/SMTP notification channel.
|
||||
7. Add audit event writes for auth, monitor, credential, notification, and incident actions.
|
||||
8. Build general credential vault workflows with masked secret handling.
|
||||
9. Add user administration UI.
|
||||
10. Add graphs for website response time and monitor status history.
|
||||
11. Add richer alert condition editing.
|
||||
12. Add frontend coverage for monitor, alert, and notification workflows.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
|
||||
@@ -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