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,184 @@
|
|
|
1
|
+
"""Text formatting and label utilities for SVG rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..model.topology import WanInfo, WanInterface
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _escape_text(value: str) -> str:
|
|
9
|
+
return value.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _extract_port_text(side: str) -> str | None:
|
|
13
|
+
candidate = side.split(":", 1)[1].strip() if ":" in side else side.strip()
|
|
14
|
+
if candidate.lower().startswith("port "):
|
|
15
|
+
return candidate
|
|
16
|
+
return None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _extract_device_name(side: str) -> str | None:
|
|
20
|
+
if ":" not in side:
|
|
21
|
+
return None
|
|
22
|
+
name = side.split(":", 1)[0].strip()
|
|
23
|
+
return name or None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _compact_edge_label(
|
|
27
|
+
label: str, *, left_node: str | None = None, right_node: str | None = None
|
|
28
|
+
) -> str:
|
|
29
|
+
if "<->" not in label:
|
|
30
|
+
return label
|
|
31
|
+
left_segment, right_segment = (part.strip() for part in label.split("<->", 1))
|
|
32
|
+
left_name = _extract_device_name(left_segment)
|
|
33
|
+
right_name = _extract_device_name(right_segment)
|
|
34
|
+
left_port = _extract_port_text(left_segment)
|
|
35
|
+
right_port = _extract_port_text(right_segment)
|
|
36
|
+
if left_node and right_node:
|
|
37
|
+
if right_name and right_name == left_node and left_name == right_node:
|
|
38
|
+
left_name, right_name = right_name, left_name
|
|
39
|
+
left_port, right_port = right_port, left_port
|
|
40
|
+
if left_port and right_port:
|
|
41
|
+
if left_name:
|
|
42
|
+
return f"{left_name} {left_port} <-> {right_port}"
|
|
43
|
+
return f"{left_port} <-> {right_port}"
|
|
44
|
+
if left_port:
|
|
45
|
+
return left_port
|
|
46
|
+
if right_port:
|
|
47
|
+
return right_port
|
|
48
|
+
return label
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _format_port_label_lines(
|
|
52
|
+
port_label: str,
|
|
53
|
+
*,
|
|
54
|
+
node_type: str,
|
|
55
|
+
prefix: str,
|
|
56
|
+
max_chars: int,
|
|
57
|
+
) -> list[str]:
|
|
58
|
+
def _port_only(segment: str) -> str:
|
|
59
|
+
port = _extract_port_text(segment)
|
|
60
|
+
if port:
|
|
61
|
+
return port
|
|
62
|
+
lower = segment.lower()
|
|
63
|
+
idx = lower.rfind("port ")
|
|
64
|
+
if idx != -1:
|
|
65
|
+
return segment[idx:].strip()
|
|
66
|
+
return segment.split(":", 1)[-1].strip()
|
|
67
|
+
|
|
68
|
+
def _truncate(text: str, max_len: int = max_chars) -> str:
|
|
69
|
+
return text[: max_len - 3].rstrip() + "..." if len(text) > max_len else text
|
|
70
|
+
|
|
71
|
+
if "<->" in port_label:
|
|
72
|
+
left_part, right_part = (part.strip() for part in port_label.split("<->", 1))
|
|
73
|
+
front_text = _truncate(f"{prefix}: {_port_only(left_part)}")
|
|
74
|
+
side_prefix = prefix if node_type == "client" else "local"
|
|
75
|
+
side_text = _truncate(f"{side_prefix}: {_port_only(right_part)}")
|
|
76
|
+
return [line for line in (front_text, side_text) if line]
|
|
77
|
+
side_prefix = prefix if node_type == "client" else "local"
|
|
78
|
+
side_text = _truncate(f"{side_prefix}: {_port_only(port_label)}")
|
|
79
|
+
return [side_text]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _wrap_text(label: str, *, max_len: int = 24) -> list[str]:
|
|
83
|
+
if len(label) <= max_len:
|
|
84
|
+
return [label]
|
|
85
|
+
split_at = label.rfind(" ", 0, max_len + 1)
|
|
86
|
+
if split_at == -1:
|
|
87
|
+
split_at = max_len
|
|
88
|
+
first = label[:split_at].rstrip()
|
|
89
|
+
rest = label[split_at:].lstrip()
|
|
90
|
+
return [first, rest] if rest else [first]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _shorten_prefix(name: str, max_words: int = 2) -> str:
|
|
94
|
+
words = name.split()
|
|
95
|
+
if len(words) <= max_words:
|
|
96
|
+
return name
|
|
97
|
+
return " ".join(words[:max_words]) + "..."
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _label_metrics(
|
|
101
|
+
lines: list[str], *, font_size: int, padding_x: int = 6, padding_y: int = 3
|
|
102
|
+
) -> tuple[float, float]:
|
|
103
|
+
max_len = max((len(line) for line in lines), default=0)
|
|
104
|
+
text_width = max_len * font_size * 0.6
|
|
105
|
+
text_height = len(lines) * (font_size + 2)
|
|
106
|
+
width = text_width + padding_x * 2
|
|
107
|
+
height = text_height + padding_y * 2
|
|
108
|
+
return width, height
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _format_wan_speed(speed_mbps: int | None) -> str | None:
|
|
112
|
+
"""Format speed in Mbps to human-readable string (e.g., 10GbE, 100MbE)."""
|
|
113
|
+
if speed_mbps is None or speed_mbps == 0:
|
|
114
|
+
return None
|
|
115
|
+
if speed_mbps >= 1000:
|
|
116
|
+
gbps = speed_mbps / 1000
|
|
117
|
+
if gbps == int(gbps):
|
|
118
|
+
return f"{int(gbps)}GbE"
|
|
119
|
+
return f"{gbps:.1f}GbE"
|
|
120
|
+
return f"{speed_mbps}MbE"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _format_wan_interface_line(
|
|
124
|
+
wan: WanInterface,
|
|
125
|
+
prefix: str,
|
|
126
|
+
*,
|
|
127
|
+
is_dual: bool,
|
|
128
|
+
include_speed: bool = True,
|
|
129
|
+
) -> str:
|
|
130
|
+
"""Format a single WAN interface line."""
|
|
131
|
+
status = "●" if wan.enabled else "○"
|
|
132
|
+
label = wan.label or prefix
|
|
133
|
+
speed_parts = []
|
|
134
|
+
if include_speed and wan.link_speed and wan.enabled:
|
|
135
|
+
speed_parts.append(f"Link {_format_wan_speed(wan.link_speed)}")
|
|
136
|
+
if wan.isp_speed:
|
|
137
|
+
speed_parts.append(f"ISP {wan.isp_speed}")
|
|
138
|
+
speed_str = " / ".join(speed_parts) if speed_parts else ""
|
|
139
|
+
|
|
140
|
+
if not wan.enabled and prefix == "WAN2":
|
|
141
|
+
return f"{prefix}: {label} (disabled) {status}"
|
|
142
|
+
|
|
143
|
+
if is_dual:
|
|
144
|
+
line = f"{prefix}: {label}"
|
|
145
|
+
if speed_str:
|
|
146
|
+
line += f" ({speed_str})"
|
|
147
|
+
return f"{line} {status}"
|
|
148
|
+
|
|
149
|
+
return label
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _build_wan_label_lines(wan_info: WanInfo) -> list[str]:
|
|
153
|
+
"""Build label lines for WAN display."""
|
|
154
|
+
label_lines: list[str] = []
|
|
155
|
+
is_dual = wan_info.wan2 is not None
|
|
156
|
+
|
|
157
|
+
if wan_info.wan1:
|
|
158
|
+
wan1 = wan_info.wan1
|
|
159
|
+
if is_dual:
|
|
160
|
+
label_lines.append(
|
|
161
|
+
_format_wan_interface_line(wan1, "WAN1", is_dual=True, include_speed=True)
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
# Single WAN format: multi-line
|
|
165
|
+
label_lines.append(wan1.label or "WAN1")
|
|
166
|
+
speed_parts = []
|
|
167
|
+
if wan1.link_speed:
|
|
168
|
+
speed_parts.append(f"Link {_format_wan_speed(wan1.link_speed)}")
|
|
169
|
+
if wan1.isp_speed:
|
|
170
|
+
speed_parts.append(f"ISP {wan1.isp_speed}")
|
|
171
|
+
if speed_parts:
|
|
172
|
+
label_lines.append(" / ".join(speed_parts))
|
|
173
|
+
if wan1.ip_address:
|
|
174
|
+
label_lines.append(wan1.ip_address)
|
|
175
|
+
|
|
176
|
+
if wan_info.wan2:
|
|
177
|
+
label_lines.append(
|
|
178
|
+
_format_wan_interface_line(wan_info.wan2, "WAN2", is_dual=True, include_speed=True)
|
|
179
|
+
)
|
|
180
|
+
# Add IP from WAN1 for dual WAN display
|
|
181
|
+
if wan_info.wan1 and wan_info.wan1.ip_address:
|
|
182
|
+
label_lines.append(wan_info.wan1.ip_address)
|
|
183
|
+
|
|
184
|
+
return label_lines
|
|
@@ -2,18 +2,103 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from dataclasses import dataclass
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
6
|
|
|
7
7
|
|
|
8
8
|
@dataclass(frozen=True)
|
|
9
9
|
class SvgTheme:
|
|
10
|
+
# Links
|
|
10
11
|
link_standard: tuple[str, str]
|
|
11
12
|
link_poe: tuple[str, str]
|
|
13
|
+
|
|
14
|
+
# Nodes - infrastructure
|
|
12
15
|
node_gateway: tuple[str, str]
|
|
13
16
|
node_switch: tuple[str, str]
|
|
14
17
|
node_ap: tuple[str, str]
|
|
15
18
|
node_client: tuple[str, str]
|
|
16
19
|
node_other: tuple[str, str]
|
|
20
|
+
node_client_cluster: tuple[str, str] = ("#d4b8ff", "#a080e0")
|
|
21
|
+
|
|
22
|
+
# Nodes - extended device types
|
|
23
|
+
node_camera: tuple[str, str] = ("#b3e5fc", "#64b5f6") # Light blue
|
|
24
|
+
node_tv: tuple[str, str] = ("#d1c4e9", "#9575cd") # Light violet
|
|
25
|
+
node_phone: tuple[str, str] = ("#c8e6c9", "#81c784") # Light green
|
|
26
|
+
node_printer: tuple[str, str] = ("#cfd8dc", "#90a4ae") # Blue-gray
|
|
27
|
+
node_nas: tuple[str, str] = ("#ffe0b2", "#ffb74d") # Amber
|
|
28
|
+
node_speaker: tuple[str, str] = ("#b2dfdb", "#4db6ac") # Teal
|
|
29
|
+
node_game_console: tuple[str, str] = ("#e1bee7", "#ba68c8") # Light purple
|
|
30
|
+
node_iot: tuple[str, str] = ("#b2ebf2", "#4dd0e1") # Cyan
|
|
31
|
+
|
|
32
|
+
# Groups
|
|
33
|
+
group_fill: str = "#f8f9fa"
|
|
34
|
+
group_stroke: str = "#dee2e6"
|
|
35
|
+
group_radius: int = 8
|
|
36
|
+
group_label_fill: str = "#495057"
|
|
37
|
+
group_stroke_width: int = 2
|
|
38
|
+
|
|
39
|
+
# VLANs
|
|
40
|
+
vlan_colors: dict[int, str] = field(default_factory=dict)
|
|
41
|
+
|
|
42
|
+
# Background & text
|
|
43
|
+
background: str = "#ffffff"
|
|
44
|
+
text_primary: str = "#1a1a1a"
|
|
45
|
+
text_secondary: str = "#6b7280"
|
|
46
|
+
|
|
47
|
+
# Status indicators
|
|
48
|
+
status_online: str = "#00a86b"
|
|
49
|
+
status_offline: str = "#ef4444"
|
|
50
|
+
|
|
51
|
+
# WAN globe
|
|
52
|
+
wan_globe: tuple[str, str] = ("#4fc3f7", "#0288d1")
|
|
53
|
+
wan_background: str = "#f0f9ff" # Light blue tint for WAN box
|
|
54
|
+
|
|
55
|
+
# PoE indicator
|
|
56
|
+
poe_fill: str = "#1565c0" # Dark blue
|
|
57
|
+
poe_stroke: str = "#ffc107" # Golden
|
|
58
|
+
|
|
59
|
+
# Icon set
|
|
60
|
+
icon_set: str = "isometric"
|
|
61
|
+
|
|
62
|
+
# Font
|
|
63
|
+
font_family: str | None = None
|
|
64
|
+
|
|
65
|
+
# Icon decal color (for modern icons rendered on node surface)
|
|
66
|
+
icon_decal: str = "#5A6878"
|
|
67
|
+
|
|
68
|
+
# Isometric grid
|
|
69
|
+
grid_color: str = "#efefef"
|
|
70
|
+
|
|
71
|
+
# Isometric node side face colors (SW=left, E=right)
|
|
72
|
+
node_side_left: str = "#dcdcdc"
|
|
73
|
+
node_side_right: str = "#c8c8c8"
|
|
74
|
+
|
|
75
|
+
def group_colors(self, group_name: str) -> tuple[str, str]:
|
|
76
|
+
"""Return (fill, stroke) colors for a group based on its type."""
|
|
77
|
+
color_map = {
|
|
78
|
+
"gateway": self.node_gateway,
|
|
79
|
+
"switch": self.node_switch,
|
|
80
|
+
"ap": self.node_ap,
|
|
81
|
+
"client": self.node_client,
|
|
82
|
+
"client_cluster": self.node_client_cluster,
|
|
83
|
+
"camera": self.node_camera,
|
|
84
|
+
"tv": self.node_tv,
|
|
85
|
+
"phone": self.node_phone,
|
|
86
|
+
"printer": self.node_printer,
|
|
87
|
+
"nas": self.node_nas,
|
|
88
|
+
"speaker": self.node_speaker,
|
|
89
|
+
"game_console": self.node_game_console,
|
|
90
|
+
"iot": self.node_iot,
|
|
91
|
+
"other": self.node_other,
|
|
92
|
+
}
|
|
93
|
+
return color_map.get(group_name.lower(), (self.group_fill, self.group_stroke))
|
|
94
|
+
|
|
95
|
+
def vlan_color(self, vlan_id: int) -> str:
|
|
96
|
+
"""Return color for a VLAN, using theme color or auto-generated fallback."""
|
|
97
|
+
if vlan_id in self.vlan_colors:
|
|
98
|
+
return self.vlan_colors[vlan_id]
|
|
99
|
+
# Golden angle HSL rotation for distinct, deterministic colors
|
|
100
|
+
hue = (vlan_id * 137) % 360
|
|
101
|
+
return f"hsl({hue}, 70%, 55%)"
|
|
17
102
|
|
|
18
103
|
|
|
19
104
|
DEFAULT_THEME = SvgTheme(
|
|
@@ -27,38 +112,87 @@ DEFAULT_THEME = SvgTheme(
|
|
|
27
112
|
)
|
|
28
113
|
|
|
29
114
|
|
|
115
|
+
def _gradient(grad_id: str, colors: tuple[str, str], *, horizontal: bool = False) -> str:
|
|
116
|
+
"""Build a single linearGradient element."""
|
|
117
|
+
x2, y2 = ("100%", "0%") if horizontal else ("100%", "100%")
|
|
118
|
+
return (
|
|
119
|
+
f'<linearGradient id="{grad_id}" x1="0%" y1="0%" x2="{x2}" y2="{y2}">'
|
|
120
|
+
f'<stop offset="0%" stop-color="{colors[0]}"/>'
|
|
121
|
+
f'<stop offset="100%" stop-color="{colors[1]}"/>'
|
|
122
|
+
"</linearGradient>"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
30
126
|
def svg_defs(prefix: str, theme: SvgTheme = DEFAULT_THEME) -> str:
|
|
31
127
|
gradient_prefix = f"{prefix}-" if prefix else ""
|
|
32
128
|
node_prefix = f"{prefix}-node-" if prefix else "node-"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
"
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
f
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
"
|
|
129
|
+
filter_prefix = f"{prefix}-" if prefix else ""
|
|
130
|
+
|
|
131
|
+
parts = ["<defs>"]
|
|
132
|
+
|
|
133
|
+
# Link gradients (horizontal)
|
|
134
|
+
parts.append(_gradient(f"{gradient_prefix}link-standard", theme.link_standard, horizontal=True))
|
|
135
|
+
parts.append(_gradient(f"{gradient_prefix}link-poe", theme.link_poe, horizontal=True))
|
|
136
|
+
|
|
137
|
+
# Node gradients (diagonal)
|
|
138
|
+
node_types: list[tuple[str, tuple[str, str]]] = [
|
|
139
|
+
("gateway", theme.node_gateway),
|
|
140
|
+
("switch", theme.node_switch),
|
|
141
|
+
("ap", theme.node_ap),
|
|
142
|
+
("client", theme.node_client),
|
|
143
|
+
("client_cluster", theme.node_client_cluster),
|
|
144
|
+
("other", theme.node_other),
|
|
145
|
+
("camera", theme.node_camera),
|
|
146
|
+
("tv", theme.node_tv),
|
|
147
|
+
("phone", theme.node_phone),
|
|
148
|
+
("printer", theme.node_printer),
|
|
149
|
+
("nas", theme.node_nas),
|
|
150
|
+
("speaker", theme.node_speaker),
|
|
151
|
+
("game_console", theme.node_game_console),
|
|
152
|
+
("iot", theme.node_iot),
|
|
153
|
+
]
|
|
154
|
+
for name, colors in node_types:
|
|
155
|
+
parts.append(_gradient(f"{node_prefix}{name}", colors))
|
|
156
|
+
|
|
157
|
+
# Filters
|
|
158
|
+
parts.append(
|
|
159
|
+
f'<filter id="{filter_prefix}edge-glow" x="-50%" y="-50%" width="200%" height="200%">'
|
|
160
|
+
'<feGaussianBlur stdDeviation="4" result="blur"/>'
|
|
161
|
+
"</filter>"
|
|
64
162
|
)
|
|
163
|
+
# Emboss filter for icon decals - iOS glass effect
|
|
164
|
+
parts.append(
|
|
165
|
+
f'<filter id="{filter_prefix}icon-emboss" x="-50%" y="-50%" width="200%" height="200%">'
|
|
166
|
+
'<feGaussianBlur in="SourceAlpha" stdDeviation="2.5" result="blur"/>'
|
|
167
|
+
'<feOffset in="blur" dx="0" dy="2.5" result="dropShadow"/>'
|
|
168
|
+
'<feFlood flood-color="#000000" flood-opacity="0.4" result="shadowColor"/>'
|
|
169
|
+
'<feComposite in="shadowColor" in2="dropShadow" operator="in" result="shadow"/>'
|
|
170
|
+
'<feGaussianBlur in="SourceAlpha" stdDeviation="1.5" result="blurLight"/>'
|
|
171
|
+
'<feOffset in="blurLight" dx="-2" dy="-1.8" result="lightOffset"/>'
|
|
172
|
+
'<feFlood flood-color="#ffffff" flood-opacity="0.9" result="lightColor"/>'
|
|
173
|
+
'<feComposite in="lightColor" in2="lightOffset" operator="in" result="highlight"/>'
|
|
174
|
+
'<feComposite in="highlight" in2="SourceAlpha" operator="out" result="edgeHighlight"/>'
|
|
175
|
+
'<feGaussianBlur in="SourceAlpha" stdDeviation="1.5" result="blurDark"/>'
|
|
176
|
+
'<feOffset in="blurDark" dx="2" dy="1.8" result="darkOffset"/>'
|
|
177
|
+
'<feFlood flood-color="#000000" flood-opacity="0.6" result="darkColor"/>'
|
|
178
|
+
'<feComposite in="darkColor" in2="darkOffset" operator="in" result="innerShadow"/>'
|
|
179
|
+
'<feComposite in="innerShadow" in2="SourceAlpha" operator="out" result="edgeShadow"/>'
|
|
180
|
+
"<feMerge>"
|
|
181
|
+
'<feMergeNode in="shadow"/>'
|
|
182
|
+
'<feMergeNode in="edgeHighlight"/>'
|
|
183
|
+
'<feMergeNode in="edgeShadow"/>'
|
|
184
|
+
'<feMergeNode in="SourceGraphic"/>'
|
|
185
|
+
"</feMerge>"
|
|
186
|
+
"</filter>"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Globe gradient and PoE bolt symbol
|
|
190
|
+
parts.append(_gradient(f"{gradient_prefix}globe", theme.wan_globe))
|
|
191
|
+
parts.append(
|
|
192
|
+
f'<symbol id="{filter_prefix}poe-bolt" viewBox="0 0 24 24">'
|
|
193
|
+
'<path fill-rule="evenodd" d="M14.615 1.595a.75.75 0 0 1 .359.852L12.982 9.75h7.268a.75.75 0 0 1 .548 1.262l-10.5 11.25a.75.75 0 0 1-1.272-.71l1.992-7.302H3.75a.75.75 0 0 1-.548-1.262l10.5-11.25a.75.75 0 0 1 .913-.143Z" clip-rule="evenodd"/>'
|
|
194
|
+
"</symbol>"
|
|
195
|
+
)
|
|
196
|
+
parts.append("</defs>")
|
|
197
|
+
|
|
198
|
+
return "".join(parts)
|
|
@@ -12,6 +12,18 @@ from .mermaid_theme import MermaidTheme
|
|
|
12
12
|
from .svg_theme import DEFAULT_THEME as DEFAULT_SVG_THEME
|
|
13
13
|
from .svg_theme import SvgTheme
|
|
14
14
|
|
|
15
|
+
# Built-in theme names mapped to YAML files in assets/themes/
|
|
16
|
+
BUILTIN_THEMES = {
|
|
17
|
+
"unifi": "unifi.yaml",
|
|
18
|
+
"unifi-dark": "unifi-dark.yaml",
|
|
19
|
+
"minimal": "minimal.yaml",
|
|
20
|
+
"minimal-dark": "minimal-dark.yaml",
|
|
21
|
+
"classic": "default.yaml",
|
|
22
|
+
"classic-dark": "dark.yaml",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
_ASSETS_DIR = Path(__file__).parent.parent / "assets" / "themes"
|
|
26
|
+
|
|
15
27
|
|
|
16
28
|
def _coerce_pair(value: object, default: tuple[str, str]) -> tuple[str, str]:
|
|
17
29
|
if isinstance(value, list | tuple) and len(value) == 2:
|
|
@@ -75,9 +87,39 @@ def _mermaid_theme_from_dict(data: dict, base: MermaidTheme) -> MermaidTheme:
|
|
|
75
87
|
)
|
|
76
88
|
|
|
77
89
|
|
|
90
|
+
def _coerce_vlan_colors(value: object) -> dict[int, str]:
|
|
91
|
+
"""Parse vlan_colors from theme YAML."""
|
|
92
|
+
if not isinstance(value, dict):
|
|
93
|
+
return {}
|
|
94
|
+
result: dict[int, str] = {}
|
|
95
|
+
for key, color in value.items():
|
|
96
|
+
if isinstance(color, str):
|
|
97
|
+
if isinstance(key, int):
|
|
98
|
+
result[key] = color
|
|
99
|
+
elif isinstance(key, str) and key.isdigit():
|
|
100
|
+
result[int(key)] = color
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _coerce_font_family(value: object, default: str | None) -> str | None:
|
|
105
|
+
"""Parse font_family from theme YAML."""
|
|
106
|
+
if isinstance(value, str) and value:
|
|
107
|
+
return value
|
|
108
|
+
return default
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _coerce_icon_set(value: object, default: str) -> str:
|
|
112
|
+
"""Parse icon_set from theme YAML."""
|
|
113
|
+
if isinstance(value, str) and value in ("isometric", "modern"):
|
|
114
|
+
return value
|
|
115
|
+
return default
|
|
116
|
+
|
|
117
|
+
|
|
78
118
|
def _svg_theme_from_dict(data: dict, base: SvgTheme) -> SvgTheme:
|
|
79
119
|
nodes = data.get("nodes", {}) if isinstance(data.get("nodes"), dict) else {}
|
|
80
120
|
links = data.get("links", {}) if isinstance(data.get("links"), dict) else {}
|
|
121
|
+
text = data.get("text", {}) if isinstance(data.get("text"), dict) else {}
|
|
122
|
+
status = data.get("status", {}) if isinstance(data.get("status"), dict) else {}
|
|
81
123
|
|
|
82
124
|
return SvgTheme(
|
|
83
125
|
link_standard=_coerce_pair(links.get("standard"), base.link_standard),
|
|
@@ -87,6 +129,31 @@ def _svg_theme_from_dict(data: dict, base: SvgTheme) -> SvgTheme:
|
|
|
87
129
|
node_ap=_coerce_pair(nodes.get("ap"), base.node_ap),
|
|
88
130
|
node_client=_coerce_pair(nodes.get("client"), base.node_client),
|
|
89
131
|
node_other=_coerce_pair(nodes.get("other"), base.node_other),
|
|
132
|
+
node_client_cluster=_coerce_pair(nodes.get("client_cluster"), base.node_client_cluster),
|
|
133
|
+
node_camera=_coerce_pair(nodes.get("camera"), base.node_camera),
|
|
134
|
+
node_tv=_coerce_pair(nodes.get("tv"), base.node_tv),
|
|
135
|
+
node_phone=_coerce_pair(nodes.get("phone"), base.node_phone),
|
|
136
|
+
node_printer=_coerce_pair(nodes.get("printer"), base.node_printer),
|
|
137
|
+
node_nas=_coerce_pair(nodes.get("nas"), base.node_nas),
|
|
138
|
+
node_speaker=_coerce_pair(nodes.get("speaker"), base.node_speaker),
|
|
139
|
+
node_game_console=_coerce_pair(nodes.get("game_console"), base.node_game_console),
|
|
140
|
+
node_iot=_coerce_pair(nodes.get("iot"), base.node_iot),
|
|
141
|
+
vlan_colors=_coerce_vlan_colors(data.get("vlan_colors")),
|
|
142
|
+
background=_coerce_color(data.get("background"), base.background),
|
|
143
|
+
text_primary=_coerce_color(text.get("primary"), base.text_primary),
|
|
144
|
+
text_secondary=_coerce_color(text.get("secondary"), base.text_secondary),
|
|
145
|
+
status_online=_coerce_color(status.get("online"), base.status_online),
|
|
146
|
+
status_offline=_coerce_color(status.get("offline"), base.status_offline),
|
|
147
|
+
wan_globe=_coerce_pair(data.get("wan_globe"), base.wan_globe),
|
|
148
|
+
wan_background=_coerce_color(data.get("wan_background"), base.wan_background),
|
|
149
|
+
poe_fill=_coerce_color(data.get("poe_fill"), base.poe_fill),
|
|
150
|
+
poe_stroke=_coerce_color(data.get("poe_stroke"), base.poe_stroke),
|
|
151
|
+
icon_set=_coerce_icon_set(data.get("icon_set"), base.icon_set),
|
|
152
|
+
font_family=_coerce_font_family(data.get("font_family"), base.font_family),
|
|
153
|
+
icon_decal=_coerce_color(data.get("icon_decal"), base.icon_decal),
|
|
154
|
+
grid_color=_coerce_color(data.get("grid_color"), base.grid_color),
|
|
155
|
+
node_side_left=_coerce_color(data.get("node_side_left"), base.node_side_left),
|
|
156
|
+
node_side_right=_coerce_color(data.get("node_side_right"), base.node_side_right),
|
|
90
157
|
)
|
|
91
158
|
|
|
92
159
|
|
|
@@ -104,7 +171,25 @@ def load_theme(path: str | Path) -> tuple[MermaidTheme, SvgTheme]:
|
|
|
104
171
|
return mermaid_theme, svg_theme
|
|
105
172
|
|
|
106
173
|
|
|
107
|
-
def resolve_themes(
|
|
174
|
+
def resolve_themes(
|
|
175
|
+
theme_name: str | None = None,
|
|
176
|
+
theme_file: str | Path | None = None,
|
|
177
|
+
) -> tuple[MermaidTheme, SvgTheme]:
|
|
178
|
+
"""Resolve theme from name or file path.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
theme_name: Built-in theme name (e.g., "unifi", "classic").
|
|
182
|
+
theme_file: Custom theme file path. Takes priority over theme_name.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Tuple of (MermaidTheme, SvgTheme).
|
|
186
|
+
"""
|
|
108
187
|
if theme_file:
|
|
109
188
|
return load_theme(theme_file)
|
|
189
|
+
if theme_name:
|
|
190
|
+
if theme_name not in BUILTIN_THEMES:
|
|
191
|
+
valid = ", ".join(sorted(BUILTIN_THEMES.keys()))
|
|
192
|
+
raise ValueError(f"Unknown theme: {theme_name}. Valid themes: {valid}")
|
|
193
|
+
builtin_path = _ASSETS_DIR / BUILTIN_THEMES[theme_name]
|
|
194
|
+
return load_theme(builtin_path)
|
|
110
195
|
return DEFAULT_MERMAID_THEME, DEFAULT_SVG_THEME
|