ui-cli 1.2.1__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.
- ui_cli/__init__.py +31 -0
- ui_cli/client.py +269 -0
- ui_cli/commands/__init__.py +1 -0
- ui_cli/commands/devices.py +187 -0
- ui_cli/commands/groups.py +503 -0
- ui_cli/commands/hosts.py +114 -0
- ui_cli/commands/isp.py +100 -0
- ui_cli/commands/local/__init__.py +63 -0
- ui_cli/commands/local/apgroups.py +445 -0
- ui_cli/commands/local/clients.py +1537 -0
- ui_cli/commands/local/config.py +758 -0
- ui_cli/commands/local/devices.py +570 -0
- ui_cli/commands/local/dpi.py +369 -0
- ui_cli/commands/local/events.py +289 -0
- ui_cli/commands/local/firewall.py +285 -0
- ui_cli/commands/local/health.py +195 -0
- ui_cli/commands/local/networks.py +426 -0
- ui_cli/commands/local/portfwd.py +153 -0
- ui_cli/commands/local/stats.py +234 -0
- ui_cli/commands/local/utils.py +85 -0
- ui_cli/commands/local/vouchers.py +410 -0
- ui_cli/commands/local/wan.py +302 -0
- ui_cli/commands/local/wlans.py +257 -0
- ui_cli/commands/mcp.py +416 -0
- ui_cli/commands/sdwan.py +168 -0
- ui_cli/commands/sites.py +65 -0
- ui_cli/commands/speedtest.py +192 -0
- ui_cli/commands/status.py +410 -0
- ui_cli/commands/version.py +13 -0
- ui_cli/config.py +106 -0
- ui_cli/groups.py +567 -0
- ui_cli/local_client.py +897 -0
- ui_cli/main.py +61 -0
- ui_cli/models.py +188 -0
- ui_cli/output.py +251 -0
- ui_cli-1.2.1.dist-info/METADATA +1315 -0
- ui_cli-1.2.1.dist-info/RECORD +46 -0
- ui_cli-1.2.1.dist-info/WHEEL +4 -0
- ui_cli-1.2.1.dist-info/entry_points.txt +3 -0
- ui_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
- ui_mcp/ARCHITECTURE.md +243 -0
- ui_mcp/README.md +235 -0
- ui_mcp/__init__.py +7 -0
- ui_mcp/__main__.py +10 -0
- ui_mcp/cli_runner.py +112 -0
- ui_mcp/server.py +468 -0
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""Firewall commands for local controller."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Annotated, Any
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ui_cli.local_client import LocalAPIError, UniFiLocalClient
|
|
9
|
+
from ui_cli.output import (
|
|
10
|
+
OutputFormat,
|
|
11
|
+
console,
|
|
12
|
+
output_csv,
|
|
13
|
+
output_json,
|
|
14
|
+
print_error,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(name="firewall", help="Firewall rules and groups", no_args_is_help=True)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Ruleset display names
|
|
21
|
+
RULESET_NAMES = {
|
|
22
|
+
"WAN_IN": "WAN In",
|
|
23
|
+
"WAN_OUT": "WAN Out",
|
|
24
|
+
"WAN_LOCAL": "WAN Local",
|
|
25
|
+
"LAN_IN": "LAN In",
|
|
26
|
+
"LAN_OUT": "LAN Out",
|
|
27
|
+
"LAN_LOCAL": "LAN Local",
|
|
28
|
+
"GUEST_IN": "Guest In",
|
|
29
|
+
"GUEST_OUT": "Guest Out",
|
|
30
|
+
"GUEST_LOCAL": "Guest Local",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def format_action(action: str) -> tuple[str, str]:
|
|
35
|
+
"""Format action with color."""
|
|
36
|
+
action_lower = action.lower()
|
|
37
|
+
if action_lower == "accept":
|
|
38
|
+
return "accept", "green"
|
|
39
|
+
elif action_lower == "drop":
|
|
40
|
+
return "drop", "red"
|
|
41
|
+
elif action_lower == "reject":
|
|
42
|
+
return "reject", "yellow"
|
|
43
|
+
return action, "white"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_protocol(rule: dict[str, Any]) -> str:
|
|
47
|
+
"""Format protocol for display."""
|
|
48
|
+
protocol = rule.get("protocol", "all")
|
|
49
|
+
if protocol == "all":
|
|
50
|
+
return "any"
|
|
51
|
+
return protocol.upper()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def format_address(rule: dict[str, Any], prefix: str) -> str:
|
|
55
|
+
"""Format source or destination address."""
|
|
56
|
+
# Check for network type
|
|
57
|
+
net_type = rule.get(f"{prefix}_network_type", "")
|
|
58
|
+
if net_type == "ADDRv4":
|
|
59
|
+
addr = rule.get(f"{prefix}_address", "")
|
|
60
|
+
return addr if addr else "any"
|
|
61
|
+
|
|
62
|
+
# Check for firewall group
|
|
63
|
+
group = rule.get(f"{prefix}_firewallgroup_ids", [])
|
|
64
|
+
if group:
|
|
65
|
+
return f"group:{len(group)}"
|
|
66
|
+
|
|
67
|
+
# Check for specific network
|
|
68
|
+
network = rule.get(f"{prefix}_network", "")
|
|
69
|
+
if network:
|
|
70
|
+
return network
|
|
71
|
+
|
|
72
|
+
return "any"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def format_port(rule: dict[str, Any], prefix: str) -> str:
|
|
76
|
+
"""Format port information."""
|
|
77
|
+
port = rule.get(f"{prefix}_port", "")
|
|
78
|
+
if port:
|
|
79
|
+
return str(port)
|
|
80
|
+
return "*"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_ruleset_order(ruleset: str) -> int:
|
|
84
|
+
"""Get sort order for rulesets."""
|
|
85
|
+
order = ["WAN_IN", "WAN_OUT", "WAN_LOCAL", "LAN_IN", "LAN_OUT", "LAN_LOCAL", "GUEST_IN", "GUEST_OUT", "GUEST_LOCAL"]
|
|
86
|
+
try:
|
|
87
|
+
return order.index(ruleset)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return 100
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.command("list")
|
|
93
|
+
def list_rules(
|
|
94
|
+
ruleset: Annotated[
|
|
95
|
+
str | None,
|
|
96
|
+
typer.Option("--ruleset", "-r", help="Filter by ruleset (e.g., WAN_IN, LAN_IN)"),
|
|
97
|
+
] = None,
|
|
98
|
+
output: Annotated[
|
|
99
|
+
OutputFormat,
|
|
100
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
101
|
+
] = OutputFormat.TABLE,
|
|
102
|
+
verbose: Annotated[
|
|
103
|
+
bool,
|
|
104
|
+
typer.Option("--verbose", "-v", help="Show additional details"),
|
|
105
|
+
] = False,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""List firewall rules."""
|
|
108
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
109
|
+
|
|
110
|
+
async def _list():
|
|
111
|
+
client = UniFiLocalClient()
|
|
112
|
+
return await client.get_firewall_rules()
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
rules = run_with_spinner(_list(), "Fetching firewall rules...")
|
|
116
|
+
except LocalAPIError as e:
|
|
117
|
+
print_error(str(e))
|
|
118
|
+
raise typer.Exit(1)
|
|
119
|
+
|
|
120
|
+
if not rules:
|
|
121
|
+
console.print("[dim]No firewall rules found[/dim]")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
# Filter by ruleset if specified
|
|
125
|
+
if ruleset:
|
|
126
|
+
ruleset_upper = ruleset.upper()
|
|
127
|
+
rules = [r for r in rules if r.get("ruleset", "").upper() == ruleset_upper]
|
|
128
|
+
if not rules:
|
|
129
|
+
console.print(f"[dim]No rules found for ruleset '{ruleset}'[/dim]")
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
# Sort by ruleset then by rule index
|
|
133
|
+
rules.sort(key=lambda r: (get_ruleset_order(r.get("ruleset", "")), r.get("rule_index", 0)))
|
|
134
|
+
|
|
135
|
+
if output == OutputFormat.JSON:
|
|
136
|
+
output_json(rules)
|
|
137
|
+
elif output == OutputFormat.CSV:
|
|
138
|
+
columns = [
|
|
139
|
+
("name", "Name"),
|
|
140
|
+
("ruleset", "Ruleset"),
|
|
141
|
+
("action", "Action"),
|
|
142
|
+
("protocol", "Protocol"),
|
|
143
|
+
("src_address", "Source"),
|
|
144
|
+
("dst_address", "Destination"),
|
|
145
|
+
("enabled", "Enabled"),
|
|
146
|
+
]
|
|
147
|
+
csv_data = []
|
|
148
|
+
for r in rules:
|
|
149
|
+
csv_data.append({
|
|
150
|
+
"name": r.get("name", ""),
|
|
151
|
+
"ruleset": r.get("ruleset", ""),
|
|
152
|
+
"action": r.get("action", ""),
|
|
153
|
+
"protocol": format_protocol(r),
|
|
154
|
+
"src_address": format_address(r, "src"),
|
|
155
|
+
"dst_address": format_address(r, "dst"),
|
|
156
|
+
"enabled": "Yes" if r.get("enabled", True) else "No",
|
|
157
|
+
})
|
|
158
|
+
output_csv(csv_data, columns)
|
|
159
|
+
else:
|
|
160
|
+
from rich.table import Table
|
|
161
|
+
|
|
162
|
+
table = Table(title="Firewall Rules", show_header=True, header_style="bold cyan")
|
|
163
|
+
table.add_column("Name")
|
|
164
|
+
table.add_column("Ruleset")
|
|
165
|
+
table.add_column("Action")
|
|
166
|
+
table.add_column("Protocol")
|
|
167
|
+
table.add_column("Source")
|
|
168
|
+
table.add_column("Destination")
|
|
169
|
+
if verbose:
|
|
170
|
+
table.add_column("Src Port")
|
|
171
|
+
table.add_column("Dst Port")
|
|
172
|
+
table.add_column("Enabled")
|
|
173
|
+
|
|
174
|
+
for r in rules:
|
|
175
|
+
name = r.get("name", "(unnamed)")
|
|
176
|
+
ruleset_name = RULESET_NAMES.get(r.get("ruleset", ""), r.get("ruleset", ""))
|
|
177
|
+
action, action_style = format_action(r.get("action", ""))
|
|
178
|
+
protocol = format_protocol(r)
|
|
179
|
+
src = format_address(r, "src")
|
|
180
|
+
dst = format_address(r, "dst")
|
|
181
|
+
enabled = "[green]✓[/green]" if r.get("enabled", True) else "[dim]✗[/dim]"
|
|
182
|
+
|
|
183
|
+
if verbose:
|
|
184
|
+
src_port = format_port(r, "src")
|
|
185
|
+
dst_port = format_port(r, "dst")
|
|
186
|
+
table.add_row(
|
|
187
|
+
name,
|
|
188
|
+
ruleset_name,
|
|
189
|
+
f"[{action_style}]{action}[/{action_style}]",
|
|
190
|
+
protocol,
|
|
191
|
+
src,
|
|
192
|
+
dst,
|
|
193
|
+
src_port,
|
|
194
|
+
dst_port,
|
|
195
|
+
enabled,
|
|
196
|
+
)
|
|
197
|
+
else:
|
|
198
|
+
table.add_row(
|
|
199
|
+
name,
|
|
200
|
+
ruleset_name,
|
|
201
|
+
f"[{action_style}]{action}[/{action_style}]",
|
|
202
|
+
protocol,
|
|
203
|
+
src,
|
|
204
|
+
dst,
|
|
205
|
+
enabled,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
console.print(table)
|
|
209
|
+
console.print(f"\n[dim]{len(rules)} rule(s)[/dim]")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@app.command("groups")
|
|
213
|
+
def list_groups(
|
|
214
|
+
output: Annotated[
|
|
215
|
+
OutputFormat,
|
|
216
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
217
|
+
] = OutputFormat.TABLE,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""List firewall groups (address and port groups)."""
|
|
220
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
221
|
+
|
|
222
|
+
async def _list():
|
|
223
|
+
client = UniFiLocalClient()
|
|
224
|
+
return await client.get_firewall_groups()
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
groups = run_with_spinner(_list(), "Fetching firewall groups...")
|
|
228
|
+
except LocalAPIError as e:
|
|
229
|
+
print_error(str(e))
|
|
230
|
+
raise typer.Exit(1)
|
|
231
|
+
|
|
232
|
+
if not groups:
|
|
233
|
+
console.print("[dim]No firewall groups found[/dim]")
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
# Sort by type then name
|
|
237
|
+
groups.sort(key=lambda g: (g.get("group_type", ""), g.get("name", "")))
|
|
238
|
+
|
|
239
|
+
if output == OutputFormat.JSON:
|
|
240
|
+
output_json(groups)
|
|
241
|
+
elif output == OutputFormat.CSV:
|
|
242
|
+
columns = [
|
|
243
|
+
("_id", "ID"),
|
|
244
|
+
("name", "Name"),
|
|
245
|
+
("group_type", "Type"),
|
|
246
|
+
("members", "Members"),
|
|
247
|
+
]
|
|
248
|
+
csv_data = []
|
|
249
|
+
for g in groups:
|
|
250
|
+
members = g.get("group_members", [])
|
|
251
|
+
csv_data.append({
|
|
252
|
+
"_id": g.get("_id", ""),
|
|
253
|
+
"name": g.get("name", ""),
|
|
254
|
+
"group_type": g.get("group_type", ""),
|
|
255
|
+
"members": ", ".join(members) if members else "",
|
|
256
|
+
})
|
|
257
|
+
output_csv(csv_data, columns)
|
|
258
|
+
else:
|
|
259
|
+
from rich.table import Table
|
|
260
|
+
|
|
261
|
+
table = Table(title="Firewall Groups", show_header=True, header_style="bold cyan")
|
|
262
|
+
table.add_column("ID", style="dim")
|
|
263
|
+
table.add_column("Name")
|
|
264
|
+
table.add_column("Type")
|
|
265
|
+
table.add_column("Members")
|
|
266
|
+
|
|
267
|
+
for g in groups:
|
|
268
|
+
group_id = g.get("_id", "")
|
|
269
|
+
name = g.get("name", "")
|
|
270
|
+
group_type = g.get("group_type", "")
|
|
271
|
+
|
|
272
|
+
# Format type for display
|
|
273
|
+
type_display = group_type.replace("-", " ").title()
|
|
274
|
+
|
|
275
|
+
# Format members
|
|
276
|
+
members = g.get("group_members", [])
|
|
277
|
+
if len(members) <= 3:
|
|
278
|
+
members_str = ", ".join(members) if members else "[dim]-[/dim]"
|
|
279
|
+
else:
|
|
280
|
+
members_str = f"{', '.join(members[:3])}... (+{len(members) - 3})"
|
|
281
|
+
|
|
282
|
+
table.add_row(group_id, name, type_display, members_str)
|
|
283
|
+
|
|
284
|
+
console.print(table)
|
|
285
|
+
console.print(f"\n[dim]{len(groups)} group(s)[/dim]")
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Site health command for local controller."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
8
|
+
from ui_cli.local_client import LocalAPIError, UniFiLocalClient
|
|
9
|
+
from ui_cli.output import OutputFormat, console, output_json, print_error
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(name="health", help="Site health status", invoke_without_command=True)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_status_indicator(status: str, score: int | None = None) -> tuple[str, str]:
|
|
15
|
+
"""Get status indicator and style based on status/score."""
|
|
16
|
+
status_lower = status.lower() if status else ""
|
|
17
|
+
|
|
18
|
+
if status_lower == "ok" or (score is not None and score >= 90):
|
|
19
|
+
return "🟢", "green"
|
|
20
|
+
elif status_lower in ("warning", "warn") or (score is not None and score >= 70):
|
|
21
|
+
return "🟡", "yellow"
|
|
22
|
+
elif status_lower in ("error", "critical", "unhealthy") or (score is not None and score < 70):
|
|
23
|
+
return "🔴", "red"
|
|
24
|
+
else:
|
|
25
|
+
return "⚪", "dim"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def format_subsystem_name(subsystem: str) -> str:
|
|
29
|
+
"""Format subsystem name for display."""
|
|
30
|
+
name_map = {
|
|
31
|
+
"www": "Internet",
|
|
32
|
+
"wan": "WAN",
|
|
33
|
+
"lan": "LAN",
|
|
34
|
+
"wlan": "WLAN",
|
|
35
|
+
"vpn": "VPN",
|
|
36
|
+
"speedtest": "Speed Test",
|
|
37
|
+
"dhcp": "DHCP",
|
|
38
|
+
"dns": "DNS",
|
|
39
|
+
}
|
|
40
|
+
return name_map.get(subsystem.lower(), subsystem.upper())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def extract_issues(health_data: list[dict[str, Any]]) -> list[str]:
|
|
44
|
+
"""Extract issues from health data."""
|
|
45
|
+
issues = []
|
|
46
|
+
|
|
47
|
+
for subsystem in health_data:
|
|
48
|
+
name = format_subsystem_name(subsystem.get("subsystem", ""))
|
|
49
|
+
status = subsystem.get("status", "").lower()
|
|
50
|
+
sub_name = subsystem.get("subsystem", "")
|
|
51
|
+
|
|
52
|
+
# Check for disconnected devices (common across subsystems)
|
|
53
|
+
num_disconnected = subsystem.get("num_disconnected", 0)
|
|
54
|
+
if num_disconnected > 0:
|
|
55
|
+
if sub_name == "lan":
|
|
56
|
+
issues.append(f"{name}: {num_disconnected} switch(es) disconnected")
|
|
57
|
+
elif sub_name == "wlan":
|
|
58
|
+
issues.append(f"{name}: {num_disconnected} AP(s) disconnected")
|
|
59
|
+
elif sub_name == "wan":
|
|
60
|
+
issues.append(f"{name}: {num_disconnected} gateway(s) disconnected")
|
|
61
|
+
else:
|
|
62
|
+
issues.append(f"{name}: {num_disconnected} device(s) disconnected")
|
|
63
|
+
|
|
64
|
+
# Check for pending devices
|
|
65
|
+
num_pending = subsystem.get("num_pending", 0)
|
|
66
|
+
if num_pending > 0:
|
|
67
|
+
issues.append(f"{name}: {num_pending} device(s) pending adoption")
|
|
68
|
+
|
|
69
|
+
# WLAN specific checks
|
|
70
|
+
if sub_name == "wlan":
|
|
71
|
+
num_disabled = subsystem.get("num_disabled", 0)
|
|
72
|
+
if num_disabled > 0:
|
|
73
|
+
issues.append(f"WLAN: {num_disabled} AP(s) disabled")
|
|
74
|
+
|
|
75
|
+
# WAN specific checks
|
|
76
|
+
if sub_name == "wan":
|
|
77
|
+
if subsystem.get("gw_wan_uptime", float("inf")) < 3600:
|
|
78
|
+
uptime = subsystem.get("gw_wan_uptime", 0)
|
|
79
|
+
issues.append(f"WAN: Connection recently restored ({uptime // 60} min ago)")
|
|
80
|
+
|
|
81
|
+
# LAN specific checks
|
|
82
|
+
if sub_name == "lan":
|
|
83
|
+
num_sw = subsystem.get("num_sw", 0)
|
|
84
|
+
num_adopted = subsystem.get("num_adopted", 0)
|
|
85
|
+
if num_sw > 0 and num_adopted < num_sw:
|
|
86
|
+
pending = num_sw - num_adopted
|
|
87
|
+
issues.append(f"LAN: {pending} switch(es) not adopted")
|
|
88
|
+
|
|
89
|
+
return issues
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@app.callback(invoke_without_command=True)
|
|
93
|
+
def health(
|
|
94
|
+
ctx: typer.Context,
|
|
95
|
+
output: Annotated[
|
|
96
|
+
OutputFormat,
|
|
97
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
98
|
+
] = OutputFormat.TABLE,
|
|
99
|
+
verbose: Annotated[
|
|
100
|
+
bool,
|
|
101
|
+
typer.Option("--verbose", "-v", help="Show additional details"),
|
|
102
|
+
] = False,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Show site health summary."""
|
|
105
|
+
if ctx.invoked_subcommand is not None:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
async def _health():
|
|
109
|
+
client = UniFiLocalClient()
|
|
110
|
+
return await client.get_health()
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
health_data = run_with_spinner(_health(), "Checking health...")
|
|
114
|
+
except LocalAPIError as e:
|
|
115
|
+
print_error(str(e))
|
|
116
|
+
raise typer.Exit(1)
|
|
117
|
+
|
|
118
|
+
if not health_data:
|
|
119
|
+
console.print("[dim]No health data available[/dim]")
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
if output == OutputFormat.JSON:
|
|
123
|
+
output_json(health_data)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
# Table output
|
|
127
|
+
from rich.table import Table
|
|
128
|
+
|
|
129
|
+
console.print()
|
|
130
|
+
console.print("[bold cyan]Site Health[/bold cyan]")
|
|
131
|
+
console.print("─" * 40)
|
|
132
|
+
console.print()
|
|
133
|
+
|
|
134
|
+
table = Table(show_header=True, header_style="bold", box=None)
|
|
135
|
+
table.add_column("Subsystem", style="cyan")
|
|
136
|
+
table.add_column("Status")
|
|
137
|
+
if verbose:
|
|
138
|
+
table.add_column("Details", style="dim")
|
|
139
|
+
|
|
140
|
+
overall_ok = True
|
|
141
|
+
overall_warning = False
|
|
142
|
+
|
|
143
|
+
for subsystem in health_data:
|
|
144
|
+
name = format_subsystem_name(subsystem.get("subsystem", "unknown"))
|
|
145
|
+
status = subsystem.get("status", "unknown")
|
|
146
|
+
indicator, style = get_status_indicator(status)
|
|
147
|
+
|
|
148
|
+
if status.lower() not in ("ok", ""):
|
|
149
|
+
if style == "red":
|
|
150
|
+
overall_ok = False
|
|
151
|
+
elif style == "yellow":
|
|
152
|
+
overall_warning = True
|
|
153
|
+
|
|
154
|
+
status_display = f"{indicator} [{style}]{status.upper()}[/{style}]"
|
|
155
|
+
|
|
156
|
+
if verbose:
|
|
157
|
+
# Build details string
|
|
158
|
+
details = []
|
|
159
|
+
if "num_user" in subsystem:
|
|
160
|
+
details.append(f"{subsystem['num_user']} users")
|
|
161
|
+
if "num_sta" in subsystem:
|
|
162
|
+
details.append(f"{subsystem['num_sta']} clients")
|
|
163
|
+
if "num_ap" in subsystem:
|
|
164
|
+
details.append(f"{subsystem['num_ap']} APs")
|
|
165
|
+
if "tx_bytes-r" in subsystem:
|
|
166
|
+
tx = subsystem.get("tx_bytes-r", 0) / 1024 / 1024
|
|
167
|
+
rx = subsystem.get("rx_bytes-r", 0) / 1024 / 1024
|
|
168
|
+
details.append(f"↑{tx:.1f} ↓{rx:.1f} MB/s")
|
|
169
|
+
if "latency" in subsystem:
|
|
170
|
+
details.append(f"{subsystem['latency']}ms latency")
|
|
171
|
+
|
|
172
|
+
table.add_row(name, status_display, ", ".join(details) if details else "-")
|
|
173
|
+
else:
|
|
174
|
+
table.add_row(name, status_display)
|
|
175
|
+
|
|
176
|
+
console.print(table)
|
|
177
|
+
|
|
178
|
+
# Overall status
|
|
179
|
+
console.print()
|
|
180
|
+
if overall_ok and not overall_warning:
|
|
181
|
+
console.print(" Overall: [green]🟢 Healthy[/green]")
|
|
182
|
+
elif overall_warning:
|
|
183
|
+
console.print(" Overall: [yellow]🟡 Warning[/yellow]")
|
|
184
|
+
else:
|
|
185
|
+
console.print(" Overall: [red]🔴 Issues Detected[/red]")
|
|
186
|
+
|
|
187
|
+
# Show issues if any
|
|
188
|
+
issues = extract_issues(health_data)
|
|
189
|
+
if issues:
|
|
190
|
+
console.print()
|
|
191
|
+
console.print(" [bold]Notes:[/bold]")
|
|
192
|
+
for issue in issues:
|
|
193
|
+
console.print(f" • {issue}")
|
|
194
|
+
|
|
195
|
+
console.print()
|