Improve SNMP discovery item context

This commit is contained in:
Keith Smith
2026-05-26 21:08:07 -06:00
parent 6ff452a8a9
commit af72a6c563
8 changed files with 303 additions and 92 deletions
+23
View File
@@ -87,6 +87,7 @@ def _monitorable_items(discovered: DiscoveredSnmpDevice) -> list[SnmpDiscoveryIt
group="Device Health",
label="Device uptime",
unit="seconds",
current_value=_format_duration(discovered.uptime_seconds),
)
)
items.extend(
@@ -96,6 +97,7 @@ def _monitorable_items(discovered: DiscoveredSnmpDevice) -> list[SnmpDiscoveryIt
group=item.group,
label=item.label,
unit=item.unit,
current_value=item.current_value,
)
for item in discovered.health_items
)
@@ -109,6 +111,7 @@ def _monitorable_items(discovered: DiscoveredSnmpDevice) -> list[SnmpDiscoveryIt
item_type="interface_status",
group=group,
label=f"{interface.label} status",
current_value=_interface_status_value(interface.admin_status, interface.oper_status),
),
SnmpDiscoveryItemRead(
item_id=f"{item_prefix}.traffic",
@@ -116,6 +119,7 @@ def _monitorable_items(discovered: DiscoveredSnmpDevice) -> list[SnmpDiscoveryIt
group=group,
label=f"{interface.label} traffic",
unit="bps",
current_value="Rate after first check",
),
SnmpDiscoveryItemRead(
item_id=f"{item_prefix}.errors",
@@ -127,3 +131,22 @@ def _monitorable_items(discovered: DiscoveredSnmpDevice) -> list[SnmpDiscoveryIt
]
)
return items
def _format_duration(seconds: int | None) -> str | None:
if seconds is None:
return None
days = seconds // 86_400
hours = (seconds % 86_400) // 3_600
minutes = (seconds % 3_600) // 60
if days:
return f"{days}d {hours}h"
if hours:
return f"{hours}h {minutes}m"
return f"{minutes}m"
def _interface_status_value(admin_status: str | None, oper_status: str | None) -> str | None:
if admin_status and oper_status:
return f"admin {admin_status}, oper {oper_status}"
return admin_status or oper_status
+1
View File
@@ -237,6 +237,7 @@ class SnmpDiscoveryItemRead(BaseModel):
group: str
label: str
unit: str | None = None
current_value: str | None = None
class SnmpMonitorsCreate(BaseModel):
+35 -10
View File
@@ -35,6 +35,7 @@ class DiscoveredSnmpHealthItem:
group: str
label: str
unit: str | None = None
current_value: str | None = None
@dataclass(frozen=True)
@@ -339,14 +340,15 @@ def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmp
for position, index in enumerate(processor_indexes, start=1):
label = "CPU load" if len(processor_indexes) == 1 else f"CPU {position} load"
items.append(
DiscoveredSnmpHealthItem(
item_id=f"cpu.{index}.load",
item_type="cpu_load",
group="Device Health",
label=label,
unit="%",
DiscoveredSnmpHealthItem(
item_id=f"cpu.{index}.load",
item_type="cpu_load",
group="Device Health",
label=label,
unit="%",
current_value=f"{_int_value(processor_loads.get(index))}%",
)
)
)
storage_types = _indexed_values(client.walk(HR_STORAGE_TYPE, max_items=256))
descriptions = _indexed_values(client.walk(HR_STORAGE_DESCR, max_items=256))
@@ -363,6 +365,7 @@ def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmp
continue
description = _string_value(descriptions.get(index)) or f"Storage {index}"
if storage_type in {HR_STORAGE_RAM, HR_STORAGE_VIRTUAL_MEMORY}:
used_percent = (used_blocks / size) * 100
items.append(
DiscoveredSnmpHealthItem(
item_id=f"storage.{index}.memory",
@@ -370,11 +373,13 @@ def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmp
group="Device Health",
label="Memory used",
unit="%",
current_value=f"{used_percent:.1f}% used",
)
)
elif storage_type in {HR_STORAGE_FIXED_DISK, HR_STORAGE_REMOVABLE_DISK}:
if not _is_monitorable_storage_path(description):
continue
used_percent = (used_blocks / size) * 100
items.append(
DiscoveredSnmpHealthItem(
item_id=f"storage.{index}.usage",
@@ -382,6 +387,7 @@ def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmp
group="Storage",
label=_storage_usage_label(description),
unit="%",
current_value=f"{used_percent:.1f}% used",
)
)
@@ -393,18 +399,23 @@ def _discover_linux_server_items(client: "SnmpV2Client") -> list[DiscoveredSnmpH
load_values = _indexed_values(client.walk(UCD_LA_LOAD_INT, max_items=16))
for index, label in [(1, "Load average 1 minute"), (2, "Load average 5 minutes"), (3, "Load average 15 minutes")]:
if _int_value(load_values.get(index)) is not None:
load_value = _int_value(load_values.get(index))
if load_value is not None:
items.append(
DiscoveredSnmpHealthItem(
item_id=f"linux.load.{index}",
item_type="linux_load_average",
group="Server Health",
label=label,
current_value=f"{load_value / 100:.2f}",
)
)
memory = client.get_many([UCD_MEM_TOTAL_REAL, UCD_MEM_AVAIL_REAL])
if _int_value(memory.get(UCD_MEM_TOTAL_REAL)) and _int_value(memory.get(UCD_MEM_AVAIL_REAL)) is not None:
total_kb = _int_value(memory.get(UCD_MEM_TOTAL_REAL))
available_kb = _int_value(memory.get(UCD_MEM_AVAIL_REAL))
if total_kb and available_kb is not None:
used_percent = ((total_kb - available_kb) / total_kb) * 100
items.append(
DiscoveredSnmpHealthItem(
item_id="linux.memory.real",
@@ -412,6 +423,7 @@ def _discover_linux_server_items(client: "SnmpV2Client") -> list[DiscoveredSnmpH
group="Server Health",
label="Memory used",
unit="%",
current_value=f"{used_percent:.1f}% used",
)
)
@@ -419,7 +431,8 @@ def _discover_linux_server_items(client: "SnmpV2Client") -> list[DiscoveredSnmpH
disk_percent = _indexed_values(client.walk(UCD_DSK_PERCENT, max_items=256))
for index in sorted(disk_paths):
path = _string_value(disk_paths.get(index))
if not path or _int_value(disk_percent.get(index)) is None:
used_percent = _int_value(disk_percent.get(index))
if not path or used_percent is None:
continue
items.append(
DiscoveredSnmpHealthItem(
@@ -428,6 +441,7 @@ def _discover_linux_server_items(client: "SnmpV2Client") -> list[DiscoveredSnmpH
group="Storage",
label=_storage_usage_label(path),
unit="%",
current_value=f"{used_percent}% used",
)
)
@@ -436,6 +450,8 @@ def _discover_linux_server_items(client: "SnmpV2Client") -> list[DiscoveredSnmpH
def _discover_sensor_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
sensor_types = _indexed_values(client.walk(ENT_PHY_SENSOR_TYPE, max_items=256))
sensor_scales = _indexed_values(client.walk(ENT_PHY_SENSOR_SCALE, max_items=256))
sensor_precisions = _indexed_values(client.walk(ENT_PHY_SENSOR_PRECISION, max_items=256))
sensor_values = _indexed_values(client.walk(ENT_PHY_SENSOR_VALUE, max_items=256))
sensor_names = _indexed_values(client.walk(ENT_PHYSICAL_NAME, max_items=256))
sensor_descriptions = _indexed_values(client.walk(ENT_PHYSICAL_DESCR, max_items=256))
@@ -446,6 +462,8 @@ def _discover_sensor_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthI
if sensor_type not in SENSOR_TYPE_LABELS or _int_value(sensor_values.get(index)) is None:
continue
kind, unit = SENSOR_TYPE_LABELS[sensor_type]
raw_value = _int_value(sensor_values.get(index)) or 0
value = _scaled_sensor_value(raw_value, _int_value(sensor_scales.get(index)), _int_value(sensor_precisions.get(index)))
name = _string_value(sensor_names.get(index)) or _string_value(sensor_descriptions.get(index))
label = kind if not name else f"{kind} {name}"
items.append(
@@ -455,6 +473,7 @@ def _discover_sensor_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthI
group="Environmental",
label=label,
unit=unit,
current_value=f"{value:g}{unit or ''}",
)
)
return items
@@ -529,6 +548,12 @@ def _timeticks_to_seconds(value: Any) -> int | None:
return int(value / 100)
def _scaled_sensor_value(raw_value: int, scale: int | None, precision: int | None) -> float:
scale_multiplier = 10 ** ((scale or 9) - 9)
precision_divisor = 10 ** (precision or 0)
return float(raw_value * scale_multiplier / precision_divisor)
class SnmpV2Client:
def __init__(self, host: str, credential: SnmpCredential) -> None:
self.host = host
+19 -15
View File
@@ -102,6 +102,7 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
"group": "Device Health",
"label": "Device uptime",
"unit": "seconds",
"current_value": "3h 25m",
},
{
"item_id": "interface.1.status",
@@ -109,6 +110,7 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
"group": "Interface GigabitEthernet 1/0/1",
"label": "GigabitEthernet 1/0/1 status",
"unit": None,
"current_value": "admin up, oper up",
},
{
"item_id": "interface.1.traffic",
@@ -116,6 +118,7 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
"group": "Interface GigabitEthernet 1/0/1",
"label": "GigabitEthernet 1/0/1 traffic",
"unit": "bps",
"current_value": "Rate after first check",
},
{
"item_id": "interface.1.errors",
@@ -123,6 +126,7 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
"group": "Interface GigabitEthernet 1/0/1",
"label": "GigabitEthernet 1/0/1 errors and discards",
"unit": "count",
"current_value": None,
},
]
assert "private-community" not in response.text
@@ -219,12 +223,12 @@ def test_snmp_server_asset_type_uses_linux_server_mibs_and_keeps_interfaces(monk
assert discovered.capabilities["cpu"] is True
assert discovered.capabilities["memory"] is True
assert discovered.capabilities["storage"] is True
assert [(item.item_id, item.item_type, item.group, item.label, item.unit) for item in discovered.health_items] == [
("linux.load.1", "linux_load_average", "Server Health", "Load average 1 minute", None),
("linux.load.2", "linux_load_average", "Server Health", "Load average 5 minutes", None),
("linux.load.3", "linux_load_average", "Server Health", "Load average 15 minutes", None),
("linux.memory.real", "linux_memory_usage", "Server Health", "Memory used", "%"),
("linux.disk.1", "linux_disk_usage", "Storage", "Disk / usage", "%"),
assert [(item.item_id, item.item_type, item.group, item.label, item.unit, item.current_value) for item in discovered.health_items] == [
("linux.load.1", "linux_load_average", "Server Health", "Load average 1 minute", None, "1.23"),
("linux.load.2", "linux_load_average", "Server Health", "Load average 5 minutes", None, "0.97"),
("linux.load.3", "linux_load_average", "Server Health", "Load average 15 minutes", None, "0.88"),
("linux.memory.real", "linux_memory_usage", "Server Health", "Memory used", "%", "75.0% used"),
("linux.disk.1", "linux_disk_usage", "Storage", "Disk / usage", "%", "42% used"),
]
@@ -282,10 +286,10 @@ def test_snmp_server_asset_type_falls_back_to_host_resources(monkeypatch) -> Non
assert discovered.profile_key == "linux_server"
assert [(interface.name, interface.label) for interface in discovered.interfaces] == [("eth0", "eth0")]
assert [(item.item_id, item.item_type, item.group, item.label, item.unit) for item in discovered.health_items] == [
("cpu.196608.load", "cpu_load", "Device Health", "CPU load", "%"),
("storage.1.memory", "memory_usage", "Device Health", "Memory used", "%"),
("storage.31.usage", "storage_usage", "Storage", "Disk / usage", "%"),
assert [(item.item_id, item.item_type, item.group, item.label, item.unit, item.current_value) for item in discovered.health_items] == [
("cpu.196608.load", "cpu_load", "Device Health", "CPU load", "%", "17%"),
("storage.1.memory", "memory_usage", "Device Health", "Memory used", "%", "50.0% used"),
("storage.31.usage", "storage_usage", "Storage", "Disk / usage", "%", "25.0% used"),
]
@@ -341,11 +345,11 @@ def test_snmp_profile_mapping_discovers_standard_health_items(monkeypatch) -> No
assert discovered.capabilities["memory"] is True
assert discovered.capabilities["storage"] is True
assert discovered.capabilities["sensors"] is True
assert [(item.item_id, item.item_type, item.group, item.label, item.unit) for item in discovered.health_items] == [
("cpu.196608.load", "cpu_load", "Device Health", "CPU load", "%"),
("storage.1.memory", "memory_usage", "Device Health", "Memory used", "%"),
("storage.31.usage", "storage_usage", "Storage", "Disk / usage", "%"),
("sensor.10.value", "sensor_value", "Environmental", "Temperature Inlet", "C"),
assert [(item.item_id, item.item_type, item.group, item.label, item.unit, item.current_value) for item in discovered.health_items] == [
("cpu.196608.load", "cpu_load", "Device Health", "CPU load", "%", "17%"),
("storage.1.memory", "memory_usage", "Device Health", "Memory used", "%", "50.0% used"),
("storage.31.usage", "storage_usage", "Storage", "Disk / usage", "%", "25.0% used"),
("sensor.10.value", "sensor_value", "Environmental", "Temperature Inlet", "C", "310C"),
]
+6
View File
@@ -3,6 +3,7 @@ import type {
AlertRuleUpdate,
Asset,
AssetCreate,
AssetUpdate,
CheckResult,
Incident,
Monitor,
@@ -72,6 +73,11 @@ export const api = {
method: "POST",
body: JSON.stringify(payload),
}),
updateAsset: (token: string, assetId: number, payload: AssetUpdate) =>
request<Asset>(`/assets/${assetId}`, token, {
method: "PATCH",
body: JSON.stringify(payload),
}),
deleteAsset: (token: string, assetId: number) =>
request<void>(`/assets/${assetId}`, token, {
method: "DELETE",
+205 -66
View File
@@ -46,10 +46,11 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
const [discovering, setDiscovering] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [deletingAssetId, setDeletingAssetId] = useState<number | null>(null);
const [deletingMonitorId, setDeletingMonitorId] = useState<number | null>(null);
const [message, setMessage] = useState<string | null>(null);
const setupName = selectedAsset?.name ?? name.trim();
const setupAddress = selectedAsset?.address?.trim() || address.trim();
const setupName = name.trim();
const setupAddress = address.trim();
const selectedItems = useMemo(
() => (discoveryResult?.monitorable_items ?? []).filter((item) => selectedItemIds.has(item.item_id)),
[discoveryResult, selectedItemIds]
@@ -76,12 +77,39 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
setSelectedItemIds(new Set());
}
function handleAssetChoice(value: string) {
setSetupAssetId(value === "new" ? "new" : Number(value));
function loadAssetIntoForm(asset: Asset) {
const assetSnmpMonitors = monitors.filter((monitor) => monitor.asset_id === asset.id && monitor.monitor_type === "snmp");
const firstSnmpMonitor = assetSnmpMonitors[0];
const firstProfileId = firstSnmpMonitor ? snmpCredentialProfileId(firstSnmpMonitor) : null;
setSetupAssetId(asset.id);
setName(asset.name);
setAssetType(asset.asset_type);
setAddress(asset.address ?? "");
setIntervalSeconds(firstSnmpMonitor?.interval_seconds ?? 60);
setPingEnabled(false);
setTcpEnabled(false);
setWebsiteEnabled(false);
setSnmpEnabled(assetSnmpMonitors.length > 0);
setTcpPort(443);
setWebsiteUrl("https://");
setProfileId(firstProfileId ?? profiles[0]?.id ?? "");
resetDiscovery();
setMessage(null);
}
function handleAssetChoice(value: string) {
if (value === "new") {
resetForm();
return;
}
const asset = assets.find((candidate) => candidate.id === Number(value));
if (asset) {
loadAssetIntoForm(asset);
}
}
async function runSnmpDiscovery() {
if (!profileId || !setupAddress) return;
setDiscovering(true);
@@ -91,9 +119,13 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
const discovered = await api.discoverSnmpDevice(token, {
host: setupAddress,
credential_profile_id: profileId,
asset_type: selectedAsset?.asset_type ?? assetType,
asset_type: assetType,
});
const existingItemIds = new Set(selectedAssetMonitors.map(snmpItemId).filter((itemId): itemId is string => Boolean(itemId)));
const discoveredItemIds = new Set(discovered.monitorable_items.map((item) => item.item_id));
const nextSelectedItemIds = new Set(Array.from(existingItemIds).filter((itemId) => discoveredItemIds.has(itemId)));
setDiscoveryResult(discovered);
setSelectedItemIds(nextSelectedItemIds);
} catch (err) {
setMessage(err instanceof Error ? err.message : "SNMP discovery failed");
} finally {
@@ -119,18 +151,24 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
if (snmpEnabled && !profileId) {
throw new Error("SNMP profile is required");
}
if (snmpEnabled && selectedItems.length === 0) {
const hasSelectedAssetSnmpMonitors = selectedAssetMonitors.some((monitor) => monitor.monitor_type === "snmp");
if (snmpEnabled && selectedItems.length === 0 && (!selectedAsset || !hasSelectedAssetSnmpMonitors)) {
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: {},
}));
selectedAsset
? await api.updateAsset(token, selectedAsset.id, {
name: setupName,
asset_type: assetType,
address: setupAddress || null,
})
: await api.createAsset(token, {
name: setupName,
asset_type: assetType,
address: setupAddress || null,
metadata: {},
});
const monitorCreates: Promise<unknown>[] = [];
if (pingEnabled) {
@@ -185,22 +223,30 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
})
);
}
if (snmpEnabled && profileId) {
monitorCreates.push(
api.createSnmpMonitorsFromDiscovery(token, {
host: setupAddress,
asset_id: asset.id,
credential_profile_id: profileId,
selected_items: selectedItems,
interval_seconds: intervalSeconds,
})
);
if (snmpEnabled && profileId && discoveryResult) {
if (selectedAsset) {
monitorCreates.push(reconcileSnmpMonitors(asset, profileId));
} else {
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");
setSetupAssetId(asset.id);
setName(asset.name);
setAssetType(asset.asset_type);
setAddress(asset.address ?? "");
resetDiscovery();
setMessage(selectedAsset ? "Asset setup updated" : "Asset setup saved");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Could not save asset setup");
} finally {
@@ -208,12 +254,8 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
}
}
function resetForm(nextAssetId?: number) {
if (nextAssetId) {
setSetupAssetId(nextAssetId);
} else {
setSetupAssetId("new");
}
function resetForm() {
setSetupAssetId("new");
setName("");
setAssetType("server");
setAddress("");
@@ -254,6 +296,60 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
});
}
async function reconcileSnmpMonitors(asset: Asset, credentialProfileId: number) {
const currentSnmpMonitors = monitors.filter((monitor) => monitor.asset_id === asset.id && monitor.monitor_type === "snmp");
const discoveredItemIds = new Set(discoveryResult?.monitorable_items.map((item) => item.item_id) ?? []);
const selectedItemsById = new Map(selectedItems.map((item) => [item.item_id, item]));
const existingItemIds = new Set<string>();
const changes: Promise<unknown>[] = [];
for (const monitor of currentSnmpMonitors) {
const itemId = snmpItemId(monitor);
if (!itemId || !discoveredItemIds.has(itemId)) {
continue;
}
existingItemIds.add(itemId);
const item = selectedItemsById.get(itemId);
if (!item) {
changes.push(api.deleteMonitor(token, monitor.id));
continue;
}
changes.push(
api.updateMonitor(token, monitor.id, {
name: `${asset.name} ${item.label}`,
target: setupAddress,
interval_seconds: intervalSeconds,
config: {
...(monitor.config ?? {}),
credential_profile_id: credentialProfileId,
item_id: item.item_id,
item_type: item.item_type,
group: item.group,
label: item.label,
unit: item.unit ?? null,
},
})
);
}
const newItems = selectedItems.filter((item) => !existingItemIds.has(item.item_id));
if (newItems.length) {
changes.push(
api.createSnmpMonitorsFromDiscovery(token, {
host: setupAddress,
asset_id: asset.id,
credential_profile_id: credentialProfileId,
selected_items: newItems,
interval_seconds: intervalSeconds,
})
);
}
await Promise.all(changes);
}
async function deleteAsset(asset: Asset, monitorCount: number) {
const confirmed = window.confirm(
monitorCount
@@ -278,6 +374,31 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
}
}
async function deleteAttachedMonitor(monitor: Monitor) {
const confirmed = window.confirm(`Delete monitor ${monitor.name}?`);
if (!confirmed) return;
setDeletingMonitorId(monitor.id);
setMessage(null);
try {
await api.deleteMonitor(token, monitor.id);
const itemId = snmpItemId(monitor);
if (itemId) {
setSelectedItemIds((current) => {
const next = new Set(current);
next.delete(itemId);
return next;
});
}
await onChanged();
setMessage("Monitor deleted");
} catch (err) {
setMessage(err instanceof Error ? err.message : "Could not delete monitor");
} finally {
setDeletingMonitorId(null);
}
}
return (
<div className="space-y-6">
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
@@ -310,36 +431,27 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
</select>
</label>
{selectedAsset ? (
<div className="rounded-md border border-line bg-slate-950 p-3 text-sm text-slate-300">
<div className="font-medium">{selectedAsset.name}</div>
<div className="mt-1 text-slate-400">{friendlyAssetType(selectedAsset.asset_type)} - {selectedAsset.address || "No address"}</div>
</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">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>
<div className="grid gap-3 sm:grid-cols-[1fr_1.3fr]">
<label className="block space-y-2">
<span className="text-sm text-slate-300">Type</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={assetType} onChange={(event) => setAssetType(event.target.value)}>
{assetTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Address</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={address} onChange={(event) => { setAddress(event.target.value); resetDiscovery(); }} placeholder="192.168.1.1 or app.example.com" />
</label>
</div>
</>
)}
<div className="grid gap-3 sm:grid-cols-[1fr_1.3fr]">
<label className="block space-y-2">
<span className="text-sm text-slate-300">Type</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={assetType} onChange={(event) => { setAssetType(event.target.value); resetDiscovery(); }}>
{assetTypes.map((type) => (
<option key={type.value} value={type.value}>
{type.label}
</option>
))}
</select>
</label>
<label className="block space-y-2">
<span className="text-sm text-slate-300">Address</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={address} onChange={(event) => { setAddress(event.target.value); resetDiscovery(); }} placeholder="192.168.1.1 or app.example.com" />
</label>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<MethodToggle active={pingEnabled} icon={<Activity size={16} />} label="Ping" onClick={() => setPingEnabled((value) => !value)} />
@@ -373,7 +485,7 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
<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-[#0d131c] px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={profileId} onChange={(event) => { setProfileId(Number(event.target.value)); resetDiscovery(); }} required={snmpEnabled}>
<select className="h-10 w-full rounded-md border border-line bg-[#0d131c] px-3 text-sm outline-none ring-pulse/40 focus:ring-2" value={profileId} onChange={(event) => { setProfileId(event.target.value ? Number(event.target.value) : ""); resetDiscovery(); }} required={snmpEnabled}>
<option value="" disabled>
Select a profile
</option>
@@ -419,7 +531,7 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
{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 className="block text-xs text-slate-500">{itemSubtitle(item)}</span>
</span>
</button>
))}
@@ -432,7 +544,7 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
</div>
) : null}
{message ? <div className={`rounded-md border p-3 text-sm ${message.includes("saved") || message.includes("deleted") ? "border-teal-500/40 bg-teal-950/40 text-teal-200" : "border-red-500/40 bg-red-950/40 text-red-200"}`}>{message}</div> : null}
{message ? <div className={`rounded-md border p-3 text-sm ${isSuccessMessage(message) ? "border-teal-500/40 bg-teal-950/40 text-teal-200" : "border-red-500/40 bg-red-950/40 text-red-200"}`}>{message}</div> : null}
<div className="flex gap-2">
<Button className="flex-1" onClick={() => resetForm()} type="button" variant="ghost">
@@ -441,7 +553,7 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
</Button>
<Button className="flex-1" disabled={submitting} type="submit">
<Save size={16} />
{submitting ? "Saving..." : "Save Setup"}
{submitting ? "Saving..." : selectedAsset ? "Save Asset" : "Save Setup"}
</Button>
</div>
</form>
@@ -486,14 +598,17 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
<div className="divide-y divide-line">
{selectedAsset && selectedAssetMonitors.length ? (
selectedAssetMonitors.map((monitor) => (
<div key={monitor.id} className="grid gap-2 p-4 md:grid-cols-[1fr_110px_110px_120px] md:items-center">
<div key={monitor.id} className="grid gap-2 p-4 md:grid-cols-[1fr_90px_110px_90px_auto] md:items-center">
<div className="min-w-0">
<div className="truncate font-medium">{monitor.name}</div>
<div className="truncate text-sm text-slate-400">{monitor.target}</div>
</div>
<span className="text-sm uppercase text-slate-400">{monitor.monitor_type}</span>
<Status status={monitor.status} />
<div className="text-sm text-slate-400 md:text-right">{monitor.interval_seconds}s</div>
<div className="text-sm text-slate-400">{monitor.interval_seconds}s</div>
<Button aria-label={`Delete ${monitor.name}`} className="h-8 px-3 text-red-100" disabled={deletingMonitorId === monitor.id} onClick={() => deleteAttachedMonitor(monitor)} title="Delete monitor" type="button" variant="ghost">
<Trash2 className="text-red-200" size={15} />
</Button>
</div>
))
) : (
@@ -560,6 +675,30 @@ function friendlyItemType(value: string) {
return value.replaceAll("_", " ");
}
function itemSubtitle(item: SnmpDiscoveryItem) {
const details = [friendlyItemType(item.item_type), item.unit, item.current_value ? `now ${item.current_value}` : null];
return details.filter(Boolean).join(", ");
}
function snmpItemId(monitor: Monitor) {
if (monitor.monitor_type !== "snmp") return null;
const itemId = monitor.config?.item_id;
return typeof itemId === "string" ? itemId : null;
}
function snmpCredentialProfileId(monitor: Monitor) {
const profileId = monitor.config?.credential_profile_id;
if (typeof profileId === "string") {
const parsed = Number(profileId);
return Number.isFinite(parsed) ? parsed : null;
}
return typeof profileId === "number" ? profileId : null;
}
function isSuccessMessage(message: string) {
return message.includes("saved") || message.includes("updated") || message.includes("deleted");
}
function formatCapabilities(capabilities: Record<string, boolean>) {
const labels: Record<string, string> = {
interfaces: "interfaces",
+6 -1
View File
@@ -208,7 +208,7 @@ export function DiscoveryPage({ token }: DiscoveryPageProps) {
{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 className="block text-xs text-slate-500">{itemSubtitle(item)}</span>
</span>
</button>
))}
@@ -268,6 +268,11 @@ function friendlyItemType(value: string) {
return value.replaceAll("_", " ");
}
function itemSubtitle(item: SnmpDiscoveryItem) {
const details = [friendlyItemType(item.item_type), item.unit, item.current_value ? `now ${item.current_value}` : null];
return details.filter(Boolean).join(", ");
}
function interfaceContext(item: SnmpDiscoveredInterface) {
const details = [item.name, item.description].filter((value, index, values) => value && values.indexOf(value) === index);
return details.length ? details.join(" - ") : "No description";
+8
View File
@@ -22,6 +22,13 @@ export interface AssetCreate {
metadata: Record<string, unknown>;
}
export interface AssetUpdate {
name?: string;
asset_type?: string;
address?: string | null;
metadata?: Record<string, unknown> | null;
}
export interface Monitor {
id: number;
asset_id?: number | null;
@@ -170,6 +177,7 @@ export interface SnmpDiscoveryItem {
group: string;
label: string;
unit?: string | null;
current_value?: string | null;
}
export interface SnmpDiscoveryResult {