unifi-network-maps 1.4.15__py3-none-any.whl → 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unifi_network_maps/__init__.py +1 -1
- unifi_network_maps/adapters/unifi.py +80 -96
- unifi_network_maps/assets/icons/modern/ap.svg +9 -0
- unifi_network_maps/assets/icons/modern/camera.svg +9 -0
- unifi_network_maps/assets/icons/modern/client.svg +9 -0
- unifi_network_maps/assets/icons/modern/game_console.svg +10 -0
- unifi_network_maps/assets/icons/modern/gateway.svg +17 -0
- unifi_network_maps/assets/icons/modern/iot.svg +9 -0
- unifi_network_maps/assets/icons/modern/nas.svg +9 -0
- unifi_network_maps/assets/icons/modern/other.svg +10 -0
- unifi_network_maps/assets/icons/modern/phone.svg +10 -0
- unifi_network_maps/assets/icons/modern/printer.svg +9 -0
- unifi_network_maps/assets/icons/modern/speaker.svg +10 -0
- unifi_network_maps/assets/icons/modern/switch.svg +10 -0
- unifi_network_maps/assets/icons/modern/tv.svg +10 -0
- unifi_network_maps/assets/icons/modern-flat/ap.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/camera.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/client.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/game_console.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/gateway.svg +13 -0
- unifi_network_maps/assets/icons/modern-flat/iot.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/nas.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/other.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/phone.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/printer.svg +5 -0
- unifi_network_maps/assets/icons/modern-flat/speaker.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/switch.svg +6 -0
- unifi_network_maps/assets/icons/modern-flat/tv.svg +6 -0
- unifi_network_maps/assets/themes/dark.yaml +53 -10
- unifi_network_maps/assets/themes/default.yaml +34 -0
- unifi_network_maps/assets/themes/minimal-dark.yaml +98 -0
- unifi_network_maps/assets/themes/minimal.yaml +92 -0
- unifi_network_maps/assets/themes/unifi-dark.yaml +97 -0
- unifi_network_maps/assets/themes/unifi.yaml +92 -0
- unifi_network_maps/cli/args.py +54 -0
- unifi_network_maps/cli/main.py +18 -7
- unifi_network_maps/cli/render.py +79 -27
- unifi_network_maps/cli/runtime.py +29 -15
- unifi_network_maps/io/debug.py +2 -1
- unifi_network_maps/io/export.py +19 -13
- unifi_network_maps/io/mock_data.py +5 -3
- unifi_network_maps/io/paths.py +5 -3
- unifi_network_maps/model/classify.py +199 -0
- unifi_network_maps/model/clients.py +271 -0
- unifi_network_maps/model/connection.py +37 -0
- unifi_network_maps/model/diff.py +544 -0
- unifi_network_maps/model/edges.py +558 -0
- unifi_network_maps/model/helpers.py +64 -0
- unifi_network_maps/model/lldp.py +20 -25
- unifi_network_maps/model/mock.py +110 -23
- unifi_network_maps/model/snapshot.py +294 -0
- unifi_network_maps/model/topology.py +143 -951
- unifi_network_maps/model/topology_coerce.py +339 -0
- unifi_network_maps/model/vlans.py +32 -46
- unifi_network_maps/model/wan.py +132 -0
- unifi_network_maps/render/device_ports_md.py +39 -97
- unifi_network_maps/render/device_summary.py +53 -0
- unifi_network_maps/render/lldp_md.py +29 -219
- unifi_network_maps/render/markdown_tables.py +8 -0
- unifi_network_maps/render/mermaid.py +11 -2
- unifi_network_maps/render/mkdocs.py +2 -1
- unifi_network_maps/render/svg.py +566 -908
- unifi_network_maps/render/svg_icons.py +231 -0
- unifi_network_maps/render/svg_isometric.py +1196 -0
- unifi_network_maps/render/svg_labels.py +184 -0
- unifi_network_maps/render/svg_theme.py +166 -32
- unifi_network_maps/render/theme.py +86 -1
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/METADATA +107 -31
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/RECORD +73 -31
- unifi_network_maps/assets/icons/isometric/printer.svg +0 -122
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/WHEEL +0 -0
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/entry_points.txt +0 -0
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/licenses/LICENSE +0 -0
- {unifi_network_maps-1.4.15.dist-info → unifi_network_maps-1.5.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
"""Topology comparison and change detection.
|
|
2
|
+
|
|
3
|
+
Provides functions to compare two topology snapshots and generate structured
|
|
4
|
+
change events for integration with monitoring systems like Home Assistant.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .helpers import normalize_mac
|
|
15
|
+
from .snapshot import device_to_dict, edge_to_dict
|
|
16
|
+
from .topology import Device, Edge
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TopologyChangeEvent:
|
|
21
|
+
"""A single change detected between two topology snapshots."""
|
|
22
|
+
|
|
23
|
+
event_type: str
|
|
24
|
+
"""One of: node_added, node_removed, node_changed, edge_added, edge_removed, edge_changed"""
|
|
25
|
+
|
|
26
|
+
entity_type: str
|
|
27
|
+
"""'device' or 'client'"""
|
|
28
|
+
|
|
29
|
+
identifier: str
|
|
30
|
+
"""MAC address - stable identifier across renames"""
|
|
31
|
+
|
|
32
|
+
name: str | None
|
|
33
|
+
"""Human-readable name (from newer topology if changed)"""
|
|
34
|
+
|
|
35
|
+
description: str
|
|
36
|
+
"""Human-readable message for notifications"""
|
|
37
|
+
|
|
38
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
39
|
+
"""Event-specific payload"""
|
|
40
|
+
|
|
41
|
+
timestamp: str | None = None
|
|
42
|
+
"""ISO timestamp if available"""
|
|
43
|
+
|
|
44
|
+
def to_dict(self) -> dict[str, Any]:
|
|
45
|
+
"""Convert to JSON-serializable dictionary."""
|
|
46
|
+
return {
|
|
47
|
+
"event_type": self.event_type,
|
|
48
|
+
"entity_type": self.entity_type,
|
|
49
|
+
"identifier": self.identifier,
|
|
50
|
+
"name": self.name,
|
|
51
|
+
"description": self.description,
|
|
52
|
+
"details": self.details,
|
|
53
|
+
"timestamp": self.timestamp,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class TopologyDiff:
|
|
59
|
+
"""Result of comparing two topology snapshots."""
|
|
60
|
+
|
|
61
|
+
events: list[TopologyChangeEvent] = field(default_factory=list)
|
|
62
|
+
"""All detected changes"""
|
|
63
|
+
|
|
64
|
+
old_timestamp: str | None = None
|
|
65
|
+
"""Timestamp from old topology metadata"""
|
|
66
|
+
|
|
67
|
+
new_timestamp: str | None = None
|
|
68
|
+
"""Timestamp from new topology metadata"""
|
|
69
|
+
|
|
70
|
+
summary: str = ""
|
|
71
|
+
"""Human-readable summary like '3 devices added, 1 removed'"""
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, Any]:
|
|
74
|
+
"""Convert to JSON-serializable dictionary."""
|
|
75
|
+
return {
|
|
76
|
+
"events": [e.to_dict() for e in self.events],
|
|
77
|
+
"old_timestamp": self.old_timestamp,
|
|
78
|
+
"new_timestamp": self.new_timestamp,
|
|
79
|
+
"summary": self.summary,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
def to_json(self) -> str:
|
|
83
|
+
"""Serialize to JSON string."""
|
|
84
|
+
return json.dumps(self.to_dict(), indent=2)
|
|
85
|
+
|
|
86
|
+
def filter(
|
|
87
|
+
self,
|
|
88
|
+
event_types: set[str] | None = None,
|
|
89
|
+
entity_types: set[str] | None = None,
|
|
90
|
+
) -> TopologyDiff:
|
|
91
|
+
"""Return filtered diff with only matching events."""
|
|
92
|
+
filtered = [
|
|
93
|
+
e
|
|
94
|
+
for e in self.events
|
|
95
|
+
if (event_types is None or e.event_type in event_types)
|
|
96
|
+
and (entity_types is None or e.entity_type in entity_types)
|
|
97
|
+
]
|
|
98
|
+
return TopologyDiff(
|
|
99
|
+
events=filtered,
|
|
100
|
+
old_timestamp=self.old_timestamp,
|
|
101
|
+
new_timestamp=self.new_timestamp,
|
|
102
|
+
summary=_build_summary(filtered),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _pluralize(count: int, singular: str) -> str:
|
|
107
|
+
"""Return 'N item' or 'N items' based on count."""
|
|
108
|
+
return f"{count} {singular}{'s' if count != 1 else ''}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _add_count_part(parts: list[str], count: int, noun: str, verb: str) -> None:
|
|
112
|
+
"""Add a count part to the summary list if count > 0."""
|
|
113
|
+
if count:
|
|
114
|
+
parts.append(f"{_pluralize(count, noun)} {verb}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _build_summary(events: list[TopologyChangeEvent]) -> str:
|
|
118
|
+
"""Build a human-readable summary of changes."""
|
|
119
|
+
counts: dict[str, int] = {}
|
|
120
|
+
for event in events:
|
|
121
|
+
key = f"{event.entity_type}_{event.event_type}"
|
|
122
|
+
counts[key] = counts.get(key, 0) + 1
|
|
123
|
+
|
|
124
|
+
parts: list[str] = []
|
|
125
|
+
|
|
126
|
+
# Devices
|
|
127
|
+
_add_count_part(parts, counts.get("device_node_added", 0), "device", "added")
|
|
128
|
+
_add_count_part(parts, counts.get("device_node_removed", 0), "device", "removed")
|
|
129
|
+
_add_count_part(parts, counts.get("device_node_changed", 0), "device", "changed")
|
|
130
|
+
|
|
131
|
+
# Clients
|
|
132
|
+
_add_count_part(parts, counts.get("client_node_added", 0), "client", "added")
|
|
133
|
+
_add_count_part(parts, counts.get("client_node_removed", 0), "client", "removed")
|
|
134
|
+
_add_count_part(parts, counts.get("client_node_changed", 0), "client", "changed")
|
|
135
|
+
|
|
136
|
+
# Edges (combine device and client edges)
|
|
137
|
+
edge_added = counts.get("device_edge_added", 0) + counts.get("client_edge_added", 0)
|
|
138
|
+
edge_removed = counts.get("device_edge_removed", 0) + counts.get("client_edge_removed", 0)
|
|
139
|
+
edge_changed = counts.get("device_edge_changed", 0) + counts.get("client_edge_changed", 0)
|
|
140
|
+
_add_count_part(parts, edge_added, "connection", "added")
|
|
141
|
+
_add_count_part(parts, edge_removed, "connection", "removed")
|
|
142
|
+
_add_count_part(parts, edge_changed, "connection", "changed")
|
|
143
|
+
|
|
144
|
+
return ", ".join(parts) if parts else "No changes"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# --- Node comparison ---
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _device_properties(device: Device) -> dict[str, Any]:
|
|
151
|
+
"""Extract comparable properties from a device."""
|
|
152
|
+
return {
|
|
153
|
+
"name": device.name,
|
|
154
|
+
"model": device.model,
|
|
155
|
+
"model_name": device.model_name,
|
|
156
|
+
"ip": device.ip,
|
|
157
|
+
"type": device.type,
|
|
158
|
+
"version": device.version,
|
|
159
|
+
"uplink_mac": device.uplink.mac if device.uplink else None,
|
|
160
|
+
"uplink_port": device.uplink.port if device.uplink else None,
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _client_properties(client: dict[str, Any]) -> dict[str, Any]:
|
|
165
|
+
"""Extract comparable properties from a client."""
|
|
166
|
+
return {
|
|
167
|
+
"name": client.get("name") or client.get("hostname"),
|
|
168
|
+
"ip": client.get("ip"),
|
|
169
|
+
"vlan": client.get("vlan") or client.get("vlan_id"),
|
|
170
|
+
"is_wired": client.get("is_wired"),
|
|
171
|
+
"uplink_mac": (client.get("ap_mac") or client.get("sw_mac") or client.get("uplink_mac")),
|
|
172
|
+
"uplink_port": client.get("sw_port") or client.get("uplink_remote_port"),
|
|
173
|
+
"channel": client.get("channel"),
|
|
174
|
+
"signal": client.get("signal"),
|
|
175
|
+
"satisfaction": client.get("satisfaction"),
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _compare_properties(
|
|
180
|
+
old_props: dict[str, Any],
|
|
181
|
+
new_props: dict[str, Any],
|
|
182
|
+
) -> dict[str, dict[str, Any]]:
|
|
183
|
+
"""Compare two property dicts and return changes."""
|
|
184
|
+
changes: dict[str, dict[str, Any]] = {}
|
|
185
|
+
all_keys = set(old_props.keys()) | set(new_props.keys())
|
|
186
|
+
for key in all_keys:
|
|
187
|
+
old_val = old_props.get(key)
|
|
188
|
+
new_val = new_props.get(key)
|
|
189
|
+
if old_val != new_val:
|
|
190
|
+
changes[key] = {"old": old_val, "new": new_val}
|
|
191
|
+
return changes
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _describe_device_added(device: Device) -> str:
|
|
195
|
+
"""Generate description for device added event."""
|
|
196
|
+
return f"Device '{device.name}' appeared on network"
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _describe_device_removed(device: Device) -> str:
|
|
200
|
+
"""Generate description for device removed event."""
|
|
201
|
+
return f"Device '{device.name}' disappeared from network"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _describe_device_changed(device: Device, changes: dict[str, dict[str, Any]]) -> str:
|
|
205
|
+
"""Generate description for device changed event."""
|
|
206
|
+
if len(changes) == 1:
|
|
207
|
+
key = list(changes.keys())[0]
|
|
208
|
+
old_val = changes[key]["old"]
|
|
209
|
+
new_val = changes[key]["new"]
|
|
210
|
+
if key == "ip":
|
|
211
|
+
return f"Device '{device.name}' IP changed from {old_val} to {new_val}"
|
|
212
|
+
if key == "name":
|
|
213
|
+
return f"Device renamed from '{old_val}' to '{new_val}'"
|
|
214
|
+
if key == "uplink_mac":
|
|
215
|
+
return f"Device '{device.name}' uplink changed"
|
|
216
|
+
if key == "uplink_port":
|
|
217
|
+
return f"Device '{device.name}' moved to port {new_val}"
|
|
218
|
+
return f"Device '{device.name}' {key} changed"
|
|
219
|
+
return f"Device '{device.name}' changed ({len(changes)} properties)"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _describe_client_added(client: dict[str, Any]) -> str:
|
|
223
|
+
"""Generate description for client added event."""
|
|
224
|
+
name = client.get("name") or client.get("hostname") or client.get("mac", "unknown")
|
|
225
|
+
is_wired = client.get("is_wired", True)
|
|
226
|
+
conn_type = "wired" if is_wired else "WiFi"
|
|
227
|
+
return f"Client '{name}' connected via {conn_type}"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _describe_client_removed(client: dict[str, Any]) -> str:
|
|
231
|
+
"""Generate description for client removed event."""
|
|
232
|
+
name = client.get("name") or client.get("hostname") or client.get("mac", "unknown")
|
|
233
|
+
return f"Client '{name}' disconnected"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _describe_client_changed(client: dict[str, Any], changes: dict[str, dict[str, Any]]) -> str:
|
|
237
|
+
"""Generate description for client changed event."""
|
|
238
|
+
name = client.get("name") or client.get("hostname") or client.get("mac", "unknown")
|
|
239
|
+
if len(changes) == 1:
|
|
240
|
+
key = list(changes.keys())[0]
|
|
241
|
+
old_val = changes[key]["old"]
|
|
242
|
+
new_val = changes[key]["new"]
|
|
243
|
+
if key == "vlan":
|
|
244
|
+
return f"Client '{name}' changed VLAN from {old_val} to {new_val}"
|
|
245
|
+
if key == "ip":
|
|
246
|
+
return f"Client '{name}' IP changed from {old_val} to {new_val}"
|
|
247
|
+
if key == "uplink_mac":
|
|
248
|
+
return f"Client '{name}' moved to different device"
|
|
249
|
+
if key == "uplink_port":
|
|
250
|
+
return f"Client '{name}' moved to port {new_val}"
|
|
251
|
+
return f"Client '{name}' {key} changed"
|
|
252
|
+
return f"Client '{name}' changed ({len(changes)} properties)"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# --- Edge comparison ---
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _edge_key(edge: Edge) -> frozenset[str]:
|
|
259
|
+
"""Create a stable key for an edge (order-independent)."""
|
|
260
|
+
return frozenset({edge.left, edge.right})
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _edge_properties(edge: Edge) -> dict[str, Any]:
|
|
264
|
+
"""Extract comparable properties from an edge."""
|
|
265
|
+
return {
|
|
266
|
+
"label": edge.label,
|
|
267
|
+
"poe": edge.poe,
|
|
268
|
+
"wireless": edge.wireless,
|
|
269
|
+
"speed": edge.speed,
|
|
270
|
+
"channel": edge.channel,
|
|
271
|
+
"vlans": edge.vlans,
|
|
272
|
+
"is_trunk": edge.is_trunk,
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _describe_edge_added(edge: Edge) -> str:
|
|
277
|
+
"""Generate description for edge added event."""
|
|
278
|
+
conn_type = "wireless" if edge.wireless else "wired"
|
|
279
|
+
return f"Connection added: {edge.left} <-> {edge.right} ({conn_type})"
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _describe_edge_removed(edge: Edge) -> str:
|
|
283
|
+
"""Generate description for edge removed event."""
|
|
284
|
+
return f"Connection removed: {edge.left} <-> {edge.right}"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _describe_edge_changed(edge: Edge, changes: dict[str, dict[str, Any]]) -> str:
|
|
288
|
+
"""Generate description for edge changed event."""
|
|
289
|
+
if len(changes) == 1:
|
|
290
|
+
key = list(changes.keys())[0]
|
|
291
|
+
if key == "speed":
|
|
292
|
+
old_val = changes[key]["old"]
|
|
293
|
+
new_val = changes[key]["new"]
|
|
294
|
+
return (
|
|
295
|
+
f"Connection {edge.left} <-> {edge.right} speed changed from {old_val} to {new_val}"
|
|
296
|
+
)
|
|
297
|
+
if key == "poe":
|
|
298
|
+
new_val = changes[key]["new"]
|
|
299
|
+
poe_state = "enabled" if new_val else "disabled"
|
|
300
|
+
return f"Connection {edge.left} <-> {edge.right} PoE {poe_state}"
|
|
301
|
+
return f"Connection {edge.left} <-> {edge.right} changed"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# --- Main comparison function ---
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def compare_topologies(
|
|
308
|
+
old_devices: list[Device],
|
|
309
|
+
new_devices: list[Device],
|
|
310
|
+
old_clients: list[dict[str, Any]] | None = None,
|
|
311
|
+
new_clients: list[dict[str, Any]] | None = None,
|
|
312
|
+
old_edges: list[Edge] | None = None,
|
|
313
|
+
new_edges: list[Edge] | None = None,
|
|
314
|
+
*,
|
|
315
|
+
old_timestamp: str | None = None,
|
|
316
|
+
new_timestamp: str | None = None,
|
|
317
|
+
) -> TopologyDiff:
|
|
318
|
+
"""Compare two topology snapshots and return structured change events.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
old_devices: Devices from the previous snapshot.
|
|
322
|
+
new_devices: Devices from the current snapshot.
|
|
323
|
+
old_clients: Clients from the previous snapshot (optional).
|
|
324
|
+
new_clients: Clients from the current snapshot (optional).
|
|
325
|
+
old_edges: Edges from the previous snapshot (optional).
|
|
326
|
+
new_edges: Edges from the current snapshot (optional).
|
|
327
|
+
old_timestamp: ISO timestamp of old snapshot.
|
|
328
|
+
new_timestamp: ISO timestamp of new snapshot.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
TopologyDiff containing all detected changes.
|
|
332
|
+
"""
|
|
333
|
+
events: list[TopologyChangeEvent] = []
|
|
334
|
+
timestamp = new_timestamp or datetime.now(UTC).isoformat()
|
|
335
|
+
|
|
336
|
+
# Compare devices
|
|
337
|
+
_compare_devices(old_devices, new_devices, events, timestamp)
|
|
338
|
+
|
|
339
|
+
# Compare clients
|
|
340
|
+
if old_clients is not None and new_clients is not None:
|
|
341
|
+
_compare_clients(old_clients, new_clients, events, timestamp)
|
|
342
|
+
|
|
343
|
+
# Compare edges
|
|
344
|
+
if old_edges is not None and new_edges is not None:
|
|
345
|
+
_compare_edges(old_edges, new_edges, events, timestamp)
|
|
346
|
+
|
|
347
|
+
return TopologyDiff(
|
|
348
|
+
events=events,
|
|
349
|
+
old_timestamp=old_timestamp,
|
|
350
|
+
new_timestamp=new_timestamp,
|
|
351
|
+
summary=_build_summary(events),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _compare_devices(
|
|
356
|
+
old_devices: list[Device],
|
|
357
|
+
new_devices: list[Device],
|
|
358
|
+
events: list[TopologyChangeEvent],
|
|
359
|
+
timestamp: str,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Compare device lists and add events."""
|
|
362
|
+
old_by_mac = {normalize_mac(d.mac): d for d in old_devices}
|
|
363
|
+
new_by_mac = {normalize_mac(d.mac): d for d in new_devices}
|
|
364
|
+
|
|
365
|
+
old_macs = set(old_by_mac.keys())
|
|
366
|
+
new_macs = set(new_by_mac.keys())
|
|
367
|
+
|
|
368
|
+
# Added devices
|
|
369
|
+
for mac in sorted(new_macs - old_macs):
|
|
370
|
+
device = new_by_mac[mac]
|
|
371
|
+
events.append(
|
|
372
|
+
TopologyChangeEvent(
|
|
373
|
+
event_type="node_added",
|
|
374
|
+
entity_type="device",
|
|
375
|
+
identifier=mac,
|
|
376
|
+
name=device.name,
|
|
377
|
+
description=_describe_device_added(device),
|
|
378
|
+
details=device_to_dict(device),
|
|
379
|
+
timestamp=timestamp,
|
|
380
|
+
)
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
# Removed devices
|
|
384
|
+
for mac in sorted(old_macs - new_macs):
|
|
385
|
+
device = old_by_mac[mac]
|
|
386
|
+
events.append(
|
|
387
|
+
TopologyChangeEvent(
|
|
388
|
+
event_type="node_removed",
|
|
389
|
+
entity_type="device",
|
|
390
|
+
identifier=mac,
|
|
391
|
+
name=device.name,
|
|
392
|
+
description=_describe_device_removed(device),
|
|
393
|
+
details=device_to_dict(device),
|
|
394
|
+
timestamp=timestamp,
|
|
395
|
+
)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
# Changed devices
|
|
399
|
+
for mac in sorted(old_macs & new_macs):
|
|
400
|
+
old_device = old_by_mac[mac]
|
|
401
|
+
new_device = new_by_mac[mac]
|
|
402
|
+
old_props = _device_properties(old_device)
|
|
403
|
+
new_props = _device_properties(new_device)
|
|
404
|
+
changes = _compare_properties(old_props, new_props)
|
|
405
|
+
if changes:
|
|
406
|
+
events.append(
|
|
407
|
+
TopologyChangeEvent(
|
|
408
|
+
event_type="node_changed",
|
|
409
|
+
entity_type="device",
|
|
410
|
+
identifier=mac,
|
|
411
|
+
name=new_device.name,
|
|
412
|
+
description=_describe_device_changed(new_device, changes),
|
|
413
|
+
details={"changes": changes},
|
|
414
|
+
timestamp=timestamp,
|
|
415
|
+
)
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _compare_clients(
|
|
420
|
+
old_clients: list[dict[str, Any]],
|
|
421
|
+
new_clients: list[dict[str, Any]],
|
|
422
|
+
events: list[TopologyChangeEvent],
|
|
423
|
+
timestamp: str,
|
|
424
|
+
) -> None:
|
|
425
|
+
"""Compare client lists and add events."""
|
|
426
|
+
old_by_mac = {normalize_mac(c.get("mac", "")): c for c in old_clients if c.get("mac")}
|
|
427
|
+
new_by_mac = {normalize_mac(c.get("mac", "")): c for c in new_clients if c.get("mac")}
|
|
428
|
+
|
|
429
|
+
old_macs = set(old_by_mac.keys())
|
|
430
|
+
new_macs = set(new_by_mac.keys())
|
|
431
|
+
|
|
432
|
+
# Added clients
|
|
433
|
+
for mac in sorted(new_macs - old_macs):
|
|
434
|
+
client = new_by_mac[mac]
|
|
435
|
+
events.append(
|
|
436
|
+
TopologyChangeEvent(
|
|
437
|
+
event_type="node_added",
|
|
438
|
+
entity_type="client",
|
|
439
|
+
identifier=mac,
|
|
440
|
+
name=client.get("name") or client.get("hostname"),
|
|
441
|
+
description=_describe_client_added(client),
|
|
442
|
+
details=_client_properties(client),
|
|
443
|
+
timestamp=timestamp,
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# Removed clients
|
|
448
|
+
for mac in sorted(old_macs - new_macs):
|
|
449
|
+
client = old_by_mac[mac]
|
|
450
|
+
events.append(
|
|
451
|
+
TopologyChangeEvent(
|
|
452
|
+
event_type="node_removed",
|
|
453
|
+
entity_type="client",
|
|
454
|
+
identifier=mac,
|
|
455
|
+
name=client.get("name") or client.get("hostname"),
|
|
456
|
+
description=_describe_client_removed(client),
|
|
457
|
+
details=_client_properties(client),
|
|
458
|
+
timestamp=timestamp,
|
|
459
|
+
)
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
# Changed clients
|
|
463
|
+
for mac in sorted(old_macs & new_macs):
|
|
464
|
+
old_client = old_by_mac[mac]
|
|
465
|
+
new_client = new_by_mac[mac]
|
|
466
|
+
old_props = _client_properties(old_client)
|
|
467
|
+
new_props = _client_properties(new_client)
|
|
468
|
+
changes = _compare_properties(old_props, new_props)
|
|
469
|
+
if changes:
|
|
470
|
+
events.append(
|
|
471
|
+
TopologyChangeEvent(
|
|
472
|
+
event_type="node_changed",
|
|
473
|
+
entity_type="client",
|
|
474
|
+
identifier=mac,
|
|
475
|
+
name=new_client.get("name") or new_client.get("hostname"),
|
|
476
|
+
description=_describe_client_changed(new_client, changes),
|
|
477
|
+
details={"changes": changes},
|
|
478
|
+
timestamp=timestamp,
|
|
479
|
+
)
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _compare_edges(
|
|
484
|
+
old_edges: list[Edge],
|
|
485
|
+
new_edges: list[Edge],
|
|
486
|
+
events: list[TopologyChangeEvent],
|
|
487
|
+
timestamp: str,
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Compare edge lists and add events."""
|
|
490
|
+
old_by_key = {_edge_key(e): e for e in old_edges}
|
|
491
|
+
new_by_key = {_edge_key(e): e for e in new_edges}
|
|
492
|
+
|
|
493
|
+
old_keys = set(old_by_key.keys())
|
|
494
|
+
new_keys = set(new_by_key.keys())
|
|
495
|
+
|
|
496
|
+
# Added edges
|
|
497
|
+
for key in sorted(new_keys - old_keys, key=lambda k: tuple(sorted(k))):
|
|
498
|
+
edge = new_by_key[key]
|
|
499
|
+
events.append(
|
|
500
|
+
TopologyChangeEvent(
|
|
501
|
+
event_type="edge_added",
|
|
502
|
+
entity_type="device", # Could be client edge, but entity_type is for filtering
|
|
503
|
+
identifier=f"{edge.left}:{edge.right}",
|
|
504
|
+
name=None,
|
|
505
|
+
description=_describe_edge_added(edge),
|
|
506
|
+
details=edge_to_dict(edge),
|
|
507
|
+
timestamp=timestamp,
|
|
508
|
+
)
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
# Removed edges
|
|
512
|
+
for key in sorted(old_keys - new_keys, key=lambda k: tuple(sorted(k))):
|
|
513
|
+
edge = old_by_key[key]
|
|
514
|
+
events.append(
|
|
515
|
+
TopologyChangeEvent(
|
|
516
|
+
event_type="edge_removed",
|
|
517
|
+
entity_type="device",
|
|
518
|
+
identifier=f"{edge.left}:{edge.right}",
|
|
519
|
+
name=None,
|
|
520
|
+
description=_describe_edge_removed(edge),
|
|
521
|
+
details=edge_to_dict(edge),
|
|
522
|
+
timestamp=timestamp,
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
# Changed edges
|
|
527
|
+
for key in sorted(old_keys & new_keys, key=lambda k: tuple(sorted(k))):
|
|
528
|
+
old_edge = old_by_key[key]
|
|
529
|
+
new_edge = new_by_key[key]
|
|
530
|
+
old_props = _edge_properties(old_edge)
|
|
531
|
+
new_props = _edge_properties(new_edge)
|
|
532
|
+
changes = _compare_properties(old_props, new_props)
|
|
533
|
+
if changes:
|
|
534
|
+
events.append(
|
|
535
|
+
TopologyChangeEvent(
|
|
536
|
+
event_type="edge_changed",
|
|
537
|
+
entity_type="device",
|
|
538
|
+
identifier=f"{new_edge.left}:{new_edge.right}",
|
|
539
|
+
name=None,
|
|
540
|
+
description=_describe_edge_changed(new_edge, changes),
|
|
541
|
+
details={"changes": changes},
|
|
542
|
+
timestamp=timestamp,
|
|
543
|
+
)
|
|
544
|
+
)
|