diff --git a/backend/app/api/discovery.py b/backend/app/api/discovery.py index be7179e..a4088d9 100644 --- a/backend/app/api/discovery.py +++ b/backend/app/api/discovery.py @@ -43,7 +43,7 @@ def discover_snmp( retries=int(extra.get("retries") or 1), ) try: - discovered = discover_snmp_device(payload.host, credential) + discovered = discover_snmp_device(payload.host, credential, asset_type=payload.asset_type) except SnmpDiscoveryError as 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( index=interface.index, name=interface.name, + label=interface.label, description=interface.description, admin_status=interface.admin_status, oper_status=interface.oper_status, @@ -99,7 +100,7 @@ def _monitorable_items(discovered: DiscoveredSnmpDevice) -> list[SnmpDiscoveryIt for item in discovered.health_items ) for interface in discovered.interfaces: - group = f"Interface {interface.name}" + group = f"Interface {interface.label}" item_prefix = f"interface.{interface.index}" items.extend( [ @@ -107,20 +108,20 @@ def _monitorable_items(discovered: DiscoveredSnmpDevice) -> list[SnmpDiscoveryIt item_id=f"{item_prefix}.status", item_type="interface_status", group=group, - label=f"{interface.name} status", + label=f"{interface.label} status", ), SnmpDiscoveryItemRead( item_id=f"{item_prefix}.traffic", item_type="interface_traffic", group=group, - label=f"{interface.name} traffic", + label=f"{interface.label} traffic", unit="bps", ), SnmpDiscoveryItemRead( item_id=f"{item_prefix}.errors", item_type="interface_errors", group=group, - label=f"{interface.name} errors and discards", + label=f"{interface.label} errors and discards", unit="count", ), ] diff --git a/backend/app/schemas/core.py b/backend/app/schemas/core.py index 0006bb5..414fcf3 100644 --- a/backend/app/schemas/core.py +++ b/backend/app/schemas/core.py @@ -218,11 +218,13 @@ class SnmpCredentialProfileRead(BaseModel): class SnmpDiscoveryRequest(BaseModel): host: str = Field(min_length=1, max_length=255) credential_profile_id: int + asset_type: str | None = Field(default=None, max_length=64) class SnmpDiscoveredInterfaceRead(BaseModel): index: int name: str + label: str description: str | None admin_status: str | None oper_status: str | None diff --git a/backend/app/services/snmp.py b/backend/app/services/snmp.py index 06673bd..c11f61a 100644 --- a/backend/app/services/snmp.py +++ b/backend/app/services/snmp.py @@ -1,5 +1,6 @@ from dataclasses import dataclass, field import random +import re import socket from typing import Any @@ -20,6 +21,7 @@ class SnmpCredential: class DiscoveredSnmpInterface: index: int name: str + label: str description: str | None admin_status: str | None oper_status: str | None @@ -48,6 +50,13 @@ class DiscoveredSnmpDevice: 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_UPTIME = (1, 3, 6, 1, 2, 1, 1, 3, 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_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) +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_VIRTUAL_MEMORY = "1.3.6.1.2.1.25.2.1.3" @@ -104,27 +118,36 @@ class SnmpProfile: key: str name: str match_terms: tuple[str, ...] = () + health_source: str = "standard" + include_interfaces: bool = True def matches(self, system_text: str) -> bool: return any(term in system_text for term in self.match_terms) 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)] SNMP_PROFILES = ( SnmpProfile("cisco_ios", "Cisco IOS SNMP", ("cisco", "ios")), 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"), ) -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) system = client.get_many([SYS_NAME, SYS_DESCR, SYS_UPTIME]) - profile = _select_profile(system) - interfaces = _discover_interfaces(client) + profile = _select_profile(system, asset_type) + interfaces = _discover_interfaces(client) if profile.include_interfaces else [] health_items = profile.discover_health_items(client) return DiscoveredSnmpDevice( 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( value.lower() for value in [_string_value(system.get(SYS_NAME)), _string_value(system.get(SYS_DESCR))] 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: if profile.match_terms and profile.matches(system_text): return profile 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( system: dict[tuple[int, ...], Any], interfaces: list[DiscoveredSnmpInterface], @@ -163,9 +204,9 @@ def _capabilities( "interface_status": bool(interfaces), "interface_traffic": bool(interfaces), "interface_errors": bool(interfaces), - "cpu": "cpu_load" in item_types, - "memory": "memory_usage" in item_types, - "storage": "storage_usage" in item_types, + "cpu": bool({"cpu_load", "linux_load_average"} & item_types), + "memory": bool({"memory_usage", "linux_memory_usage"} & item_types), + "storage": bool({"storage_usage", "linux_disk_usage"} & item_types), "sensors": "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] = [] 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( DiscoveredSnmpInterface( index=index, - name=name, - description=_string_value(description_by_index.get(index)), + name=identity.name, + label=identity.label, + description=identity.description, admin_status=_status_label(admin_by_index.get(index)), oper_status=_status_label(oper_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 +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]: 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}: + if not _is_monitorable_storage_path(description): + continue items.append( DiscoveredSnmpHealthItem( item_id=f"storage.{index}.usage", @@ -264,6 +388,52 @@ def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmp 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]: 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)) @@ -299,6 +469,20 @@ def _storage_usage_label(description: str) -> str: 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]: seen: set[tuple[str, str]] = set() deduplicated: list[DiscoveredSnmpHealthItem] = [] diff --git a/backend/tests/test_discovery.py b/backend/tests/test_discovery.py index 9c16cc3..42b13ff 100644 --- a/backend/tests/test_discovery.py +++ b/backend/tests/test_discovery.py @@ -17,8 +17,15 @@ from app.services.snmp import ( SYS_DESCR, SYS_NAME, SYS_UPTIME, + UCD_DSK_PATH, + UCD_DSK_PERCENT, + UCD_LA_LOAD_INT, + UCD_MEM_AVAIL_REAL, + UCD_MEM_TOTAL_REAL, DiscoveredSnmpDevice, DiscoveredSnmpInterface, + IF_DESCR, + IF_NAME, SnmpCredential, SnmpDiscoveryError, 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.commit() - calls: list[tuple[str, SnmpCredential]] = [] + calls: list[tuple[str, SnmpCredential, str | None]] = [] - def fake_discover(host: str, credential: SnmpCredential) -> DiscoveredSnmpDevice: - calls.append((host, credential)) + def fake_discover(host: str, credential: SnmpCredential, asset_type: str | None = None) -> DiscoveredSnmpDevice: + calls.append((host, credential, asset_type)) return DiscoveredSnmpDevice( host=host, device_name="core-sw-1", @@ -47,6 +54,7 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl DiscoveredSnmpInterface( index=1, name="Gi1/0/1", + label="GigabitEthernet 1/0/1", description="Uplink", admin_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", SnmpCredential(community="private-community", port=1161, timeout_seconds=4, retries=2), + None, ) ] body = response.json() @@ -79,6 +88,7 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl { "index": 1, "name": "Gi1/0/1", + "label": "GigabitEthernet 1/0/1", "description": "Uplink", "admin_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_type": "interface_status", - "group": "Interface Gi1/0/1", - "label": "Gi1/0/1 status", + "group": "Interface GigabitEthernet 1/0/1", + "label": "GigabitEthernet 1/0/1 status", "unit": None, }, { "item_id": "interface.1.traffic", "item_type": "interface_traffic", - "group": "Interface Gi1/0/1", - "label": "Gi1/0/1 traffic", + "group": "Interface GigabitEthernet 1/0/1", + "label": "GigabitEthernet 1/0/1 traffic", "unit": "bps", }, { "item_id": "interface.1.errors", "item_type": "interface_errors", - "group": "Interface Gi1/0/1", - "label": "Gi1/0/1 errors and discards", + "group": "Interface GigabitEthernet 1/0/1", + "label": "GigabitEthernet 1/0/1 errors and discards", "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 +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: class FakeClient: 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): return { SYS_NAME: "edge-router", - SYS_DESCR: "Linux edge-router net-snmp", + SYS_DESCR: "Generic SNMP appliance", 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")) - assert discovered.profile_key == "net_snmp" - assert discovered.profile_name == "Net-SNMP Host Resources" + assert discovered.profile_key == "generic_snmp" + assert discovered.profile_name == "Generic SNMP" assert discovered.capabilities["cpu"] is True assert discovered.capabilities["memory"] 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: 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.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") monkeypatch.setattr("app.api.discovery.discover_snmp_device", fake_discover) diff --git a/docs/progress.md b/docs/progress.md index 32eebef..b4b24cf 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -101,7 +101,7 @@ Implemented initial SNMP collection 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. - 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. diff --git a/frontend/src/pages/AssetsPage.tsx b/frontend/src/pages/AssetsPage.tsx index f041fb0..906e461 100644 --- a/frontend/src/pages/AssetsPage.tsx +++ b/frontend/src/pages/AssetsPage.tsx @@ -91,6 +91,7 @@ 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, }); setDiscoveryResult(discovered); } catch (err) { diff --git a/frontend/src/pages/DiscoveryPage.tsx b/frontend/src/pages/DiscoveryPage.tsx index 0c47dbf..6f5fee5 100644 --- a/frontend/src/pages/DiscoveryPage.tsx +++ b/frontend/src/pages/DiscoveryPage.tsx @@ -3,7 +3,7 @@ import { CheckSquare, Network, RefreshCw, Router, Search, Square } from "lucide- import { api } from "../api/client"; import { Button } from "../components/Button"; -import type { SnmpCredentialProfile, SnmpDiscoveryItem, SnmpDiscoveryResult } from "../types/api"; +import type { SnmpCredentialProfile, SnmpDiscoveredInterface, SnmpDiscoveryItem, SnmpDiscoveryResult } from "../types/api"; interface DiscoveryPageProps { token: string; @@ -169,8 +169,8 @@ export function DiscoveryPage({ token }: DiscoveryPageProps) { result.interfaces.map((item) => (
-
{item.name}
-
{item.description || "No description"}
+
{item.label}
+
{interfaceContext(item)}
@@ -268,6 +268,11 @@ function friendlyItemType(value: string) { 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) { const labels: Record = { interfaces: "interfaces", diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 05bdb55..1e047c3 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -151,11 +151,13 @@ export interface SnmpCredentialProfileUpdate { export interface SnmpDiscoveryRequest { host: string; credential_profile_id: number; + asset_type?: string | null; } export interface SnmpDiscoveredInterface { index: number; name: string; + label: string; description?: string | null; admin_status?: string | null; oper_status?: string | null; diff --git a/worker/app/collectors/snmp.py b/worker/app/collectors/snmp.py index 06986b7..22e3326 100644 --- a/worker/app/collectors/snmp.py +++ b/worker/app/collectors/snmp.py @@ -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_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) +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 = { 1: "up", @@ -128,6 +132,61 @@ def _run_snmp_check_sync(config: SnmpCheckConfig) -> SnmpCheckResult: 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"}: storage_index = _item_index(config.item_id, "storage") if storage_index is None: @@ -276,12 +335,12 @@ def _interface_index(item_id: str) -> int | None: 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(".") - if len(parts) < 3 or parts[0] != expected_prefix: + if len(parts) <= position or parts[0] != expected_prefix: return None try: - return int(parts[1]) + return int(parts[position]) except ValueError: return None diff --git a/worker/tests/test_snmp_collector.py b/worker/tests/test_snmp_collector.py index 96d5185..f1087b3 100644 --- a/worker/tests/test_snmp_collector.py +++ b/worker/tests/test_snmp_collector.py @@ -20,6 +20,10 @@ from app.collectors.snmp import ( HR_STORAGE_SIZE, HR_STORAGE_USED, SYS_UPTIME, + UCD_DSK_PERCENT, + UCD_LA_LOAD_INT, + UCD_MEM_AVAIL_REAL, + UCD_MEM_TOTAL_REAL, SnmpCheckConfig, _with_index, run_snmp_check, @@ -66,6 +70,72 @@ class SnmpCollectorTestCase(unittest.IsolatedAsyncioTestCase): ("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: oids = [ _with_index(HR_STORAGE_ALLOCATION_UNITS, 31),