diff --git a/backend/app/api/assets.py b/backend/app/api/assets.py index 6d6fe22..50ef87e 100644 --- a/backend/app/api/assets.py +++ b/backend/app/api/assets.py @@ -1,10 +1,12 @@ +from datetime import UTC, datetime + from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session from app.auth.dependencies import get_current_user, require_role from app.db.session import get_db -from app.models import Asset, User +from app.models import Asset, Incident, Monitor, User from app.schemas.core import AssetCreate, AssetRead, AssetUpdate router = APIRouter(prefix="/assets", tags=["assets"]) @@ -86,5 +88,26 @@ def delete_asset( asset = db.get(Asset, asset_id) if asset is None: raise HTTPException(status_code=404, detail="Asset not found") + + attached_monitors = db.scalars(select(Monitor).where(Monitor.asset_id == asset.id)).all() + attached_monitor_ids = [monitor.id for monitor in attached_monitors] + now = datetime.now(UTC) + + if attached_monitor_ids: + monitor_incidents = db.scalars(select(Incident).where(Incident.monitor_id.in_(attached_monitor_ids))).all() + for incident in monitor_incidents: + if incident.status == "open": + incident.status = "resolved" + incident.resolved_at = now + incident.details = {**(incident.details or {}), "recovery_message": "Asset was deleted"} + incident.monitor_id = None + + asset_incidents = db.scalars(select(Incident).where(Incident.asset_id == asset.id)).all() + for incident in asset_incidents: + incident.asset_id = None + + for monitor in attached_monitors: + db.delete(monitor) + db.delete(asset) db.commit() diff --git a/backend/app/api/discovery.py b/backend/app/api/discovery.py index 70258b2..be7179e 100644 --- a/backend/app/api/discovery.py +++ b/backend/app/api/discovery.py @@ -65,6 +65,9 @@ def _discovery_to_read(credential_profile_id: int, discovered: DiscoveredSnmpDev return SnmpDiscoveryRead( host=discovered.host, credential_profile_id=credential_profile_id, + profile_key=discovered.profile_key, + profile_name=discovered.profile_name, + capabilities=discovered.capabilities, device_name=discovered.device_name, description=discovered.description, uptime_seconds=discovered.uptime_seconds, @@ -74,15 +77,27 @@ def _discovery_to_read(credential_profile_id: int, discovered: DiscoveredSnmpDev def _monitorable_items(discovered: DiscoveredSnmpDevice) -> list[SnmpDiscoveryItemRead]: - items = [ - SnmpDiscoveryItemRead( - item_id="device.uptime", - item_type="device_uptime", - group="Device Health", - label="Device uptime", - unit="seconds", + items = [] + if discovered.uptime_seconds is not None: + items.append( + SnmpDiscoveryItemRead( + item_id="device.uptime", + item_type="device_uptime", + group="Device Health", + label="Device uptime", + unit="seconds", + ) ) - ] + items.extend( + SnmpDiscoveryItemRead( + item_id=item.item_id, + item_type=item.item_type, + group=item.group, + label=item.label, + unit=item.unit, + ) + for item in discovered.health_items + ) for interface in discovered.interfaces: group = f"Interface {interface.name}" item_prefix = f"interface.{interface.index}" diff --git a/backend/app/schemas/core.py b/backend/app/schemas/core.py index d712463..0006bb5 100644 --- a/backend/app/schemas/core.py +++ b/backend/app/schemas/core.py @@ -248,6 +248,9 @@ class SnmpMonitorsCreate(BaseModel): class SnmpDiscoveryRead(BaseModel): host: str credential_profile_id: int + profile_key: str + profile_name: str + capabilities: dict[str, bool] device_name: str | None description: str | None uptime_seconds: int | None diff --git a/backend/app/services/snmp.py b/backend/app/services/snmp.py index e0532a8..06673bd 100644 --- a/backend/app/services/snmp.py +++ b/backend/app/services/snmp.py @@ -1,4 +1,4 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field import random import socket from typing import Any @@ -26,6 +26,15 @@ class DiscoveredSnmpInterface: speed_bps: int | None +@dataclass(frozen=True) +class DiscoveredSnmpHealthItem: + item_id: str + item_type: str + group: str + label: str + unit: str | None = None + + @dataclass(frozen=True) class DiscoveredSnmpDevice: host: str @@ -33,6 +42,10 @@ class DiscoveredSnmpDevice: description: str | None uptime_seconds: int | None interfaces: list[DiscoveredSnmpInterface] + profile_key: str = "generic_snmp" + profile_name: str = "Generic SNMP" + capabilities: dict[str, bool] = field(default_factory=dict) + health_items: list[DiscoveredSnmpHealthItem] = field(default_factory=list) SYS_DESCR = (1, 3, 6, 1, 2, 1, 1, 1, 0) @@ -43,6 +56,24 @@ IF_SPEED = (1, 3, 6, 1, 2, 1, 2, 2, 1, 5) IF_ADMIN_STATUS = (1, 3, 6, 1, 2, 1, 2, 2, 1, 7) IF_OPER_STATUS = (1, 3, 6, 1, 2, 1, 2, 2, 1, 8) IF_NAME = (1, 3, 6, 1, 2, 1, 31, 1, 1, 1, 1) +HR_PROCESSOR_LOAD = (1, 3, 6, 1, 2, 1, 25, 3, 3, 1, 2) +HR_STORAGE_TYPE = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 2) +HR_STORAGE_DESCR = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 3) +HR_STORAGE_ALLOCATION_UNITS = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 4) +HR_STORAGE_SIZE = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 5) +HR_STORAGE_USED = (1, 3, 6, 1, 2, 1, 25, 2, 3, 1, 6) +ENT_PHYSICAL_DESCR = (1, 3, 6, 1, 2, 1, 47, 1, 1, 1, 1, 2) +ENT_PHYSICAL_NAME = (1, 3, 6, 1, 2, 1, 47, 1, 1, 1, 1, 7) +ENT_PHY_SENSOR_TYPE = (1, 3, 6, 1, 2, 1, 99, 1, 1, 1, 1) +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) + +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_FIXED_DISK = "1.3.6.1.2.1.25.2.1.4" +HR_STORAGE_REMOVABLE_DISK = "1.3.6.1.2.1.25.2.1.5" STATUS_LABELS = { 1: "up", @@ -54,20 +85,92 @@ STATUS_LABELS = { 7: "lower layer down", } +SENSOR_TYPE_LABELS = { + 3: ("AC voltage", "V"), + 4: ("DC voltage", "V"), + 5: ("Current", "A"), + 6: ("Power", "W"), + 7: ("Frequency", "Hz"), + 8: ("Temperature", "C"), + 9: ("Humidity", "%"), + 10: ("Fan speed", "rpm"), + 11: ("Airflow", "m3/min"), + 12: ("Sensor state", None), +} + + +@dataclass(frozen=True) +class SnmpProfile: + key: str + name: str + match_terms: tuple[str, ...] = () + + 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]: + 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("generic_snmp", "Generic SNMP"), +) + def discover_snmp_device(host: str, credential: SnmpCredential) -> DiscoveredSnmpDevice: client = SnmpV2Client(host, credential) system = client.get_many([SYS_NAME, SYS_DESCR, SYS_UPTIME]) + profile = _select_profile(system) interfaces = _discover_interfaces(client) + health_items = profile.discover_health_items(client) return DiscoveredSnmpDevice( host=host, device_name=_string_value(system.get(SYS_NAME)), description=_string_value(system.get(SYS_DESCR)), uptime_seconds=_timeticks_to_seconds(system.get(SYS_UPTIME)), interfaces=interfaces, + profile_key=profile.key, + profile_name=profile.name, + capabilities=_capabilities(system, interfaces, health_items), + health_items=health_items, ) +def _select_profile(system: dict[tuple[int, ...], Any]) -> SnmpProfile: + system_text = " ".join( + value.lower() + for value in [_string_value(system.get(SYS_NAME)), _string_value(system.get(SYS_DESCR))] + if value + ) + for profile in SNMP_PROFILES: + if profile.match_terms and profile.matches(system_text): + return profile + return SNMP_PROFILES[-1] + + +def _capabilities( + system: dict[tuple[int, ...], Any], + interfaces: list[DiscoveredSnmpInterface], + health_items: list[DiscoveredSnmpHealthItem], +) -> dict[str, bool]: + item_types = {item.item_type for item in health_items} + return { + "system": any(system.get(oid) is not None for oid in [SYS_NAME, SYS_DESCR, SYS_UPTIME]), + "interfaces": bool(interfaces), + "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, + "sensors": "sensor_value" in item_types, + "environmental": "sensor_value" in item_types, + } + + def _discover_interfaces(client: "SnmpV2Client") -> list[DiscoveredSnmpInterface]: names = client.walk(IF_NAME) descriptions = client.walk(IF_DESCR) @@ -106,6 +209,108 @@ def _discover_interfaces(client: "SnmpV2Client") -> list[DiscoveredSnmpInterface return interfaces +def _discover_host_resource_items(client: "SnmpV2Client") -> list[DiscoveredSnmpHealthItem]: + items: list[DiscoveredSnmpHealthItem] = [] + + processor_loads = _indexed_values(client.walk(HR_PROCESSOR_LOAD, max_items=256)) + processor_indexes = sorted(index for index, value in processor_loads.items() if _int_value(value) is not None) + 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="%", + ) + ) + + storage_types = _indexed_values(client.walk(HR_STORAGE_TYPE, max_items=256)) + descriptions = _indexed_values(client.walk(HR_STORAGE_DESCR, max_items=256)) + allocation_units = _indexed_values(client.walk(HR_STORAGE_ALLOCATION_UNITS, max_items=256)) + sizes = _indexed_values(client.walk(HR_STORAGE_SIZE, max_items=256)) + used = _indexed_values(client.walk(HR_STORAGE_USED, max_items=256)) + + for index in sorted(storage_types): + storage_type = _string_value(storage_types.get(index)) + allocation_unit = _int_value(allocation_units.get(index)) + size = _int_value(sizes.get(index)) + used_blocks = _int_value(used.get(index)) + if not storage_type or not allocation_unit or not size or used_blocks is None: + continue + description = _string_value(descriptions.get(index)) or f"Storage {index}" + if storage_type in {HR_STORAGE_RAM, HR_STORAGE_VIRTUAL_MEMORY}: + items.append( + DiscoveredSnmpHealthItem( + item_id=f"storage.{index}.memory", + item_type="memory_usage", + group="Device Health", + label="Memory used", + unit="%", + ) + ) + elif storage_type in {HR_STORAGE_FIXED_DISK, HR_STORAGE_REMOVABLE_DISK}: + items.append( + DiscoveredSnmpHealthItem( + item_id=f"storage.{index}.usage", + item_type="storage_usage", + group="Storage", + label=_storage_usage_label(description), + 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)) + sensor_names = _indexed_values(client.walk(ENT_PHYSICAL_NAME, max_items=256)) + sensor_descriptions = _indexed_values(client.walk(ENT_PHYSICAL_DESCR, max_items=256)) + + items: list[DiscoveredSnmpHealthItem] = [] + for index in sorted(sensor_types): + sensor_type = _int_value(sensor_types.get(index)) + 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] + 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( + DiscoveredSnmpHealthItem( + item_id=f"sensor.{index}.value", + item_type="sensor_value", + group="Environmental", + label=label, + unit=unit, + ) + ) + return items + + +def _storage_usage_label(description: str) -> str: + normalized = description.strip() + if normalized in {"/", "/boot", "/home", "/var"}: + return f"Disk {normalized} usage" + if "disk" not in normalized.lower() and normalized.startswith("/"): + return f"Disk {normalized} usage" + return f"{normalized} usage" + + +def _deduplicate_items(items: list[DiscoveredSnmpHealthItem]) -> list[DiscoveredSnmpHealthItem]: + seen: set[tuple[str, str]] = set() + deduplicated: list[DiscoveredSnmpHealthItem] = [] + for item in items: + key = (item.item_type, item.label) + if key in seen: + continue + seen.add(key) + deduplicated.append(item) + return deduplicated + + def _indexed_values(values: dict[tuple[int, ...], Any]) -> dict[int, Any]: indexed: dict[int, Any] = {} for oid, value in values.items(): @@ -317,7 +522,9 @@ def _decode_oid(value: bytes) -> tuple[int, ...]: def _decode_value(tag: int, value: bytes) -> Any: - if tag in {0x02, 0x41, 0x42, 0x43, 0x46}: + if tag == 0x02: + return _decode_integer(value) + if tag in {0x41, 0x42, 0x43, 0x46}: return int.from_bytes(value, "big") if tag == 0x04: return value.decode("utf-8", errors="replace") diff --git a/backend/tests/test_discovery.py b/backend/tests/test_discovery.py index 5e3e041..9c16cc3 100644 --- a/backend/tests/test_discovery.py +++ b/backend/tests/test_discovery.py @@ -2,7 +2,27 @@ from fastapi.testclient import TestClient from app.core.secrets import encrypt_secret from app.models import Credential -from app.services.snmp import DiscoveredSnmpDevice, DiscoveredSnmpInterface, SnmpCredential, SnmpDiscoveryError +from app.services.snmp import ( + ENT_PHYSICAL_NAME, + ENT_PHY_SENSOR_TYPE, + ENT_PHY_SENSOR_VALUE, + HR_PROCESSOR_LOAD, + HR_STORAGE_ALLOCATION_UNITS, + HR_STORAGE_DESCR, + HR_STORAGE_FIXED_DISK, + HR_STORAGE_RAM, + HR_STORAGE_SIZE, + HR_STORAGE_TYPE, + HR_STORAGE_USED, + SYS_DESCR, + SYS_NAME, + SYS_UPTIME, + DiscoveredSnmpDevice, + DiscoveredSnmpInterface, + SnmpCredential, + SnmpDiscoveryError, + discover_snmp_device, +) def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestClient, db_session, monkeypatch) -> None: @@ -49,6 +69,9 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl body = response.json() assert body["host"] == "192.0.2.10" assert body["credential_profile_id"] == profile.id + assert body["profile_key"] == "generic_snmp" + assert body["profile_name"] == "Generic SNMP" + assert body["capabilities"] == {} assert body["device_name"] == "core-sw-1" assert body["description"] == "Core switch" assert body["uptime_seconds"] == 12345 @@ -96,6 +119,66 @@ def test_snmp_discovery_uses_profile_and_returns_friendly_results(client: TestCl assert "1.3.6" not in response.text +def test_snmp_profile_mapping_discovers_standard_health_items(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: "edge-router", + SYS_DESCR: "Linux edge-router net-snmp", + SYS_UPTIME: 10_000, + } + + def walk(self, base_oid, max_items=128): + values = { + 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_DESCR: { + (*HR_STORAGE_DESCR, 1): "Physical memory", + (*HR_STORAGE_DESCR, 31): "/", + }, + HR_STORAGE_ALLOCATION_UNITS: { + (*HR_STORAGE_ALLOCATION_UNITS, 1): 1024, + (*HR_STORAGE_ALLOCATION_UNITS, 31): 4096, + }, + HR_STORAGE_SIZE: { + (*HR_STORAGE_SIZE, 1): 2048, + (*HR_STORAGE_SIZE, 31): 4096, + }, + HR_STORAGE_USED: { + (*HR_STORAGE_USED, 1): 1024, + (*HR_STORAGE_USED, 31): 1024, + }, + ENT_PHY_SENSOR_TYPE: {(*ENT_PHY_SENSOR_TYPE, 10): 8}, + ENT_PHY_SENSOR_VALUE: {(*ENT_PHY_SENSOR_VALUE, 10): 310}, + ENT_PHYSICAL_NAME: {(*ENT_PHYSICAL_NAME, 10): "Inlet"}, + } + return values.get(base_oid, {}) + + monkeypatch.setattr("app.services.snmp.SnmpV2Client", FakeClient) + + 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.capabilities["cpu"] is True + 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"), + ] + + 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}) diff --git a/backend/tests/test_monitors.py b/backend/tests/test_monitors.py index fe6c720..02cd9fe 100644 --- a/backend/tests/test_monitors.py +++ b/backend/tests/test_monitors.py @@ -3,7 +3,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from app.core.secrets import encrypt_secret -from app.models import AlertRule, Asset, Credential, Monitor +from app.models import AlertRule, Asset, Credential, Incident, Monitor def test_create_website_monitor_creates_asset_and_alert_rule(client: TestClient, db_session: Session) -> None: @@ -211,3 +211,41 @@ def test_create_snmp_monitors_rejects_missing_profile(client: TestClient, db_ses ) assert response.status_code == 404 + + +def test_delete_asset_deletes_attached_monitors_and_resolves_incidents(client: TestClient, db_session: Session) -> None: + asset = Asset(name="Router", asset_type="network_device", address="192.0.2.1", status="down", extra={}) + monitor = Monitor( + asset=asset, + name="Router ping", + monitor_type="ping", + target="192.0.2.1", + config={}, + interval_seconds=60, + status="down", + ) + db_session.add_all([asset, monitor]) + db_session.flush() + incident = Incident( + asset_id=asset.id, + monitor_id=monitor.id, + alert_rule_id=None, + title="Router ping is failing", + severity="warning", + status="open", + details={"last_message": "Ping failed"}, + ) + db_session.add(incident) + db_session.commit() + + response = client.delete(f"/assets/{asset.id}") + + assert response.status_code == 204 + assert db_session.get(Asset, asset.id) is None + assert db_session.get(Monitor, monitor.id) is None + db_session.refresh(incident) + assert incident.status == "resolved" + assert incident.resolved_at is not None + assert incident.asset_id is None + assert incident.monitor_id is None + assert incident.details["recovery_message"] == "Asset was deleted" diff --git a/docs/agent-handoff.md b/docs/agent-handoff.md index dc8bad5..2928552 100644 --- a/docs/agent-handoff.md +++ b/docs/agent-handoff.md @@ -1,6 +1,6 @@ # Agent Handoff Notes -Last updated: 2026-05-24 +Last updated: 2026-05-26 ## Current Identity @@ -38,16 +38,19 @@ OrbitalWard is a secure monitoring appliance focused on the v0.1 vertical slice: - Guided SNMP device discovery with friendly device, interface, and monitorable item results. - Asset setup supports creating, selecting, and deleting assets, plus attaching ping, TCP, website, and SNMP monitors without creating alert rules automatically. - Worker collects configured SNMP uptime, interface status, traffic counter, error, and discard monitors. +- SNMP profile mapping exposes friendly capability flags plus CPU, memory, storage, and standard sensor monitorable items when supported. +- Worker collects configured SNMP CPU load, memory usage, storage usage, and standard sensor value/status monitors. ## Verification State Recent Docker checks: -- `docker compose -f docker-compose.dev.yml exec -T backend python -m pytest tests` -- `docker compose -f docker-compose.dev.yml exec -T frontend npm run typecheck` -- `docker compose -f docker-compose.dev.yml exec -T frontend npm run build` -- `docker compose -f docker-compose.dev.yml exec -T worker python -m unittest discover -s tests` -- `docker compose -f docker-compose.dev.yml exec -T worker python -m compileall app` +- `docker compose -f docker-compose.dev.yml run --rm backend sh -c "pip install -e '.[test]' && python -m pytest tests/test_discovery.py tests/test_monitors.py"` +- `docker compose -f docker-compose.dev.yml run --rm frontend npm run typecheck` +- `docker compose -f docker-compose.dev.yml run --rm frontend npm run build` +- `docker compose -f docker-compose.dev.yml run --rm worker python -m unittest discover -s tests` +- `docker compose -f docker-compose.dev.yml run --rm worker python -m compileall app` +- `docker compose -f docker-compose.dev.yml run --rm backend sh -c "pip install -e '.[test]' >/dev/null && python -m compileall app"` Earlier rename and monitor work also verified: @@ -84,7 +87,7 @@ Issue source docs: - `docs/progress.md` - `docs/roadmap.md` -Current completed items include TLS expiry monitor support, HTTP/website checks, ping and TCP port checks, basic alert evaluation, alert rule editing UI, incident actions, webhook notification channels, SNMPv2c credential profiles, the SNMP device discovery API, guided SNMP discovery UI, asset-based monitor setup, and initial SNMP collection for uptime plus interface counters/status. The next recommended implementation work is SNMP profile mapping and expanded CPU, memory, storage, and sensor collection. +Current completed items include TLS expiry monitor support, HTTP/website checks, ping and TCP port checks, basic alert evaluation, alert rule editing UI, incident actions, webhook notification channels, SNMPv2c credential profiles, the SNMP device discovery API, guided SNMP discovery UI, asset-based monitor setup, initial SNMP collection for uptime plus interface counters/status, and SNMP profile mapping for standard CPU, memory, storage, and sensor health items. The next recommended implementation work is notification routing/policies or email/SMTP notifications. ## Guardrails diff --git a/docs/gitea-issues.md b/docs/gitea-issues.md index f7506dd..908738e 100644 --- a/docs/gitea-issues.md +++ b/docs/gitea-issues.md @@ -53,6 +53,12 @@ 39. Create monitors from SNMP discovery selections 40. Add SNMP interface status and traffic collection 41. Add SNMP profile mapping for friendly metric names +42. Add vendor-private SNMP profile mappings from real device examples +43. Fix asset deletion cleanup for attached monitors +44. Show and graph SNMP interface throughput +45. Build asset detail UI for monitors, metrics, and context +46. Refine metric-only monitor status semantics +47. Rename product from OrbitalWard to OrbitWard ## Current Implementation Snapshot diff --git a/docs/progress.md b/docs/progress.md index d12bd85..32eebef 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1,6 +1,6 @@ # OrbitalWard Progress -Last updated: 2026-05-24 +Last updated: 2026-05-26 ## Current State @@ -99,6 +99,15 @@ Implemented initial SNMP collection slice: - SNMP interface traffic checks collect inbound/outbound octet counters and store metrics. - SNMP interface error checks collect inbound/outbound errors and discards and store metrics. +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. +- 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. +- Worker collection supports CPU load, memory usage, storage usage, and sensor value/status monitors created from discovery. +- Raw SNMP implementation details remain internal to profiles and are not returned in the normal discovery UI/API response. + ## Known Gaps - General credential vault workflows beyond SNMP profiles are not complete. @@ -106,7 +115,7 @@ Implemented initial SNMP collection slice: - User management UI is not implemented. - Role management is basic and needs full admin flows. - Richer alert condition editing is not implemented yet. -- SNMP collection is implemented for uptime, interface status, traffic counters, errors, and discards, but CPU, memory, storage, sensor, and vendor-specific profile mappings are not implemented yet. +- SNMP collection now covers uptime, interface status, traffic counters, errors, discards, CPU load, memory usage, storage usage, and standard sensor value/status data. Vendor-private profile mappings beyond common standard MIBs are not implemented yet. - Notification routing/policies are not implemented; all enabled webhook channels receive incident notifications. - Email/SMTP notifications are not implemented yet. - Graphing exists only as placeholders; metric visualization is not implemented. @@ -116,16 +125,15 @@ Implemented initial SNMP collection slice: ## Recommended Next Work -1. Add SNMP profile mapping for friendly metric names across common vendors. -2. Add SNMP CPU, memory, storage, and sensor collection where supported by profiles. -3. Add notification policy/routing controls. -4. Add email/SMTP notification channel. -5. Add audit event writes for auth, monitor, credential, notification, and incident actions. -6. Build general credential vault workflows with masked secret handling. -7. Add user administration UI. -8. Add graphs for website response time and monitor status history. -9. Add richer alert condition editing. -10. Add frontend coverage for monitor, alert, and notification workflows. +1. Add notification policy/routing controls. +2. Add email/SMTP notification channel. +3. Add audit event writes for auth, monitor, credential, notification, and incident actions. +4. Build general credential vault workflows with masked secret handling. +5. Add user administration UI. +6. Add graphs for website response time and monitor status history. +7. Add richer alert condition editing. +8. Add vendor-private SNMP profile mappings for specific common devices after real device examples are available. +9. Add frontend coverage for monitor, alert, and notification workflows. ## Operational Notes diff --git a/frontend/src/pages/AssetsPage.tsx b/frontend/src/pages/AssetsPage.tsx index 7b041e5..f041fb0 100644 --- a/frontend/src/pages/AssetsPage.tsx +++ b/frontend/src/pages/AssetsPage.tsx @@ -397,8 +397,10 @@ export function AssetsPage({ token, assets, monitors, onChanged }: AssetsPagePro