Improve SNMP discovery item context
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -237,6 +237,7 @@ class SnmpDiscoveryItemRead(BaseModel):
|
||||
group: str
|
||||
label: str
|
||||
unit: str | None = None
|
||||
current_value: str | None = None
|
||||
|
||||
|
||||
class SnmpMonitorsCreate(BaseModel):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user