From bd6c508c94cf053e8b8dd2fc841826b7101055ff Mon Sep 17 00:00:00 2001 From: Keith Smith Date: Sat, 23 May 2026 21:07:05 -0600 Subject: [PATCH] Add asset-based monitor setup --- backend/app/api/monitors.py | 108 ++++-- backend/app/schemas/core.py | 11 + backend/tests/test_monitors.py | 141 +++++++- docs/agent-handoff.md | 16 +- docs/progress.md | 21 +- frontend/src/api/client.ts | 16 + frontend/src/app/App.tsx | 42 +-- frontend/src/pages/AssetsPage.tsx | 558 ++++++++++++++++++++++++++++++ frontend/src/types/api.ts | 19 + 9 files changed, 858 insertions(+), 74 deletions(-) create mode 100644 frontend/src/pages/AssetsPage.tsx diff --git a/backend/app/api/monitors.py b/backend/app/api/monitors.py index e889525..a47015b 100644 --- a/backend/app/api/monitors.py +++ b/backend/app/api/monitors.py @@ -4,14 +4,42 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import func, select from sqlalchemy.orm import Session +from app.api.credentials import SNMP_CREDENTIAL_TYPE from app.auth.dependencies import get_current_user, require_role from app.db.session import get_db -from app.models import AlertRule, Asset, CheckResult, Incident, Monitor, User -from app.schemas.core import CheckResultRead, MonitorCreate, MonitorRead, MonitorUpdate, PingMonitorCreate, TcpMonitorCreate, WebsiteMonitorCreate +from app.models import AlertRule, Asset, CheckResult, Credential, Incident, Monitor, User +from app.schemas.core import ( + CheckResultRead, + MonitorCreate, + MonitorRead, + MonitorUpdate, + PingMonitorCreate, + SnmpMonitorsCreate, + TcpMonitorCreate, + WebsiteMonitorCreate, +) router = APIRouter(prefix="/monitors", tags=["monitors"]) +def _get_asset_or_404(db: Session, asset_id: int) -> Asset: + asset = db.get(Asset, asset_id) + if asset is None: + raise HTTPException(status_code=404, detail="Asset not found") + return asset + + +def _resolve_asset_id(db: Session, asset_id: int | None, create_asset: bool, asset: Asset) -> int | None: + if asset_id is not None: + _get_asset_or_404(db, asset_id) + return asset_id + if not create_asset: + return None + db.add(asset) + db.flush() + return asset.id + + @router.get("", response_model=list[MonitorRead]) def list_monitors(_: User = Depends(get_current_user), db: Session = Depends(get_db)) -> list[Monitor]: return list(db.scalars(select(Monitor).order_by(Monitor.name)).all()) @@ -23,6 +51,8 @@ def create_monitor( _: User = Depends(require_role("admin")), db: Session = Depends(get_db), ) -> Monitor: + if payload.asset_id is not None: + _get_asset_or_404(db, payload.asset_id) monitor = Monitor(**payload.model_dump()) db.add(monitor) db.commit() @@ -36,12 +66,12 @@ def create_website_monitor( _: User = Depends(require_role("admin")), db: Session = Depends(get_db), ) -> Monitor: - asset_id: int | None = None - if payload.create_asset: - asset = Asset(name=payload.name, asset_type="website", address=payload.url, status="unknown", extra={}) - db.add(asset) - db.flush() - asset_id = asset.id + asset_id = _resolve_asset_id( + db, + payload.asset_id, + payload.create_asset, + Asset(name=payload.name, asset_type="website", address=payload.url, status="unknown", extra={}), + ) monitor = Monitor( asset_id=asset_id, @@ -86,12 +116,12 @@ def create_ping_monitor( _: User = Depends(require_role("admin")), db: Session = Depends(get_db), ) -> Monitor: - asset_id: int | None = None - if payload.create_asset: - asset = Asset(name=payload.name, asset_type="host", address=payload.host, status="unknown", extra={}) - db.add(asset) - db.flush() - asset_id = asset.id + asset_id = _resolve_asset_id( + db, + payload.asset_id, + payload.create_asset, + Asset(name=payload.name, asset_type="host", address=payload.host, status="unknown", extra={}), + ) monitor = Monitor( asset_id=asset_id, @@ -129,13 +159,13 @@ def create_tcp_monitor( _: User = Depends(require_role("admin")), db: Session = Depends(get_db), ) -> Monitor: - asset_id: int | None = None target = f"{payload.host}:{payload.port}" - if payload.create_asset: - asset = Asset(name=payload.name, asset_type="tcp_service", address=target, status="unknown", extra={}) - db.add(asset) - db.flush() - asset_id = asset.id + asset_id = _resolve_asset_id( + db, + payload.asset_id, + payload.create_asset, + Asset(name=payload.name, asset_type="tcp_service", address=target, status="unknown", extra={}), + ) monitor = Monitor( asset_id=asset_id, @@ -167,6 +197,44 @@ def create_tcp_monitor( return monitor +@router.post("/snmp/from-discovery", response_model=list[MonitorRead]) +def create_snmp_monitors_from_discovery( + payload: SnmpMonitorsCreate, + _: User = Depends(require_role("admin")), + db: Session = Depends(get_db), +) -> list[Monitor]: + asset = _get_asset_or_404(db, payload.asset_id) + profile = db.get(Credential, payload.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") + + monitors: list[Monitor] = [] + for item in payload.selected_items: + monitor = Monitor( + asset_id=asset.id, + name=f"{asset.name} {item.label}", + monitor_type="snmp", + target=payload.host, + config={ + "credential_profile_id": payload.credential_profile_id, + "item_id": item.item_id, + "item_type": item.item_type, + "group": item.group, + "label": item.label, + "unit": item.unit, + }, + interval_seconds=payload.interval_seconds, + status="unknown", + ) + db.add(monitor) + monitors.append(monitor) + + db.commit() + for monitor in monitors: + db.refresh(monitor) + return monitors + + @router.get("/{monitor_id}", response_model=MonitorRead) def get_monitor(monitor_id: int, _: User = Depends(get_current_user), db: Session = Depends(get_db)) -> Monitor: monitor = db.get(Monitor, monitor_id) diff --git a/backend/app/schemas/core.py b/backend/app/schemas/core.py index 034e690..d712463 100644 --- a/backend/app/schemas/core.py +++ b/backend/app/schemas/core.py @@ -60,6 +60,7 @@ class MonitorRead(MonitorCreate): class WebsiteMonitorCreate(BaseModel): name: str = Field(min_length=1, max_length=160) url: str = Field(min_length=1, max_length=512) + asset_id: int | None = None expected_status: int = Field(default=200, ge=100, le=599) expected_text: str | None = None unexpected_text: str | None = None @@ -76,6 +77,7 @@ class WebsiteMonitorCreate(BaseModel): class PingMonitorCreate(BaseModel): name: str = Field(min_length=1, max_length=160) host: str = Field(min_length=1, max_length=255) + asset_id: int | None = None timeout_seconds: int = Field(default=5, ge=1, le=60) interval_seconds: int = Field(default=60, ge=10) create_asset: bool = True @@ -88,6 +90,7 @@ class TcpMonitorCreate(BaseModel): name: str = Field(min_length=1, max_length=160) host: str = Field(min_length=1, max_length=255) port: int = Field(ge=1, le=65535) + asset_id: int | None = None timeout_seconds: int = Field(default=5, ge=1, le=60) interval_seconds: int = Field(default=60, ge=10) create_asset: bool = True @@ -234,6 +237,14 @@ class SnmpDiscoveryItemRead(BaseModel): unit: str | None = None +class SnmpMonitorsCreate(BaseModel): + host: str = Field(min_length=1, max_length=255) + asset_id: int + credential_profile_id: int + selected_items: list[SnmpDiscoveryItemRead] = Field(min_length=1) + interval_seconds: int = Field(default=60, ge=10) + + class SnmpDiscoveryRead(BaseModel): host: str credential_profile_id: int diff --git a/backend/tests/test_monitors.py b/backend/tests/test_monitors.py index 8e48543..fe6c720 100644 --- a/backend/tests/test_monitors.py +++ b/backend/tests/test_monitors.py @@ -2,7 +2,8 @@ from fastapi.testclient import TestClient from sqlalchemy import select from sqlalchemy.orm import Session -from app.models import AlertRule, Asset, Monitor +from app.core.secrets import encrypt_secret +from app.models import AlertRule, Asset, Credential, Monitor def test_create_website_monitor_creates_asset_and_alert_rule(client: TestClient, db_session: Session) -> None: @@ -72,3 +73,141 @@ def test_create_website_monitor_can_skip_default_alert_rule(client: TestClient, assert monitor is not None assert monitor.asset_id is None assert db_session.scalars(select(AlertRule).where(AlertRule.monitor_id == monitor.id)).all() == [] + + +def test_create_website_monitor_can_attach_existing_asset_without_default_alert(client: TestClient, db_session: Session) -> None: + asset = Asset(name="Existing App", asset_type="application", address="app.example.com", status="unknown", extra={}) + db_session.add(asset) + db_session.commit() + + response = client.post( + "/monitors/website", + json={ + "name": "Existing App HTTPS", + "url": "https://app.example.com", + "asset_id": asset.id, + "create_asset": True, + "alert_enabled": False, + }, + ) + + assert response.status_code == 200 + body = response.json() + monitor = db_session.get(Monitor, body["id"]) + assert monitor is not None + assert monitor.asset_id == asset.id + assert db_session.scalars(select(AlertRule).where(AlertRule.monitor_id == monitor.id)).all() == [] + assert db_session.scalars(select(Asset)).all() == [asset] + + +def test_create_ping_monitor_rejects_missing_asset(client: TestClient) -> None: + response = client.post( + "/monitors/ping", + json={ + "name": "Missing Asset Ping", + "host": "192.0.2.10", + "asset_id": 999, + "create_asset": False, + "alert_enabled": False, + }, + ) + + assert response.status_code == 404 + + +def test_create_tcp_monitor_can_attach_existing_asset(client: TestClient, db_session: Session) -> None: + asset = Asset(name="Router", asset_type="network_device", address="192.0.2.1", status="unknown", extra={}) + db_session.add(asset) + db_session.commit() + + response = client.post( + "/monitors/tcp", + json={ + "name": "Router SSH", + "host": "192.0.2.1", + "port": 22, + "asset_id": asset.id, + "create_asset": False, + "alert_enabled": False, + }, + ) + + assert response.status_code == 200 + body = response.json() + assert body["asset_id"] == asset.id + assert body["target"] == "192.0.2.1:22" + + +def test_create_snmp_monitors_from_discovery_attaches_asset_and_skips_alerts(client: TestClient, db_session: Session) -> None: + asset = Asset(name="Core Switch", asset_type="network_device", address="192.0.2.10", status="unknown", extra={}) + profile = Credential( + name="Core Switch Read Only", + credential_type="snmp", + encrypted_secret=encrypt_secret("private-community"), + extra={"version": "2c"}, + ) + db_session.add_all([asset, profile]) + db_session.commit() + + response = client.post( + "/monitors/snmp/from-discovery", + json={ + "host": "192.0.2.10", + "asset_id": asset.id, + "credential_profile_id": profile.id, + "interval_seconds": 120, + "selected_items": [ + { + "item_id": "device.uptime", + "item_type": "device_uptime", + "group": "Device Health", + "label": "Device uptime", + "unit": "seconds", + }, + { + "item_id": "interface.1.status", + "item_type": "interface_status", + "group": "Interface uplink", + "label": "uplink status", + "unit": None, + }, + ], + }, + ) + + assert response.status_code == 200 + body = response.json() + assert len(body) == 2 + assert {monitor["name"] for monitor in body} == {"Core Switch Device uptime", "Core Switch uplink status"} + assert all(monitor["asset_id"] == asset.id for monitor in body) + assert all(monitor["monitor_type"] == "snmp" for monitor in body) + assert all(monitor["interval_seconds"] == 120 for monitor in body) + assert all("1.3.6" not in str(monitor["config"]) for monitor in body) + + monitor_ids = [monitor["id"] for monitor in body] + assert db_session.scalars(select(AlertRule).where(AlertRule.monitor_id.in_(monitor_ids))).all() == [] + + +def test_create_snmp_monitors_rejects_missing_profile(client: TestClient, db_session: Session) -> None: + asset = Asset(name="Core Switch", asset_type="network_device", address="192.0.2.10", status="unknown", extra={}) + db_session.add(asset) + db_session.commit() + + response = client.post( + "/monitors/snmp/from-discovery", + json={ + "host": "192.0.2.10", + "asset_id": asset.id, + "credential_profile_id": 999, + "selected_items": [ + { + "item_id": "device.uptime", + "item_type": "device_uptime", + "group": "Device Health", + "label": "Device uptime", + } + ], + }, + ) + + assert response.status_code == 404 diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index cc4495d..1a4a325 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -8,7 +8,7 @@ Last updated: 2026-05-23 - Local repository path: `/home/ksmith/projects/OrbitalWard` - Git remote: `https://git.firebugit.com/ksmith/OrbitalWard.git` - Main branch: `main` -- Latest pushed commit at last update: `3b75075 Rename project to OrbitalWard` +- Latest pushed commit at last update: `8b5dea1 Add guided SNMP discovery UI` The project was previously named InfraPulse. Do not reintroduce the old name in product copy, package names, environment variables, service names, or docs unless explicitly discussing historical context. @@ -34,15 +34,21 @@ OrbitalWard is a secure monitoring appliance focused on the v0.1 vertical slice: - Alert rules, incident opening/resolution, acknowledge, silence, and webhook notifications. - Generic webhook, Mattermost, and Zoom Team Chat notification channels. - Saved webhook URLs encrypted at rest and not returned to the UI. -- Guided SNMP device discovery is v0.1 scope, but not yet implemented. +- SNMPv2c credential profiles with encrypted community strings. +- Guided SNMP device discovery with friendly device, interface, and monitorable item results. +- Asset setup supports creating, selecting, and deleting assets, plus attaching ping, TCP, website, and SNMP monitors without creating alert rules automatically. ## Verification State -After the rename and TLS expiry work, these checks passed in Docker: +Recent Docker checks: -- `docker compose -f docker-compose.dev.yml up -d --build` - `docker compose -f docker-compose.dev.yml exec -T backend python -m pytest tests` - `docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck` +- `docker compose -f docker-compose.dev.yml exec -T frontend npm run build` + +Earlier rename and monitor work also verified: + +- `docker compose -f docker-compose.dev.yml up -d --build` - `docker compose -f docker-compose.dev.yml exec -T worker python -m compileall app` - Backend health returned `{"status":"ok","service":"orbitalward-backend"}`. - Direct worker probes for TCP and ICMP ping checks passed inside the Docker network. @@ -76,7 +82,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, the SNMP device discovery API, and guided SNMP discovery UI. The next recommended implementation issue is creating monitors from SNMP discovery selections. +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, guided SNMP discovery UI, and asset-based monitor setup. The next recommended implementation work is SNMP collection for configured SNMP monitors and friendly metric/profile mapping. ## Guardrails diff --git a/docs/progress.md b/docs/progress.md index 5026512..325528f 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -80,6 +80,16 @@ Implemented guided SNMP discovery UI slice: - 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. +Implemented asset-based monitor setup slice: + +- Assets page can create a new asset or select an existing asset before configuring monitors. +- Assets page can delete assets, with confirmation that attached monitors are also removed. +- Asset setup supports choosing ping, TCP, website, and SNMP monitoring in any combination. +- Website, ping, and TCP monitor APIs can attach new monitors to an existing asset without creating duplicate assets. +- Asset setup creates monitors without automatically creating alert rules; alerting remains managed separately. +- SNMP setup can run guided discovery from the asset flow and save selected friendly items as SNMP monitors attached to the asset. +- SNMP monitor creation stores friendly discovery metadata and avoids raw OIDs in normal UI/API responses. + ## Known Gaps - General credential vault workflows beyond SNMP profiles are not complete. @@ -87,8 +97,7 @@ Implemented guided SNMP discovery UI slice: - User management UI is not implemented. - Role management is basic and needs full admin flows. - Richer alert condition editing is 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. +- SNMP monitors can be configured, but SNMP collection for interface status, traffic counters, errors, uptime, CPU, memory, storage, and sensor 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. @@ -98,8 +107,8 @@ Implemented guided SNMP discovery UI slice: ## Recommended Next Work -1. Create monitors from SNMP discovery selections. -2. Add SNMP interface status, traffic, errors, uptime, CPU, and memory collection. +1. Add SNMP interface status, traffic, errors, uptime, CPU, memory, storage, and sensor collection. +2. Add SNMP profile mapping for friendly metric names across common vendors. 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. @@ -108,10 +117,6 @@ Implemented guided SNMP discovery UI slice: 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. -11. Add frontend coverage for monitor, alert, and notification workflows. ## Operational Notes diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 8f3b90d..fbe0007 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,6 +2,7 @@ import type { AlertRule, AlertRuleUpdate, Asset, + AssetCreate, CheckResult, Incident, Monitor, @@ -15,6 +16,7 @@ import type { SnmpCredentialProfileUpdate, SnmpDiscoveryRequest, SnmpDiscoveryResult, + SnmpMonitorsCreate, TcpMonitorCreate, User, WebsiteMonitorCreate, @@ -65,6 +67,15 @@ export async function login(email: string, password: string): Promise request("/auth/me", token), assets: (token: string) => request("/assets", token), + createAsset: (token: string, payload: AssetCreate) => + request("/assets", token, { + method: "POST", + body: JSON.stringify(payload), + }), + deleteAsset: (token: string, assetId: number) => + request(`/assets/${assetId}`, token, { + method: "DELETE", + }), monitors: (token: string) => request("/monitors", token), createWebsiteMonitor: (token: string, payload: WebsiteMonitorCreate) => request("/monitors/website", token, { @@ -146,4 +157,9 @@ export const api = { method: "POST", body: JSON.stringify(payload), }), + createSnmpMonitorsFromDiscovery: (token: string, payload: SnmpMonitorsCreate) => + request("/monitors/snmp/from-discovery", token, { + method: "POST", + body: JSON.stringify(payload), + }), }; diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 1120ac4..9583743 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -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 { AssetsPage } from "../pages/AssetsPage"; import { CredentialsPage } from "../pages/CredentialsPage"; import { DashboardPage } from "../pages/DashboardPage"; import { DiscoveryPage } from "../pages/DiscoveryPage"; @@ -75,9 +76,7 @@ export function App() { {page === "dashboard" ? : null} {page === "assets" ? ( - - [asset.name, asset.asset_type, asset.address ?? "-", asset.status])} columns={["Name", "Type", "Address", "Status"]} /> - + ) : null} {page === "websites" ? ( @@ -102,40 +101,3 @@ function getIncidentIdFromPath(): number | null { if (!match) return null; return Number(match[1]); } - -function SimpleTable({ columns, rows }: { columns: string[]; rows: string[][] }) { - return ( -
- - - - {columns.map((column) => ( - - ))} - - - - {rows.length ? ( - rows.map((row, rowIndex) => ( - - {row.map((cell, cellIndex) => ( - - ))} - - )) - ) : ( - - - - )} - -
- {column} -
- {cell} -
- No records yet. -
-
- ); -} diff --git a/frontend/src/pages/AssetsPage.tsx b/frontend/src/pages/AssetsPage.tsx new file mode 100644 index 0000000..7b041e5 --- /dev/null +++ b/frontend/src/pages/AssetsPage.tsx @@ -0,0 +1,558 @@ +import { FormEvent, useEffect, useMemo, useState } from "react"; +import type { ReactNode } from "react"; +import { Activity, CheckSquare, Globe2, Network, PlugZap, Plus, RefreshCw, Router, Save, Search, Server, Square, Trash2 } from "lucide-react"; + +import { api } from "../api/client"; +import { Button } from "../components/Button"; +import type { Asset, Monitor, SnmpCredentialProfile, SnmpDiscoveryItem, SnmpDiscoveryResult } from "../types/api"; + +interface AssetsPageProps { + token: string; + assets: Asset[]; + monitors: Monitor[]; + onChanged: () => Promise; +} + +type SetupAssetId = "new" | number; + +const assetTypes = [ + { value: "server", label: "Server" }, + { value: "network_device", label: "Network Device" }, + { value: "website", label: "Website" }, + { value: "service", label: "Service" }, + { value: "other", label: "Other" }, +]; + +export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPageProps) { + const [setupAssetId, setSetupAssetId] = useState("new"); + const selectedAsset = typeof setupAssetId === "number" ? assets.find((asset) => asset.id === setupAssetId) ?? null : null; + const selectedAssetMonitors = selectedAsset ? monitors.filter((monitor) => monitor.asset_id === selectedAsset.id) : []; + + const [name, setName] = useState(""); + const [assetType, setAssetType] = useState("server"); + const [address, setAddress] = useState(""); + const [intervalSeconds, setIntervalSeconds] = useState(60); + const [pingEnabled, setPingEnabled] = useState(true); + const [tcpEnabled, setTcpEnabled] = useState(false); + const [websiteEnabled, setWebsiteEnabled] = useState(false); + const [snmpEnabled, setSnmpEnabled] = useState(false); + const [tcpPort, setTcpPort] = useState(443); + const [websiteUrl, setWebsiteUrl] = useState("https://"); + const [profiles, setProfiles] = useState([]); + const [profileId, setProfileId] = useState(""); + const [discoveryResult, setDiscoveryResult] = useState(null); + const [selectedItemIds, setSelectedItemIds] = useState>(new Set()); + const [loadingProfiles, setLoadingProfiles] = useState(false); + const [discovering, setDiscovering] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [deletingAssetId, setDeletingAssetId] = useState(null); + const [message, setMessage] = useState(null); + + const setupName = selectedAsset?.name ?? name.trim(); + const setupAddress = selectedAsset?.address?.trim() || address.trim(); + const selectedItems = useMemo( + () => (discoveryResult?.monitorable_items ?? []).filter((item) => selectedItemIds.has(item.item_id)), + [discoveryResult, selectedItemIds] + ); + const groupedItems = useMemo(() => groupItems(discoveryResult?.monitorable_items ?? []), [discoveryResult]); + + 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]); + + function resetDiscovery() { + setDiscoveryResult(null); + setSelectedItemIds(new Set()); + } + + function handleAssetChoice(value: string) { + setSetupAssetId(value === "new" ? "new" : Number(value)); + resetDiscovery(); + setMessage(null); + } + + async function runSnmpDiscovery() { + if (!profileId || !setupAddress) return; + setDiscovering(true); + setMessage(null); + resetDiscovery(); + try { + const discovered = await api.discoverSnmpDevice(token, { + host: setupAddress, + credential_profile_id: profileId, + }); + setDiscoveryResult(discovered); + } catch (err) { + setMessage(err instanceof Error ? err.message : "SNMP discovery failed"); + } finally { + setDiscovering(false); + } + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + setSubmitting(true); + setMessage(null); + + try { + if (!setupName) { + throw new Error("Asset name is required"); + } + if ((pingEnabled || tcpEnabled || snmpEnabled) && !setupAddress) { + throw new Error("Address is required for ping, TCP, and SNMP monitoring"); + } + if (websiteEnabled && !normalizedWebsiteUrl(websiteUrl, setupAddress)) { + throw new Error("Website URL is required"); + } + if (snmpEnabled && !profileId) { + throw new Error("SNMP profile is required"); + } + if (snmpEnabled && selectedItems.length === 0) { + throw new Error("Select at least one SNMP item"); + } + + const asset = + selectedAsset ?? + (await api.createAsset(token, { + name: setupName, + asset_type: assetType, + address: setupAddress || null, + metadata: {}, + })); + + const monitorCreates: Promise[] = []; + if (pingEnabled) { + monitorCreates.push( + api.createPingMonitor(token, { + name: `${asset.name} ping`, + host: setupAddress, + asset_id: asset.id, + timeout_seconds: 5, + interval_seconds: intervalSeconds, + create_asset: false, + alert_enabled: false, + alert_severity: "warning", + failure_threshold: 3, + }) + ); + } + if (tcpEnabled) { + monitorCreates.push( + api.createTcpMonitor(token, { + name: `${asset.name} TCP ${tcpPort}`, + host: setupAddress, + port: tcpPort, + asset_id: asset.id, + timeout_seconds: 5, + interval_seconds: intervalSeconds, + create_asset: false, + alert_enabled: false, + alert_severity: "warning", + failure_threshold: 3, + }) + ); + } + if (websiteEnabled) { + const url = normalizedWebsiteUrl(websiteUrl, setupAddress); + monitorCreates.push( + api.createWebsiteMonitor(token, { + name: `${asset.name} website`, + url, + asset_id: asset.id, + expected_status: 200, + expected_text: null, + unexpected_text: null, + timeout_seconds: 10, + check_tls_expiry: url.toLowerCase().startsWith("https://"), + tls_warning_days: 30, + interval_seconds: intervalSeconds, + create_asset: false, + alert_enabled: false, + alert_severity: "critical", + failure_threshold: 3, + }) + ); + } + if (snmpEnabled && profileId) { + monitorCreates.push( + api.createSnmpMonitorsFromDiscovery(token, { + host: setupAddress, + asset_id: asset.id, + credential_profile_id: profileId, + selected_items: selectedItems, + interval_seconds: intervalSeconds, + }) + ); + } + + await Promise.all(monitorCreates); + resetForm(asset.id); + await onChanged(); + setMessage("Asset setup saved"); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Could not save asset setup"); + } finally { + setSubmitting(false); + } + } + + function resetForm(nextAssetId?: number) { + if (nextAssetId) { + setSetupAssetId(nextAssetId); + } else { + setSetupAssetId("new"); + } + setName(""); + setAssetType("server"); + setAddress(""); + setIntervalSeconds(60); + setPingEnabled(true); + setTcpEnabled(false); + setWebsiteEnabled(false); + setSnmpEnabled(false); + setTcpPort(443); + setWebsiteUrl("https://"); + resetDiscovery(); + } + + 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; + }); + } + + async function deleteAsset(asset: Asset, monitorCount: number) { + const confirmed = window.confirm( + monitorCount + ? `Delete ${asset.name} and its ${monitorCount} attached monitor${monitorCount === 1 ? "" : "s"}?` + : `Delete ${asset.name}?` + ); + if (!confirmed) return; + + setDeletingAssetId(asset.id); + setMessage(null); + try { + await api.deleteAsset(token, asset.id); + if (selectedAsset?.id === asset.id) { + resetForm(); + } + await onChanged(); + setMessage("Asset deleted"); + } catch (err) { + setMessage(err instanceof Error ? err.message : "Could not delete asset"); + } finally { + setDeletingAssetId(null); + } + } + + return ( +
+
+
+

Assets

+

Add assets and choose the monitors attached to each one.

+
+ +
+ +
+
+
+ +

Asset Setup

+
+ + + + {selectedAsset ? ( +
+
{selectedAsset.name}
+
{friendlyAssetType(selectedAsset.asset_type)} - {selectedAsset.address || "No address"}
+
+ ) : ( + <> + + +
+ + +
+ + )} + +
+ } label="Ping" onClick={() => setPingEnabled((value) => !value)} /> + } label="TCP" onClick={() => setTcpEnabled((value) => !value)} /> + } label="Website" onClick={() => setWebsiteEnabled((value) => !value)} /> + } label="SNMP" onClick={() => setSnmpEnabled((value) => !value)} /> +
+ +
+ + {tcpEnabled ? ( + + ) : null} +
+ + {websiteEnabled ? ( + + ) : null} + + {snmpEnabled ? ( +
+
+ +
+ +
+
+ + {!profiles.length ?
Add an SNMP profile before scanning.
: null} + + {discoveryResult ? ( +
+
+ + + +
+
+ {groupedItems.map(({ group, items }) => ( +
+
+

{group}

+ +
+
+ {items.map((item) => ( + + ))} +
+
+ ))} +
+
+ ) : null} +
+ ) : null} + + {message ?
{message}
: null} + +
+ + +
+
+ +
+
+
+ +

