SNMP discovery updates continued
This commit is contained in:
@@ -43,7 +43,7 @@ def discover_snmp(
|
|||||||
retries=int(extra.get("retries") or 1),
|
retries=int(extra.get("retries") or 1),
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
discovered = discover_snmp_device(payload.host, credential)
|
discovered = discover_snmp_device(payload.host, credential, asset_type=payload.asset_type)
|
||||||
except SnmpDiscoveryError as exc:
|
except SnmpDiscoveryError as exc:
|
||||||
raise HTTPException(status_code=502, detail="SNMP discovery failed") from exc
|
raise HTTPException(status_code=502, detail="SNMP discovery failed") from exc
|
||||||
|
|
||||||
@@ -55,6 +55,7 @@ def _discovery_to_read(credential_profile_id: int, discovered: DiscoveredSnmpDev
|
|||||||
SnmpDiscoveredInterfaceRead(
|
SnmpDiscoveredInterfaceRead(
|
||||||
index=interface.index,
|
index=interface.index,
|
||||||
name=interface.name,
|
name=interface.name,
|
||||||
|
label=interface.label,
|
||||||
description=interface.description,
|
description=interface.description,
|
||||||
admin_status=interface.admin_status,
|
admin_status=interface.admin_status,
|
||||||
oper_status=interface.oper_status,
|
oper_status=interface.oper_status,
|
||||||
@@ -99,7 +100,7 @@ def _monitorable_items(discovered: DiscoveredSnmpDevice) -> list[SnmpDiscoveryIt
|
|||||||
for item in discovered.health_items
|
for item in discovered.health_items
|
||||||
)
|
)
|
||||||
for interface in discovered.interfaces:
|
for interface in discovered.interfaces:
|
||||||
group = f"Interface {interface.name}"
|
group = f"Interface {interface.label}"
|
||||||
item_prefix = f"interface.{interface.index}"
|
item_prefix = f"interface.{interface.index}"
|
||||||
items.extend(
|
items.extend(
|
||||||
[
|
[
|
||||||
@@ -107,20 +108,20 @@ def _monitorable_items(discovered: DiscoveredSnmpDevice) -> list[SnmpDiscoveryIt
|
|||||||
item_id=f"{item_prefix}.status",
|
item_id=f"{item_prefix}.status",
|
||||||
item_type="interface_status",
|
item_type="interface_status",
|
||||||
group=group,
|
group=group,
|
||||||
label=f"{interface.name} status",
|
label=f"{interface.label} status",
|
||||||
),
|
),
|
||||||
SnmpDiscoveryItemRead(
|
SnmpDiscoveryItemRead(
|
||||||
item_id=f"{item_prefix}.traffic",
|
item_id=f"{item_prefix}.traffic",
|
||||||
item_type="interface_traffic",
|
item_type="interface_traffic",
|
||||||
group=group,
|
group=group,
|
||||||
label=f"{interface.name} traffic",
|
label=f"{interface.label} traffic",
|
||||||
unit="bps",
|
unit="bps",
|
||||||
),
|
),
|
||||||
SnmpDiscoveryItemRead(
|
SnmpDiscoveryItemRead(
|
||||||
item_id=f"{item_prefix}.errors",
|
item_id=f"{item_prefix}.errors",
|
||||||
item_type="interface_errors",
|
item_type="interface_errors",
|
||||||
group=group,
|
group=group,
|
||||||
label=f"{interface.name} errors and discards",
|
label=f"{interface.label} errors and discards",
|
||||||
unit="count",
|
unit="count",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -218,11 +218,13 @@ class SnmpCredentialProfileRead(BaseModel):
|
|||||||
class SnmpDiscoveryRequest(BaseModel):
|
class SnmpDiscoveryRequest(BaseModel):
|
||||||
host: str = Field(min_length=1, max_length=255)
|
host: str = Field(min_length=1, max_length=255)
|
||||||
credential_profile_id: int
|
credential_profile_id: int
|
||||||
|
asset_type: str | None = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
class SnmpDiscoveredInterfaceRead(BaseModel):
|
class SnmpDiscoveredInterfaceRead(BaseModel):
|
||||||
index: int
|
index: int
|
||||||
name: str
|
name: str
|
||||||
|
label: str
|
||||||
description: str | None
|
description: str | None
|
||||||
admin_status: str | None
|
admin_status: str | None
|
||||||
oper_status: str | None
|
oper_status: str | None
|
||||||
|
|||||||
+195
-11
@@ -1,5 +1,6 @@
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import socket
|
import socket
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ class SnmpCredential:
|
|||||||
class DiscoveredSnmpInterface:
|
class DiscoveredSnmpInterface:
|
||||||
index: int
|
index: int
|
||||||
name: str
|
name: str
|
||||||
|
label: str
|
||||||
description: str | None
|
description: str | None
|
||||||
admin_status: str | None
|
admin_status: str | None
|
||||||
oper_status: str | None
|
oper_status: str | None
|
||||||
@@ -48,6 +50,13 @@ class DiscoveredSnmpDevice:
|
|||||||
health_items: list[DiscoveredSnmpHealthItem] = field(default_factory=list)
|
health_items: list[DiscoveredSnmpHealthItem] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class InterfaceIdentity:
|
||||||
|
label: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
|
||||||
|
|
||||||
SYS_DESCR = (1, 3, 6, 1, 2, 1, 1, 1, 0)
|
SYS_DESCR = (1, 3, 6, 1, 2, 1, 1, 1, 0)
|
||||||
SYS_UPTIME = (1, 3, 6, 1, 2, 1, 1, 3, 0)
|
SYS_UPTIME = (1, 3, 6, 1, 2, 1, 1, 3, 0)
|
||||||
SYS_NAME = (1, 3, 6, 1, 2, 1, 1, 5, 0)
|
SYS_NAME = (1, 3, 6, 1, 2, 1, 1, 5, 0)
|
||||||
@@ -69,6 +78,11 @@ ENT_PHY_SENSOR_SCALE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 2)
|
|||||||
ENT_PHY_SENSOR_PRECISION = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 3)
|
ENT_PHY_SENSOR_PRECISION = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 3)
|
||||||
ENT_PHY_SENSOR_VALUE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 4)
|
ENT_PHY_SENSOR_VALUE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 4)
|
||||||
ENT_PHY_SENSOR_OPER_STATUS = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 5)
|
ENT_PHY_SENSOR_OPER_STATUS = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 5)
|
||||||
|
UCD_LA_LOAD_INT = (1, 3, 6, 1, 4, 1, 2021, 10, 1, 5)
|
||||||
|
UCD_MEM_TOTAL_REAL = (1, 3, 6, 1, 4, 1, 2021, 4, 5, 0)
|
||||||
|
UCD_MEM_AVAIL_REAL = (1, 3, 6, 1, 4, 1, 2021, 4, 6, 0)
|
||||||
|
UCD_DSK_PATH = (1, 3, 6, 1, 4, 1, 2021, 9, 1, 2)
|
||||||
|
UCD_DSK_PERCENT = (1, 3, 6, 1, 4, 1, 2021, 9, 1, 9)
|
||||||
|
|
||||||
HR_STORAGE_RAM = "1.3.6.1.2.1.25.2.1.2"
|
HR_STORAGE_RAM = "1.3.6.1.2.1.25.2.1.2"
|
||||||
HR_STORAGE_VIRTUAL_MEMORY = "1.3.6.1.2.1.25.2.1.3"
|
HR_STORAGE_VIRTUAL_MEMORY = "1.3.6.1.2.1.25.2.1.3"
|
||||||
@@ -104,27 +118,36 @@ class SnmpProfile:
|
|||||||
key: str
|
key: str
|
||||||
name: str
|
name: str
|
||||||
match_terms: tuple[str, ...] = ()
|
match_terms: tuple[str, ...] = ()
|
||||||
|
health_source: str = "standard"
|
||||||
|
include_interfaces: bool = True
|
||||||
|
|
||||||
def matches(self, system_text: str) -> bool:
|
def matches(self, system_text: str) -> bool:
|
||||||
return any(term in system_text for term in self.match_terms)
|
return any(term in system_text for term in self.match_terms)
|
||||||
|
|
||||||
def discover_health_items(self, client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
|
def discover_health_items(self, client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
|
||||||
|
if self.health_source == "linux":
|
||||||
|
linux_items = _discover_linux_server_items(client)
|
||||||
|
host_items = _discover_host_resource_items(client)
|
||||||
|
if any(item.item_type == "linux_memory_usage" for item in linux_items):
|
||||||
|
host_items = [item for item in host_items if item.item_type != "memory_usage"]
|
||||||
|
return _deduplicate_items([*linux_items, *host_items, *_discover_sensor_items(client)])
|
||||||
return [*_discover_host_resource_items(client), *_discover_sensor_items(client)]
|
return [*_discover_host_resource_items(client), *_discover_sensor_items(client)]
|
||||||
|
|
||||||
|
|
||||||
SNMP_PROFILES = (
|
SNMP_PROFILES = (
|
||||||
SnmpProfile("cisco_ios", "Cisco IOS SNMP", ("cisco", "ios")),
|
SnmpProfile("cisco_ios", "Cisco IOS SNMP", ("cisco", "ios")),
|
||||||
SnmpProfile("mikrotik_routeros", "MikroTik RouterOS SNMP", ("mikrotik", "routeros")),
|
SnmpProfile("mikrotik_routeros", "MikroTik RouterOS SNMP", ("mikrotik", "routeros")),
|
||||||
SnmpProfile("net_snmp", "Net-SNMP Host Resources", ("net-snmp", "linux")),
|
SnmpProfile("proxmox_ve", "Proxmox VE Server", ("proxmox", "-pve", " pve "), health_source="linux"),
|
||||||
|
SnmpProfile("linux_server", "Linux Server", ("linux", "net-snmp"), health_source="linux"),
|
||||||
SnmpProfile("generic_snmp", "Generic SNMP"),
|
SnmpProfile("generic_snmp", "Generic SNMP"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def discover_snmp_device(host: str, credential: SnmpCredential) -> DiscoveredSnmpDevice:
|
def discover_snmp_device(host: str, credential: SnmpCredential, asset_type: str | None = None) -> DiscoveredSnmpDevice:
|
||||||
client = SnmpV2Client(host, credential)
|
client = SnmpV2Client(host, credential)
|
||||||
system = client.get_many([SYS_NAME, SYS_DESCR, SYS_UPTIME])
|
system = client.get_many([SYS_NAME, SYS_DESCR, SYS_UPTIME])
|
||||||
profile = _select_profile(system)
|
profile = _select_profile(system, asset_type)
|
||||||
interfaces = _discover_interfaces(client)
|
interfaces = _discover_interfaces(client) if profile.include_interfaces else []
|
||||||
health_items = profile.discover_health_items(client)
|
health_items = profile.discover_health_items(client)
|
||||||
return DiscoveredSnmpDevice(
|
return DiscoveredSnmpDevice(
|
||||||
host=host,
|
host=host,
|
||||||
@@ -139,18 +162,36 @@ def discover_snmp_device(host: str, credential: SnmpCredential) -> DiscoveredSnm
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _select_profile(system: dict[tuple[int, ...], Any]) -> SnmpProfile:
|
def _select_profile(system: dict[tuple[int, ...], Any], asset_type: str | None = None) -> SnmpProfile:
|
||||||
system_text = " ".join(
|
system_text = " ".join(
|
||||||
value.lower()
|
value.lower()
|
||||||
for value in [_string_value(system.get(SYS_NAME)), _string_value(system.get(SYS_DESCR))]
|
for value in [_string_value(system.get(SYS_NAME)), _string_value(system.get(SYS_DESCR))]
|
||||||
if value
|
if value
|
||||||
)
|
)
|
||||||
|
normalized_asset_type = (asset_type or "").strip().lower()
|
||||||
|
if normalized_asset_type == "server":
|
||||||
|
proxmox_profile = _profile_by_key("proxmox_ve")
|
||||||
|
if proxmox_profile.matches(system_text):
|
||||||
|
return proxmox_profile
|
||||||
|
return _profile_by_key("linux_server")
|
||||||
|
if normalized_asset_type == "network_device":
|
||||||
|
for profile in SNMP_PROFILES:
|
||||||
|
if profile.include_interfaces and profile.match_terms and profile.matches(system_text):
|
||||||
|
return profile
|
||||||
|
return _profile_by_key("generic_snmp")
|
||||||
for profile in SNMP_PROFILES:
|
for profile in SNMP_PROFILES:
|
||||||
if profile.match_terms and profile.matches(system_text):
|
if profile.match_terms and profile.matches(system_text):
|
||||||
return profile
|
return profile
|
||||||
return SNMP_PROFILES[-1]
|
return SNMP_PROFILES[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_by_key(key: str) -> SnmpProfile:
|
||||||
|
for profile in SNMP_PROFILES:
|
||||||
|
if profile.key == key:
|
||||||
|
return profile
|
||||||
|
raise KeyError(key)
|
||||||
|
|
||||||
|
|
||||||
def _capabilities(
|
def _capabilities(
|
||||||
system: dict[tuple[int, ...], Any],
|
system: dict[tuple[int, ...], Any],
|
||||||
interfaces: list[DiscoveredSnmpInterface],
|
interfaces: list[DiscoveredSnmpInterface],
|
||||||
@@ -163,9 +204,9 @@ def _capabilities(
|
|||||||
"interface_status": bool(interfaces),
|
"interface_status": bool(interfaces),
|
||||||
"interface_traffic": bool(interfaces),
|
"interface_traffic": bool(interfaces),
|
||||||
"interface_errors": bool(interfaces),
|
"interface_errors": bool(interfaces),
|
||||||
"cpu": "cpu_load" in item_types,
|
"cpu": bool({"cpu_load", "linux_load_average"} & item_types),
|
||||||
"memory": "memory_usage" in item_types,
|
"memory": bool({"memory_usage", "linux_memory_usage"} & item_types),
|
||||||
"storage": "storage_usage" in item_types,
|
"storage": bool({"storage_usage", "linux_disk_usage"} & item_types),
|
||||||
"sensors": "sensor_value" in item_types,
|
"sensors": "sensor_value" in item_types,
|
||||||
"environmental": "sensor_value" in item_types,
|
"environmental": "sensor_value" in item_types,
|
||||||
}
|
}
|
||||||
@@ -195,12 +236,13 @@ def _discover_interfaces(client: "SnmpV2Client") -> list[DiscoveredSnmpInterface
|
|||||||
|
|
||||||
interfaces: list[DiscoveredSnmpInterface] = []
|
interfaces: list[DiscoveredSnmpInterface] = []
|
||||||
for index in indexes:
|
for index in indexes:
|
||||||
name = _string_value(name_by_index.get(index)) or _string_value(description_by_index.get(index)) or f"Interface {index}"
|
identity = _interface_identity(index, name_by_index.get(index), description_by_index.get(index))
|
||||||
interfaces.append(
|
interfaces.append(
|
||||||
DiscoveredSnmpInterface(
|
DiscoveredSnmpInterface(
|
||||||
index=index,
|
index=index,
|
||||||
name=name,
|
name=identity.name,
|
||||||
description=_string_value(description_by_index.get(index)),
|
label=identity.label,
|
||||||
|
description=identity.description,
|
||||||
admin_status=_status_label(admin_by_index.get(index)),
|
admin_status=_status_label(admin_by_index.get(index)),
|
||||||
oper_status=_status_label(oper_by_index.get(index)),
|
oper_status=_status_label(oper_by_index.get(index)),
|
||||||
speed_bps=_int_value(speed_by_index.get(index)),
|
speed_bps=_int_value(speed_by_index.get(index)),
|
||||||
@@ -209,6 +251,86 @@ def _discover_interfaces(client: "SnmpV2Client") -> list[DiscoveredSnmpInterface
|
|||||||
return interfaces
|
return interfaces
|
||||||
|
|
||||||
|
|
||||||
|
def _interface_identity(index: int, name_value: Any, description_value: Any) -> InterfaceIdentity:
|
||||||
|
raw_name = _string_value(name_value)
|
||||||
|
raw_description = _string_value(description_value)
|
||||||
|
name = raw_name or raw_description or f"Interface {index}"
|
||||||
|
description = raw_description if raw_description and raw_description != name else None
|
||||||
|
return InterfaceIdentity(
|
||||||
|
label=_friendly_interface_label(name, raw_description, index),
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _friendly_interface_label(name: str, description: str | None, index: int) -> str:
|
||||||
|
value = _clean_interface_text(name) or _clean_interface_text(description or "") or f"Interface {index}"
|
||||||
|
lower = value.lower()
|
||||||
|
|
||||||
|
if lower in {"lo", "lo0", "loopback", "loopback0"}:
|
||||||
|
return "Loopback"
|
||||||
|
if lower.startswith("vlan"):
|
||||||
|
suffix = _suffix_after_prefix(value, "vlan")
|
||||||
|
return f"VLAN {suffix}" if suffix else "VLAN"
|
||||||
|
if lower.startswith("bridge"):
|
||||||
|
suffix = _suffix_after_prefix(value, "bridge")
|
||||||
|
return f"Bridge {suffix}" if suffix else "Bridge"
|
||||||
|
if lower.startswith("br-"):
|
||||||
|
return f"Bridge {value[3:]}"
|
||||||
|
if lower.startswith("bond"):
|
||||||
|
suffix = _suffix_after_prefix(value, "bond")
|
||||||
|
return f"Bond {suffix}" if suffix else "Bond"
|
||||||
|
if lower.startswith("lag"):
|
||||||
|
suffix = _suffix_after_prefix(value, "lag")
|
||||||
|
return f"LAG {suffix}" if suffix else "LAG"
|
||||||
|
if lower.startswith("sfp-sfpplus"):
|
||||||
|
suffix = _suffix_after_prefix(value, "sfp-sfpplus")
|
||||||
|
return f"SFP+ {suffix}" if suffix else "SFP+"
|
||||||
|
if lower.startswith("sfpplus"):
|
||||||
|
suffix = _suffix_after_prefix(value, "sfpplus")
|
||||||
|
return f"SFP+ {suffix}" if suffix else "SFP+"
|
||||||
|
if lower.startswith("sfp"):
|
||||||
|
suffix = _suffix_after_prefix(value, "sfp")
|
||||||
|
return f"SFP {suffix}" if suffix else "SFP"
|
||||||
|
if lower.startswith("ethernet"):
|
||||||
|
suffix = _suffix_after_prefix(value, "ethernet")
|
||||||
|
return f"Ethernet {suffix}" if suffix else "Ethernet"
|
||||||
|
if lower.startswith("ether"):
|
||||||
|
suffix = _suffix_after_prefix(value, "ether")
|
||||||
|
return f"Port {suffix}" if suffix else "Ethernet port"
|
||||||
|
if lower.startswith("gi"):
|
||||||
|
suffix = _suffix_after_prefix(value, "gi")
|
||||||
|
return f"GigabitEthernet {suffix}" if suffix else "GigabitEthernet"
|
||||||
|
if lower.startswith("te"):
|
||||||
|
suffix = _suffix_after_prefix(value, "te")
|
||||||
|
return f"TenGigabitEthernet {suffix}" if suffix else "TenGigabitEthernet"
|
||||||
|
if lower.startswith("fa"):
|
||||||
|
suffix = _suffix_after_prefix(value, "fa")
|
||||||
|
return f"FastEthernet {suffix}" if suffix else "FastEthernet"
|
||||||
|
if lower.startswith("ge-"):
|
||||||
|
return f"GigabitEthernet {value[3:]}"
|
||||||
|
if lower.startswith("xe-"):
|
||||||
|
return f"TenGigabitEthernet {value[3:]}"
|
||||||
|
if lower.startswith("et-"):
|
||||||
|
return f"Ethernet {value[3:]}"
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_interface_text(value: str) -> str:
|
||||||
|
cleaned = " ".join(value.strip().split())
|
||||||
|
if not cleaned:
|
||||||
|
return ""
|
||||||
|
if cleaned.startswith("Interface "):
|
||||||
|
return cleaned
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
|
||||||
|
def _suffix_after_prefix(value: str, prefix: str) -> str:
|
||||||
|
suffix = value[len(prefix) :].strip(" -_./")
|
||||||
|
return re.sub(r"\s+", " ", suffix)
|
||||||
|
|
||||||
|
|
||||||
def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
|
def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
|
||||||
items: list[DiscoveredSnmpHealthItem] = []
|
items: list[DiscoveredSnmpHealthItem] = []
|
||||||
|
|
||||||
@@ -251,6 +373,8 @@ def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmp
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
elif storage_type in {HR_STORAGE_FIXED_DISK, HR_STORAGE_REMOVABLE_DISK}:
|
elif storage_type in {HR_STORAGE_FIXED_DISK, HR_STORAGE_REMOVABLE_DISK}:
|
||||||
|
if not _is_monitorable_storage_path(description):
|
||||||
|
continue
|
||||||
items.append(
|
items.append(
|
||||||
DiscoveredSnmpHealthItem(
|
DiscoveredSnmpHealthItem(
|
||||||
item_id=f"storage.{index}.usage",
|
item_id=f"storage.{index}.usage",
|
||||||
@@ -264,6 +388,52 @@ def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmp
|
|||||||
return _deduplicate_items(items)
|
return _deduplicate_items(items)
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_linux_server_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
|
||||||
|
items: list[DiscoveredSnmpHealthItem] = []
|
||||||
|
|
||||||
|
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:
|
||||||
|
items.append(
|
||||||
|
DiscoveredSnmpHealthItem(
|
||||||
|
item_id=f"linux.load.{index}",
|
||||||
|
item_type="linux_load_average",
|
||||||
|
group="Server Health",
|
||||||
|
label=label,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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:
|
||||||
|
items.append(
|
||||||
|
DiscoveredSnmpHealthItem(
|
||||||
|
item_id="linux.memory.real",
|
||||||
|
item_type="linux_memory_usage",
|
||||||
|
group="Server Health",
|
||||||
|
label="Memory used",
|
||||||
|
unit="%",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
disk_paths = _indexed_values(client.walk(UCD_DSK_PATH, max_items=256))
|
||||||
|
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:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
DiscoveredSnmpHealthItem(
|
||||||
|
item_id=f"linux.disk.{index}",
|
||||||
|
item_type="linux_disk_usage",
|
||||||
|
group="Storage",
|
||||||
|
label=_storage_usage_label(path),
|
||||||
|
unit="%",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return _deduplicate_items(items)
|
||||||
|
|
||||||
|
|
||||||
def _discover_sensor_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
|
def _discover_sensor_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]:
|
||||||
sensor_types = _indexed_values(client.walk(ENT_PHY_SENSOR_TYPE, max_items=256))
|
sensor_types = _indexed_values(client.walk(ENT_PHY_SENSOR_TYPE, max_items=256))
|
||||||
sensor_values = _indexed_values(client.walk(ENT_PHY_SENSOR_VALUE, max_items=256))
|
sensor_values = _indexed_values(client.walk(ENT_PHY_SENSOR_VALUE, max_items=256))
|
||||||
@@ -299,6 +469,20 @@ def _storage_usage_label(description: str) -> str:
|
|||||||
return f"{normalized} usage"
|
return f"{normalized} usage"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_monitorable_storage_path(description: str) -> bool:
|
||||||
|
normalized = description.strip()
|
||||||
|
if not normalized.startswith("/"):
|
||||||
|
return True
|
||||||
|
ignored_exact = {"/dev", "/dev/shm", "/proc", "/run", "/sys", "/tmp"}
|
||||||
|
ignored_prefixes = (
|
||||||
|
"/dev/",
|
||||||
|
"/proc/",
|
||||||
|
"/run/",
|
||||||
|
"/sys/",
|
||||||
|
)
|
||||||
|
return normalized not in ignored_exact and not normalized.startswith(ignored_prefixes)
|
||||||
|
|
||||||
|
|
||||||
def _deduplicate_items(items: list[DiscoveredSnmpHealthItem]) -> list[DiscoveredSnmpHealthItem]:
|
def _deduplicate_items(items: list[DiscoveredSnmpHealthItem]) -> list[DiscoveredSnmpHealthItem]:
|
||||||
seen: set[tuple[str, str]] = set()
|
seen: set[tuple[str, str]] = set()
|
||||||
deduplicated: list[DiscoveredSnmpHealthItem] = []
|
deduplicated: list[DiscoveredSnmpHealthItem] = []
|
||||||
|
|||||||
+207
-13
@@ -17,8 +17,15 @@ from app.services.snmp import (
|
|||||||
SYS_DESCR,
|
SYS_DESCR,
|
||||||
SYS_NAME,
|
SYS_NAME,
|
||||||
SYS_UPTIME,
|
SYS_UPTIME,
|
||||||
|
UCD_DSK_PATH,
|
||||||
|
UCD_DSK_PERCENT,
|
||||||
|
UCD_LA_LOAD_INT,
|
||||||
|
UCD_MEM_AVAIL_REAL,
|
||||||
|
UCD_MEM_TOTAL_REAL,
|
||||||
DiscoveredSnmpDevice,
|
DiscoveredSnmpDevice,
|
||||||
DiscoveredSnmpInterface,
|
DiscoveredSnmpInterface,
|
||||||
|
IF_DESCR,
|
||||||
|
IF_NAME,
|
||||||
SnmpCredential,
|
SnmpCredential,
|
||||||
SnmpDiscoveryError,
|
SnmpDiscoveryError,
|
||||||
discover_snmp_device,
|
discover_snmp_device,
|
||||||
@@ -34,10 +41,10 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
|
|||||||
)
|
)
|
||||||
db_session.add(profile)
|
db_session.add(profile)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
calls: list[tuple[str, SnmpCredential]] = []
|
calls: list[tuple[str, SnmpCredential, str | None]] = []
|
||||||
|
|
||||||
def fake_discover(host: str, credential: SnmpCredential) -> DiscoveredSnmpDevice:
|
def fake_discover(host: str, credential: SnmpCredential, asset_type: str | None = None) -> DiscoveredSnmpDevice:
|
||||||
calls.append((host, credential))
|
calls.append((host, credential, asset_type))
|
||||||
return DiscoveredSnmpDevice(
|
return DiscoveredSnmpDevice(
|
||||||
host=host,
|
host=host,
|
||||||
device_name="core-sw-1",
|
device_name="core-sw-1",
|
||||||
@@ -47,6 +54,7 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
|
|||||||
DiscoveredSnmpInterface(
|
DiscoveredSnmpInterface(
|
||||||
index=1,
|
index=1,
|
||||||
name="Gi1/0/1",
|
name="Gi1/0/1",
|
||||||
|
label="GigabitEthernet 1/0/1",
|
||||||
description="Uplink",
|
description="Uplink",
|
||||||
admin_status="up",
|
admin_status="up",
|
||||||
oper_status="up",
|
oper_status="up",
|
||||||
@@ -64,6 +72,7 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
|
|||||||
(
|
(
|
||||||
"192.0.2.10",
|
"192.0.2.10",
|
||||||
SnmpCredential(community="private-community", port=1161, timeout_seconds=4, retries=2),
|
SnmpCredential(community="private-community", port=1161, timeout_seconds=4, retries=2),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
body = response.json()
|
body = response.json()
|
||||||
@@ -79,6 +88,7 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
|
|||||||
{
|
{
|
||||||
"index": 1,
|
"index": 1,
|
||||||
"name": "Gi1/0/1",
|
"name": "Gi1/0/1",
|
||||||
|
"label": "GigabitEthernet 1/0/1",
|
||||||
"description": "Uplink",
|
"description": "Uplink",
|
||||||
"admin_status": "up",
|
"admin_status": "up",
|
||||||
"oper_status": "up",
|
"oper_status": "up",
|
||||||
@@ -96,22 +106,22 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
|
|||||||
{
|
{
|
||||||
"item_id": "interface.1.status",
|
"item_id": "interface.1.status",
|
||||||
"item_type": "interface_status",
|
"item_type": "interface_status",
|
||||||
"group": "Interface Gi1/0/1",
|
"group": "Interface GigabitEthernet 1/0/1",
|
||||||
"label": "Gi1/0/1 status",
|
"label": "GigabitEthernet 1/0/1 status",
|
||||||
"unit": None,
|
"unit": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"item_id": "interface.1.traffic",
|
"item_id": "interface.1.traffic",
|
||||||
"item_type": "interface_traffic",
|
"item_type": "interface_traffic",
|
||||||
"group": "Interface Gi1/0/1",
|
"group": "Interface GigabitEthernet 1/0/1",
|
||||||
"label": "Gi1/0/1 traffic",
|
"label": "GigabitEthernet 1/0/1 traffic",
|
||||||
"unit": "bps",
|
"unit": "bps",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"item_id": "interface.1.errors",
|
"item_id": "interface.1.errors",
|
||||||
"item_type": "interface_errors",
|
"item_type": "interface_errors",
|
||||||
"group": "Interface Gi1/0/1",
|
"group": "Interface GigabitEthernet 1/0/1",
|
||||||
"label": "Gi1/0/1 errors and discards",
|
"label": "GigabitEthernet 1/0/1 errors and discards",
|
||||||
"unit": "count",
|
"unit": "count",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -119,6 +129,166 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl
|
|||||||
assert "1.3.6" not in response.text
|
assert "1.3.6" not in response.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_snmp_interface_discovery_normalizes_network_interface_labels(monkeypatch) -> None:
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, host: str, credential: SnmpCredential) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.credential = credential
|
||||||
|
|
||||||
|
def get_many(self, _oids):
|
||||||
|
return {
|
||||||
|
SYS_NAME: "lan-party-router",
|
||||||
|
SYS_DESCR: "MikroTik RouterOS",
|
||||||
|
SYS_UPTIME: 10_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
def walk(self, base_oid, max_items=128):
|
||||||
|
values = {
|
||||||
|
IF_NAME: {
|
||||||
|
(*IF_NAME, 1): "ether1",
|
||||||
|
(*IF_NAME, 2): "sfp-sfpplus1",
|
||||||
|
(*IF_NAME, 3): "bridge1",
|
||||||
|
(*IF_NAME, 4): "vlan20",
|
||||||
|
(*IF_NAME, 5): "lo",
|
||||||
|
(*IF_NAME, 6): "Gi1/0/1",
|
||||||
|
(*IF_NAME, 7): "Te1/1/1",
|
||||||
|
(*IF_NAME, 8): "xe-0/0/1",
|
||||||
|
},
|
||||||
|
IF_DESCR: {
|
||||||
|
(*IF_DESCR, 1): "WAN",
|
||||||
|
(*IF_DESCR, 2): "Fiber uplink",
|
||||||
|
(*IF_DESCR, 3): "LAN bridge",
|
||||||
|
(*IF_DESCR, 4): "Guest VLAN",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return values.get(base_oid, {})
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.snmp.SnmpV2Client", FakeClient)
|
||||||
|
|
||||||
|
discovered = discover_snmp_device("10.10.10.254", SnmpCredential(community="private-community"))
|
||||||
|
|
||||||
|
assert [(interface.name, interface.label, interface.description) for interface in discovered.interfaces] == [
|
||||||
|
("ether1", "Port 1", "WAN"),
|
||||||
|
("sfp-sfpplus1", "SFP+ 1", "Fiber uplink"),
|
||||||
|
("bridge1", "Bridge 1", "LAN bridge"),
|
||||||
|
("vlan20", "VLAN 20", "Guest VLAN"),
|
||||||
|
("lo", "Loopback", None),
|
||||||
|
("Gi1/0/1", "GigabitEthernet 1/0/1", None),
|
||||||
|
("Te1/1/1", "TenGigabitEthernet 1/1/1", None),
|
||||||
|
("xe-0/0/1", "TenGigabitEthernet 0/0/1", None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_snmp_server_asset_type_uses_linux_server_mibs_and_keeps_interfaces(monkeypatch) -> None:
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, host: str, credential: SnmpCredential) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.credential = credential
|
||||||
|
|
||||||
|
def get_many(self, oids):
|
||||||
|
values = {
|
||||||
|
SYS_NAME: "app-1",
|
||||||
|
SYS_DESCR: "Linux app-1 6.1.0 net-snmp",
|
||||||
|
SYS_UPTIME: 10_000,
|
||||||
|
UCD_MEM_TOTAL_REAL: 1_000_000,
|
||||||
|
UCD_MEM_AVAIL_REAL: 250_000,
|
||||||
|
}
|
||||||
|
return {oid: values.get(oid) for oid in oids}
|
||||||
|
|
||||||
|
def walk(self, base_oid, max_items=128):
|
||||||
|
values = {
|
||||||
|
IF_NAME: {(*IF_NAME, 1): "eth0"},
|
||||||
|
UCD_LA_LOAD_INT: {
|
||||||
|
(*UCD_LA_LOAD_INT, 1): 123,
|
||||||
|
(*UCD_LA_LOAD_INT, 2): 97,
|
||||||
|
(*UCD_LA_LOAD_INT, 3): 88,
|
||||||
|
},
|
||||||
|
UCD_DSK_PATH: {(*UCD_DSK_PATH, 1): "/"},
|
||||||
|
UCD_DSK_PERCENT: {(*UCD_DSK_PERCENT, 1): 42},
|
||||||
|
}
|
||||||
|
return values.get(base_oid, {})
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.snmp.SnmpV2Client", FakeClient)
|
||||||
|
|
||||||
|
discovered = discover_snmp_device("192.0.2.21", SnmpCredential(community="private-community"), asset_type="server")
|
||||||
|
|
||||||
|
assert discovered.profile_key == "linux_server"
|
||||||
|
assert discovered.profile_name == "Linux Server"
|
||||||
|
assert [(interface.name, interface.label) for interface in discovered.interfaces] == [("eth0", "eth0")]
|
||||||
|
assert discovered.capabilities["interfaces"] is True
|
||||||
|
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", "%"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_snmp_server_asset_type_falls_back_to_host_resources(monkeypatch) -> None:
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, host: str, credential: SnmpCredential) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.credential = credential
|
||||||
|
|
||||||
|
def get_many(self, oids):
|
||||||
|
values = {
|
||||||
|
SYS_NAME: "app-2",
|
||||||
|
SYS_DESCR: "Linux app-2",
|
||||||
|
SYS_UPTIME: 10_000,
|
||||||
|
UCD_MEM_TOTAL_REAL: None,
|
||||||
|
UCD_MEM_AVAIL_REAL: None,
|
||||||
|
}
|
||||||
|
return {oid: values.get(oid) for oid in oids}
|
||||||
|
|
||||||
|
def walk(self, base_oid, max_items=128):
|
||||||
|
values = {
|
||||||
|
IF_NAME: {(*IF_NAME, 1): "eth0"},
|
||||||
|
HR_PROCESSOR_LOAD: {(*HR_PROCESSOR_LOAD, 196608): 17},
|
||||||
|
HR_STORAGE_TYPE: {
|
||||||
|
(*HR_STORAGE_TYPE, 1): HR_STORAGE_RAM,
|
||||||
|
(*HR_STORAGE_TYPE, 31): HR_STORAGE_FIXED_DISK,
|
||||||
|
(*HR_STORAGE_TYPE, 35): HR_STORAGE_FIXED_DISK,
|
||||||
|
},
|
||||||
|
HR_STORAGE_DESCR: {
|
||||||
|
(*HR_STORAGE_DESCR, 1): "Physical memory",
|
||||||
|
(*HR_STORAGE_DESCR, 31): "/",
|
||||||
|
(*HR_STORAGE_DESCR, 35): "/run/credentials/systemd-journald.service",
|
||||||
|
},
|
||||||
|
HR_STORAGE_ALLOCATION_UNITS: {
|
||||||
|
(*HR_STORAGE_ALLOCATION_UNITS, 1): 1024,
|
||||||
|
(*HR_STORAGE_ALLOCATION_UNITS, 31): 4096,
|
||||||
|
(*HR_STORAGE_ALLOCATION_UNITS, 35): 4096,
|
||||||
|
},
|
||||||
|
HR_STORAGE_SIZE: {
|
||||||
|
(*HR_STORAGE_SIZE, 1): 2048,
|
||||||
|
(*HR_STORAGE_SIZE, 31): 4096,
|
||||||
|
(*HR_STORAGE_SIZE, 35): 4096,
|
||||||
|
},
|
||||||
|
HR_STORAGE_USED: {
|
||||||
|
(*HR_STORAGE_USED, 1): 1024,
|
||||||
|
(*HR_STORAGE_USED, 31): 1024,
|
||||||
|
(*HR_STORAGE_USED, 35): 1024,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return values.get(base_oid, {})
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.snmp.SnmpV2Client", FakeClient)
|
||||||
|
|
||||||
|
discovered = discover_snmp_device("192.0.2.22", SnmpCredential(community="private-community"), asset_type="server")
|
||||||
|
|
||||||
|
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", "%"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_snmp_profile_mapping_discovers_standard_health_items(monkeypatch) -> None:
|
def test_snmp_profile_mapping_discovers_standard_health_items(monkeypatch) -> None:
|
||||||
class FakeClient:
|
class FakeClient:
|
||||||
def __init__(self, host: str, credential: SnmpCredential) -> None:
|
def __init__(self, host: str, credential: SnmpCredential) -> None:
|
||||||
@@ -128,7 +298,7 @@ def test_snmp_profile_mapping_discovers_standard_health_items(monkeypatch) -> No
|
|||||||
def get_many(self, _oids):
|
def get_many(self, _oids):
|
||||||
return {
|
return {
|
||||||
SYS_NAME: "edge-router",
|
SYS_NAME: "edge-router",
|
||||||
SYS_DESCR: "Linux edge-router net-snmp",
|
SYS_DESCR: "Generic SNMP appliance",
|
||||||
SYS_UPTIME: 10_000,
|
SYS_UPTIME: 10_000,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,8 +335,8 @@ def test_snmp_profile_mapping_discovers_standard_health_items(monkeypatch) -> No
|
|||||||
|
|
||||||
discovered = discover_snmp_device("192.0.2.20", SnmpCredential(community="private-community"))
|
discovered = discover_snmp_device("192.0.2.20", SnmpCredential(community="private-community"))
|
||||||
|
|
||||||
assert discovered.profile_key == "net_snmp"
|
assert discovered.profile_key == "generic_snmp"
|
||||||
assert discovered.profile_name == "Net-SNMP Host Resources"
|
assert discovered.profile_name == "Generic SNMP"
|
||||||
assert discovered.capabilities["cpu"] is True
|
assert discovered.capabilities["cpu"] is True
|
||||||
assert discovered.capabilities["memory"] is True
|
assert discovered.capabilities["memory"] is True
|
||||||
assert discovered.capabilities["storage"] is True
|
assert discovered.capabilities["storage"] is True
|
||||||
@@ -179,6 +349,30 @@ def test_snmp_profile_mapping_discovers_standard_health_items(monkeypatch) -> No
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_snmp_profile_mapping_identifies_proxmox_before_linux(monkeypatch) -> None:
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, host: str, credential: SnmpCredential) -> None:
|
||||||
|
self.host = host
|
||||||
|
self.credential = credential
|
||||||
|
|
||||||
|
def get_many(self, _oids):
|
||||||
|
return {
|
||||||
|
SYS_NAME: "pve-1",
|
||||||
|
SYS_DESCR: "Linux pve-1 6.8.12-9-pve #1 SMP PREEMPT_DYNAMIC PMX",
|
||||||
|
SYS_UPTIME: 10_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
def walk(self, base_oid, max_items=128):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.services.snmp.SnmpV2Client", FakeClient)
|
||||||
|
|
||||||
|
discovered = discover_snmp_device("192.0.2.30", SnmpCredential(community="private-community"))
|
||||||
|
|
||||||
|
assert discovered.profile_key == "proxmox_ve"
|
||||||
|
assert discovered.profile_name == "Proxmox VE Server"
|
||||||
|
|
||||||
|
|
||||||
def test_snmp_discovery_rejects_missing_profile(client: TestClient) -> None:
|
def test_snmp_discovery_rejects_missing_profile(client: TestClient) -> None:
|
||||||
response = client.post("/discovery/snmp", json={"host": "192.0.2.10", "credential_profile_id": 999})
|
response = client.post("/discovery/snmp", json={"host": "192.0.2.10", "credential_profile_id": 999})
|
||||||
|
|
||||||
@@ -210,7 +404,7 @@ def test_snmp_discovery_reports_probe_failure(client: TestClient, db_session, mo
|
|||||||
db_session.add(profile)
|
db_session.add(profile)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
def fake_discover(host: str, credential: SnmpCredential) -> DiscoveredSnmpDevice:
|
def fake_discover(host: str, credential: SnmpCredential, asset_type: str | None = None) -> DiscoveredSnmpDevice:
|
||||||
raise SnmpDiscoveryError("timeout")
|
raise SnmpDiscoveryError("timeout")
|
||||||
|
|
||||||
monkeypatch.setattr("app.api.discovery.discover_snmp_device", fake_discover)
|
monkeypatch.setattr("app.api.discovery.discover_snmp_device", fake_discover)
|
||||||
|
|||||||
+1
-1
@@ -101,7 +101,7 @@ Implemented initial SNMP collection slice:
|
|||||||
|
|
||||||
Implemented SNMP profile mapping slice:
|
Implemented SNMP profile mapping slice:
|
||||||
|
|
||||||
- SNMP discovery selects an internal friendly profile such as Generic SNMP, Net-SNMP Host Resources, Cisco IOS SNMP, or MikroTik RouterOS SNMP from system identity details.
|
- SNMP discovery selects an internal friendly profile such as Generic SNMP, Linux Server, Proxmox VE Server, Cisco IOS SNMP, or MikroTik RouterOS SNMP from system identity details.
|
||||||
- Discovery reports capability flags for system identity, interfaces, CPU, memory, storage, and sensors.
|
- Discovery reports capability flags for system identity, interfaces, CPU, memory, storage, and sensors.
|
||||||
- Standard HOST-RESOURCES CPU load, memory usage, and disk/storage usage are exposed as friendly monitorable items when supported.
|
- Standard HOST-RESOURCES CPU load, memory usage, and disk/storage usage are exposed as friendly monitorable items when supported.
|
||||||
- Standard ENTITY-SENSOR environmental readings are exposed as friendly monitorable items when supported.
|
- Standard ENTITY-SENSOR environmental readings are exposed as friendly monitorable items when supported.
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro
|
|||||||
const discovered = await api.discoverSnmpDevice(token, {
|
const discovered = await api.discoverSnmpDevice(token, {
|
||||||
host: setupAddress,
|
host: setupAddress,
|
||||||
credential_profile_id: profileId,
|
credential_profile_id: profileId,
|
||||||
|
asset_type: selectedAsset?.asset_type ?? assetType,
|
||||||
});
|
});
|
||||||
setDiscoveryResult(discovered);
|
setDiscoveryResult(discovered);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CheckSquare, Network, RefreshCw, Router, Search, Square } from "lucide-
|
|||||||
|
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { Button } from "../components/Button";
|
import { Button } from "../components/Button";
|
||||||
import type { SnmpCredentialProfile, SnmpDiscoveryItem, SnmpDiscoveryResult } from "../types/api";
|
import type { SnmpCredentialProfile, SnmpDiscoveredInterface, SnmpDiscoveryItem, SnmpDiscoveryResult } from "../types/api";
|
||||||
|
|
||||||
interface DiscoveryPageProps {
|
interface DiscoveryPageProps {
|
||||||
token: string;
|
token: string;
|
||||||
@@ -169,8 +169,8 @@ export function DiscoveryPage({ token }: DiscoveryPageProps) {
|
|||||||
result.interfaces.map((item) => (
|
result.interfaces.map((item) => (
|
||||||
<div key={item.index} className="grid gap-2 p-4 md:grid-cols-[1fr_120px_120px_120px] md:items-center">
|
<div key={item.index} className="grid gap-2 p-4 md:grid-cols-[1fr_120px_120px_120px] md:items-center">
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{item.name}</div>
|
<div className="font-medium">{item.label}</div>
|
||||||
<div className="text-sm text-slate-400">{item.description || "No description"}</div>
|
<div className="text-sm text-slate-400">{interfaceContext(item)}</div>
|
||||||
</div>
|
</div>
|
||||||
<Status value={item.admin_status || "unknown"} />
|
<Status value={item.admin_status || "unknown"} />
|
||||||
<Status value={item.oper_status || "unknown"} />
|
<Status value={item.oper_status || "unknown"} />
|
||||||
@@ -268,6 +268,11 @@ function friendlyItemType(value: string) {
|
|||||||
return value.replaceAll("_", " ");
|
return value.replaceAll("_", " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
function formatCapabilities(capabilities: Record<string, boolean>) {
|
function formatCapabilities(capabilities: Record<string, boolean>) {
|
||||||
const labels: Record<string, string> = {
|
const labels: Record<string, string> = {
|
||||||
interfaces: "interfaces",
|
interfaces: "interfaces",
|
||||||
|
|||||||
@@ -151,11 +151,13 @@ export interface SnmpCredentialProfileUpdate {
|
|||||||
export interface SnmpDiscoveryRequest {
|
export interface SnmpDiscoveryRequest {
|
||||||
host: string;
|
host: string;
|
||||||
credential_profile_id: number;
|
credential_profile_id: number;
|
||||||
|
asset_type?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnmpDiscoveredInterface {
|
export interface SnmpDiscoveredInterface {
|
||||||
index: number;
|
index: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
label: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
admin_status?: string | null;
|
admin_status?: string | null;
|
||||||
oper_status?: string | null;
|
oper_status?: string | null;
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ ENT_PHY_SENSOR_SCALE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 2)
|
|||||||
ENT_PHY_SENSOR_PRECISION = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 3)
|
ENT_PHY_SENSOR_PRECISION = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 3)
|
||||||
ENT_PHY_SENSOR_VALUE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 4)
|
ENT_PHY_SENSOR_VALUE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 4)
|
||||||
ENT_PHY_SENSOR_OPER_STATUS = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 5)
|
ENT_PHY_SENSOR_OPER_STATUS = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 5)
|
||||||
|
UCD_LA_LOAD_INT = (1, 3, 6, 1, 4, 1, 2021, 10, 1, 5)
|
||||||
|
UCD_MEM_TOTAL_REAL = (1, 3, 6, 1, 4, 1, 2021, 4, 5, 0)
|
||||||
|
UCD_MEM_AVAIL_REAL = (1, 3, 6, 1, 4, 1, 2021, 4, 6, 0)
|
||||||
|
UCD_DSK_PERCENT = (1, 3, 6, 1, 4, 1, 2021, 9, 1, 9)
|
||||||
|
|
||||||
STATUS_LABELS = {
|
STATUS_LABELS = {
|
||||||
1: "up",
|
1: "up",
|
||||||
@@ -128,6 +132,61 @@ def _run_snmp_check_sync(config: SnmpCheckConfig) -> SnmpCheckResult:
|
|||||||
metrics=[SnmpMetricValue(name="load_percent", value=float(value), unit="%")],
|
metrics=[SnmpMetricValue(name="load_percent", value=float(value), unit="%")],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if config.item_type == "linux_load_average":
|
||||||
|
load_index = _item_index(config.item_id, "linux", position=2)
|
||||||
|
if load_index is None:
|
||||||
|
return SnmpCheckResult(status="down", response_time_ms=0, message="SNMP Linux load item was not valid")
|
||||||
|
oid = _with_index(UCD_LA_LOAD_INT, load_index)
|
||||||
|
value = _int_value(client.get_many([oid]).get(oid))
|
||||||
|
response_time_ms = int((perf_counter() - started) * 1000)
|
||||||
|
if value is None:
|
||||||
|
return SnmpCheckResult(status="down", response_time_ms=response_time_ms, message="Linux load average was not reported")
|
||||||
|
load_average = value / 100
|
||||||
|
label = config.label or "Load average"
|
||||||
|
return SnmpCheckResult(
|
||||||
|
status="up",
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
message=f"{label} is {load_average:.2f}",
|
||||||
|
metrics=[SnmpMetricValue(name="load_average", value=load_average)],
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.item_type == "linux_memory_usage":
|
||||||
|
values = client.get_many([UCD_MEM_TOTAL_REAL, UCD_MEM_AVAIL_REAL])
|
||||||
|
response_time_ms = int((perf_counter() - started) * 1000)
|
||||||
|
total_kb = _int_value(values.get(UCD_MEM_TOTAL_REAL))
|
||||||
|
available_kb = _int_value(values.get(UCD_MEM_AVAIL_REAL))
|
||||||
|
if not total_kb or available_kb is None:
|
||||||
|
return SnmpCheckResult(status="down", response_time_ms=response_time_ms, message="Linux memory was not reported")
|
||||||
|
used_kb = total_kb - available_kb
|
||||||
|
used_percent = (used_kb / total_kb) * 100
|
||||||
|
return SnmpCheckResult(
|
||||||
|
status="up",
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
message=f"Memory is {used_percent:.1f}% used",
|
||||||
|
metrics=[
|
||||||
|
SnmpMetricValue(name="used_percent", value=used_percent, unit="%"),
|
||||||
|
SnmpMetricValue(name="used_bytes", value=float(used_kb * 1024), unit="bytes"),
|
||||||
|
SnmpMetricValue(name="total_bytes", value=float(total_kb * 1024), unit="bytes"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if config.item_type == "linux_disk_usage":
|
||||||
|
disk_index = _item_index(config.item_id, "linux", position=2)
|
||||||
|
if disk_index is None:
|
||||||
|
return SnmpCheckResult(status="down", response_time_ms=0, message="SNMP Linux disk item was not valid")
|
||||||
|
oid = _with_index(UCD_DSK_PERCENT, disk_index)
|
||||||
|
used_percent = _int_value(client.get_many([oid]).get(oid))
|
||||||
|
response_time_ms = int((perf_counter() - started) * 1000)
|
||||||
|
if used_percent is None:
|
||||||
|
return SnmpCheckResult(status="down", response_time_ms=response_time_ms, message="Linux disk usage was not reported")
|
||||||
|
label = config.label or "Disk"
|
||||||
|
return SnmpCheckResult(
|
||||||
|
status="up",
|
||||||
|
response_time_ms=response_time_ms,
|
||||||
|
message=f"{label} is {used_percent}% used",
|
||||||
|
metrics=[SnmpMetricValue(name="used_percent", value=float(used_percent), unit="%")],
|
||||||
|
)
|
||||||
|
|
||||||
if config.item_type in {"memory_usage", "storage_usage"}:
|
if config.item_type in {"memory_usage", "storage_usage"}:
|
||||||
storage_index = _item_index(config.item_id, "storage")
|
storage_index = _item_index(config.item_id, "storage")
|
||||||
if storage_index is None:
|
if storage_index is None:
|
||||||
@@ -276,12 +335,12 @@ def _interface_index(item_id: str) -> int | None:
|
|||||||
return _item_index(item_id, "interface")
|
return _item_index(item_id, "interface")
|
||||||
|
|
||||||
|
|
||||||
def _item_index(item_id: str, expected_prefix: str) -> int | None:
|
def _item_index(item_id: str, expected_prefix: str, position: int = 1) -> int | None:
|
||||||
parts = item_id.split(".")
|
parts = item_id.split(".")
|
||||||
if len(parts) < 3 or parts[0] != expected_prefix:
|
if len(parts) <= position or parts[0] != expected_prefix:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return int(parts[1])
|
return int(parts[position])
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ from app.collectors.snmp import (
|
|||||||
HR_STORAGE_SIZE,
|
HR_STORAGE_SIZE,
|
||||||
HR_STORAGE_USED,
|
HR_STORAGE_USED,
|
||||||
SYS_UPTIME,
|
SYS_UPTIME,
|
||||||
|
UCD_DSK_PERCENT,
|
||||||
|
UCD_LA_LOAD_INT,
|
||||||
|
UCD_MEM_AVAIL_REAL,
|
||||||
|
UCD_MEM_TOTAL_REAL,
|
||||||
SnmpCheckConfig,
|
SnmpCheckConfig,
|
||||||
_with_index,
|
_with_index,
|
||||||
run_snmp_check,
|
run_snmp_check,
|
||||||
@@ -66,6 +70,72 @@ class SnmpCollectorTestCase(unittest.IsolatedAsyncioTestCase):
|
|||||||
("load_percent", 42.0, "%")
|
("load_percent", 42.0, "%")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
async def test_collects_linux_load_average(self) -> None:
|
||||||
|
oid = _with_index(UCD_LA_LOAD_INT, 1)
|
||||||
|
with patch("app.collectors.snmp.SnmpV2Client") as client_class:
|
||||||
|
client_class.return_value.get_many.return_value = {oid: 123}
|
||||||
|
|
||||||
|
result = await run_snmp_check(
|
||||||
|
SnmpCheckConfig(
|
||||||
|
host="192.0.2.10",
|
||||||
|
community="private-community",
|
||||||
|
item_id="linux.load.1",
|
||||||
|
item_type="linux_load_average",
|
||||||
|
label="Load average 1 minute",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "up"
|
||||||
|
assert result.message == "Load average 1 minute is 1.23"
|
||||||
|
assert [(metric.name, metric.value, metric.unit) for metric in result.metrics] == [
|
||||||
|
("load_average", 1.23, None)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_collects_linux_memory_usage(self) -> None:
|
||||||
|
with patch("app.collectors.snmp.SnmpV2Client") as client_class:
|
||||||
|
client_class.return_value.get_many.return_value = {
|
||||||
|
UCD_MEM_TOTAL_REAL: 1000,
|
||||||
|
UCD_MEM_AVAIL_REAL: 250,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await run_snmp_check(
|
||||||
|
SnmpCheckConfig(
|
||||||
|
host="192.0.2.10",
|
||||||
|
community="private-community",
|
||||||
|
item_id="linux.memory.real",
|
||||||
|
item_type="linux_memory_usage",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "up"
|
||||||
|
assert result.message == "Memory is 75.0% used"
|
||||||
|
assert [(metric.name, metric.value, metric.unit) for metric in result.metrics] == [
|
||||||
|
("used_percent", 75.0, "%"),
|
||||||
|
("used_bytes", 768000.0, "bytes"),
|
||||||
|
("total_bytes", 1024000.0, "bytes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_collects_linux_disk_usage(self) -> None:
|
||||||
|
oid = _with_index(UCD_DSK_PERCENT, 31)
|
||||||
|
with patch("app.collectors.snmp.SnmpV2Client") as client_class:
|
||||||
|
client_class.return_value.get_many.return_value = {oid: 81}
|
||||||
|
|
||||||
|
result = await run_snmp_check(
|
||||||
|
SnmpCheckConfig(
|
||||||
|
host="192.0.2.10",
|
||||||
|
community="private-community",
|
||||||
|
item_id="linux.disk.31",
|
||||||
|
item_type="linux_disk_usage",
|
||||||
|
label="Disk / usage",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.status == "up"
|
||||||
|
assert result.message == "Disk / usage is 81% used"
|
||||||
|
assert [(metric.name, metric.value, metric.unit) for metric in result.metrics] == [
|
||||||
|
("used_percent", 81.0, "%")
|
||||||
|
]
|
||||||
|
|
||||||
async def test_collects_storage_usage(self) -> None:
|
async def test_collects_storage_usage(self) -> None:
|
||||||
oids = [
|
oids = [
|
||||||
_with_index(HR_STORAGE_ALLOCATION_UNITS, 31),
|
_with_index(HR_STORAGE_ALLOCATION_UNITS, 31),
|
||||||
|
|||||||
Reference in New Issue
Block a user