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