Configured Assets

+
+
+ {assets.length ? ( + assets.map((asset) => { + const assetMonitors = monitors.filter((monitor) => monitor.asset_id === asset.id); + return ( +
+ + +
+ ); + }) + ) : ( +
No assets yet.
+ )} +
+
+ +
+
+

{selectedAsset ? `${selectedAsset.name} Monitors` : "Asset Monitors"}

+
+
+ {selectedAsset && selectedAssetMonitors.length ? ( + selectedAssetMonitors.map((monitor) => ( +
+
+
{monitor.name}
+
{monitor.target}
+
+ {monitor.monitor_type} + +
{monitor.interval_seconds}s
+
+ )) + ) : ( +
{selectedAsset ? "No monitors attached to this asset." : "Select an asset to view attached monitors."}
+ )} +
+
+
+
+
+ ); +} + +function MethodToggle({ active, icon, label, onClick }: { active: boolean; icon: ReactNode; label: string; onClick: () => void }) { + return ( + + ); +} + +function SummaryItem({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Status({ status }: { status: string }) { + const classes = + status === "up" + ? "border-teal-500/40 bg-teal-950/40 text-teal-200" + : status === "down" + ? "border-red-500/40 bg-red-950/40 text-red-200" + : status === "warning" + ? "border-amber-500/40 bg-amber-950/40 text-amber-200" + : "border-slate-600 bg-slate-900 text-slate-300"; + return {status}; +} + +function groupItems(items: SnmpDiscoveryItem[]) { + const groups = new Map(); + for (const item of items) { + groups.set(item.group, [...(groups.get(item.group) ?? []), item]); + } + return Array.from(groups, ([group, groupItems]) => ({ group, items: groupItems })); +} + +function normalizedWebsiteUrl(url: string, address: string) { + const candidate = url.trim() && url.trim() !== "https://" ? url.trim() : address.trim(); + if (!candidate) return ""; + if (candidate.startsWith("http://") || candidate.startsWith("https://")) return candidate; + return `https://${candidate}`; +} + +function friendlyAssetType(value: string) { + return value.replaceAll("_", " "); +} + +function friendlyItemType(value: string) { + return value.replaceAll("_", " "); +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index cef76b8..763f181 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -12,6 +12,14 @@ export interface Asset { asset_type: string; address?: string | null; status: string; + metadata?: Record; +} + +export interface AssetCreate { + name: string; + asset_type: string; + address?: string | null; + metadata: Record; } export interface Monitor { @@ -172,9 +180,18 @@ export interface SnmpDiscoveryResult { monitorable_items: SnmpDiscoveryItem[]; } +export interface SnmpMonitorsCreate { + host: string; + asset_id: number; + credential_profile_id: number; + selected_items: SnmpDiscoveryItem[]; + interval_seconds: number; +} + export interface WebsiteMonitorCreate { name: string; url: string; + asset_id?: number | null; expected_status: number; expected_text?: string | null; unexpected_text?: string | null; @@ -191,6 +208,7 @@ export interface WebsiteMonitorCreate { export interface PingMonitorCreate { name: string; host: string; + asset_id?: number | null; timeout_seconds: number; interval_seconds: number; create_asset: boolean; @@ -203,6 +221,7 @@ export interface TcpMonitorCreate { name: string; host: string; port: number; + asset_id?: number | null; timeout_seconds: number; interval_seconds: number; create_asset: boolean;