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,369 @@
|
|
|
1
|
+
"""DPI (Deep Packet Inspection) 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
|
+
print_warning,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(name="dpi", help="Deep Packet Inspection statistics", no_args_is_help=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
async def check_dpi_enabled(client: UniFiLocalClient) -> bool:
|
|
22
|
+
"""Check if DPI is enabled in site settings."""
|
|
23
|
+
try:
|
|
24
|
+
settings = await client.get_site_settings()
|
|
25
|
+
for setting in settings:
|
|
26
|
+
if setting.get("key") == "dpi":
|
|
27
|
+
return setting.get("dpi_enabled", False)
|
|
28
|
+
return False
|
|
29
|
+
except LocalAPIError:
|
|
30
|
+
# Can't determine, assume it might be enabled
|
|
31
|
+
return True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# DPI category mapping (UniFi uses numeric codes)
|
|
35
|
+
DPI_CATEGORIES = {
|
|
36
|
+
0: "Instant Messaging",
|
|
37
|
+
1: "P2P",
|
|
38
|
+
2: "File Transfer",
|
|
39
|
+
3: "Streaming Media",
|
|
40
|
+
4: "Mail & Collaboration",
|
|
41
|
+
5: "VoIP",
|
|
42
|
+
6: "Database",
|
|
43
|
+
7: "Games",
|
|
44
|
+
8: "Network Management",
|
|
45
|
+
9: "Remote Access",
|
|
46
|
+
10: "Bypass Proxies",
|
|
47
|
+
11: "Stock Market",
|
|
48
|
+
12: "Web",
|
|
49
|
+
13: "Security Update",
|
|
50
|
+
14: "E-Commerce",
|
|
51
|
+
15: "Social Network",
|
|
52
|
+
16: "News",
|
|
53
|
+
18: "Business",
|
|
54
|
+
19: "Network Protocol",
|
|
55
|
+
20: "VPN & Tunneling",
|
|
56
|
+
21: "IoT",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
# Common application names
|
|
60
|
+
DPI_APPS = {
|
|
61
|
+
# Streaming
|
|
62
|
+
"youtube": "YouTube",
|
|
63
|
+
"netflix": "Netflix",
|
|
64
|
+
"amazonvideo": "Amazon Video",
|
|
65
|
+
"hulu": "Hulu",
|
|
66
|
+
"twitch": "Twitch",
|
|
67
|
+
"spotify": "Spotify",
|
|
68
|
+
"appletv": "Apple TV+",
|
|
69
|
+
"disneyplus": "Disney+",
|
|
70
|
+
# Social
|
|
71
|
+
"facebook": "Facebook",
|
|
72
|
+
"instagram": "Instagram",
|
|
73
|
+
"twitter": "Twitter/X",
|
|
74
|
+
"tiktok": "TikTok",
|
|
75
|
+
"snapchat": "Snapchat",
|
|
76
|
+
"whatsapp": "WhatsApp",
|
|
77
|
+
"discord": "Discord",
|
|
78
|
+
"telegram": "Telegram",
|
|
79
|
+
# Productivity
|
|
80
|
+
"microsoft": "Microsoft",
|
|
81
|
+
"office365": "Office 365",
|
|
82
|
+
"teams": "MS Teams",
|
|
83
|
+
"zoom": "Zoom",
|
|
84
|
+
"slack": "Slack",
|
|
85
|
+
"dropbox": "Dropbox",
|
|
86
|
+
"gdrive": "Google Drive",
|
|
87
|
+
# Cloud
|
|
88
|
+
"aws": "AWS",
|
|
89
|
+
"azure": "Azure",
|
|
90
|
+
"googlecloud": "Google Cloud",
|
|
91
|
+
"icloud": "iCloud",
|
|
92
|
+
# Other
|
|
93
|
+
"apple": "Apple",
|
|
94
|
+
"google": "Google",
|
|
95
|
+
"amazon": "Amazon",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def format_bytes(bytes_val: int | float | None) -> str:
|
|
100
|
+
"""Format bytes to human-readable form."""
|
|
101
|
+
if not bytes_val or bytes_val == 0:
|
|
102
|
+
return "0 B"
|
|
103
|
+
|
|
104
|
+
units = ["B", "KB", "MB", "GB", "TB"]
|
|
105
|
+
unit_index = 0
|
|
106
|
+
value = float(bytes_val)
|
|
107
|
+
|
|
108
|
+
while value >= 1024 and unit_index < len(units) - 1:
|
|
109
|
+
value /= 1024
|
|
110
|
+
unit_index += 1
|
|
111
|
+
|
|
112
|
+
if unit_index == 0:
|
|
113
|
+
return f"{int(value)} {units[unit_index]}"
|
|
114
|
+
return f"{value:.1f} {units[unit_index]}"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_category_name(cat_id: int) -> str:
|
|
118
|
+
"""Get category name from ID."""
|
|
119
|
+
return DPI_CATEGORIES.get(cat_id, f"Category {cat_id}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def get_app_name(app_key: str) -> str:
|
|
123
|
+
"""Get friendly app name."""
|
|
124
|
+
# Try direct lookup
|
|
125
|
+
key_lower = app_key.lower()
|
|
126
|
+
if key_lower in DPI_APPS:
|
|
127
|
+
return DPI_APPS[key_lower]
|
|
128
|
+
|
|
129
|
+
# Check partial matches
|
|
130
|
+
for k, v in DPI_APPS.items():
|
|
131
|
+
if k in key_lower:
|
|
132
|
+
return v
|
|
133
|
+
|
|
134
|
+
# Fallback: title case the key
|
|
135
|
+
return app_key.replace("_", " ").title()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def aggregate_dpi_data(dpi_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
139
|
+
"""Aggregate DPI data by category or app."""
|
|
140
|
+
aggregated = {}
|
|
141
|
+
|
|
142
|
+
for item in dpi_data:
|
|
143
|
+
# Try to get app name or category
|
|
144
|
+
app = item.get("app")
|
|
145
|
+
cat = item.get("cat")
|
|
146
|
+
|
|
147
|
+
if app:
|
|
148
|
+
key = f"app_{app}"
|
|
149
|
+
name = get_app_name(str(app))
|
|
150
|
+
elif cat is not None:
|
|
151
|
+
key = f"cat_{cat}"
|
|
152
|
+
name = get_category_name(cat)
|
|
153
|
+
else:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
if key not in aggregated:
|
|
157
|
+
aggregated[key] = {
|
|
158
|
+
"name": name,
|
|
159
|
+
"rx_bytes": 0,
|
|
160
|
+
"tx_bytes": 0,
|
|
161
|
+
"clients": set(),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
aggregated[key]["rx_bytes"] += item.get("rx_bytes", 0)
|
|
165
|
+
aggregated[key]["tx_bytes"] += item.get("tx_bytes", 0)
|
|
166
|
+
|
|
167
|
+
# Track unique clients if available
|
|
168
|
+
mac = item.get("mac")
|
|
169
|
+
if mac:
|
|
170
|
+
aggregated[key]["clients"].add(mac)
|
|
171
|
+
|
|
172
|
+
# Convert to list and sort by total bytes
|
|
173
|
+
result = []
|
|
174
|
+
for key, data in aggregated.items():
|
|
175
|
+
result.append({
|
|
176
|
+
"name": data["name"],
|
|
177
|
+
"rx_bytes": data["rx_bytes"],
|
|
178
|
+
"tx_bytes": data["tx_bytes"],
|
|
179
|
+
"total_bytes": data["rx_bytes"] + data["tx_bytes"],
|
|
180
|
+
"client_count": len(data["clients"]),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
result.sort(key=lambda x: x["total_bytes"], reverse=True)
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command("site")
|
|
188
|
+
def site_dpi(
|
|
189
|
+
output: Annotated[
|
|
190
|
+
OutputFormat,
|
|
191
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
192
|
+
] = OutputFormat.TABLE,
|
|
193
|
+
limit: Annotated[
|
|
194
|
+
int,
|
|
195
|
+
typer.Option("--limit", "-l", help="Limit number of results"),
|
|
196
|
+
] = 20,
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Show site-level DPI statistics."""
|
|
199
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
200
|
+
|
|
201
|
+
async def _dpi():
|
|
202
|
+
client = UniFiLocalClient()
|
|
203
|
+
dpi_enabled = await check_dpi_enabled(client)
|
|
204
|
+
dpi_data = await client.get_site_dpi()
|
|
205
|
+
return dpi_data, dpi_enabled
|
|
206
|
+
|
|
207
|
+
try:
|
|
208
|
+
dpi_data, dpi_enabled = run_with_spinner(_dpi(), "Fetching DPI stats...")
|
|
209
|
+
except LocalAPIError as e:
|
|
210
|
+
print_error(str(e))
|
|
211
|
+
raise typer.Exit(1)
|
|
212
|
+
|
|
213
|
+
# Aggregate the data
|
|
214
|
+
aggregated = aggregate_dpi_data(dpi_data)[:limit] if dpi_data else []
|
|
215
|
+
|
|
216
|
+
if not aggregated:
|
|
217
|
+
if not dpi_enabled:
|
|
218
|
+
print_warning("DPI is not enabled on this controller")
|
|
219
|
+
console.print("[dim]Enable Traffic Identification in Network Settings to use DPI.[/dim]")
|
|
220
|
+
else:
|
|
221
|
+
console.print("[dim]No DPI data collected yet.[/dim]")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
if output == OutputFormat.JSON:
|
|
225
|
+
output_json(aggregated)
|
|
226
|
+
elif output == OutputFormat.CSV:
|
|
227
|
+
columns = [
|
|
228
|
+
("name", "Application"),
|
|
229
|
+
("rx_bytes", "Download (bytes)"),
|
|
230
|
+
("tx_bytes", "Upload (bytes)"),
|
|
231
|
+
("client_count", "Clients"),
|
|
232
|
+
]
|
|
233
|
+
output_csv(aggregated, columns)
|
|
234
|
+
else:
|
|
235
|
+
from rich.table import Table
|
|
236
|
+
|
|
237
|
+
table = Table(title="Site DPI Statistics", show_header=True, header_style="bold cyan")
|
|
238
|
+
table.add_column("Application")
|
|
239
|
+
table.add_column("Download", justify="right")
|
|
240
|
+
table.add_column("Upload", justify="right")
|
|
241
|
+
table.add_column("Clients", justify="right")
|
|
242
|
+
|
|
243
|
+
total_rx = 0
|
|
244
|
+
total_tx = 0
|
|
245
|
+
|
|
246
|
+
for item in aggregated:
|
|
247
|
+
rx = item["rx_bytes"]
|
|
248
|
+
tx = item["tx_bytes"]
|
|
249
|
+
total_rx += rx
|
|
250
|
+
total_tx += tx
|
|
251
|
+
|
|
252
|
+
table.add_row(
|
|
253
|
+
item["name"],
|
|
254
|
+
format_bytes(rx),
|
|
255
|
+
format_bytes(tx),
|
|
256
|
+
str(item["client_count"]) if item["client_count"] > 0 else "-",
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
console.print(table)
|
|
260
|
+
console.print()
|
|
261
|
+
console.print(f"[dim]Total: {format_bytes(total_rx)} down, {format_bytes(total_tx)} up[/dim]")
|
|
262
|
+
console.print()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@app.command("client")
|
|
266
|
+
def client_dpi(
|
|
267
|
+
identifier: Annotated[str, typer.Argument(help="Client MAC address or name")],
|
|
268
|
+
output: Annotated[
|
|
269
|
+
OutputFormat,
|
|
270
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
271
|
+
] = OutputFormat.TABLE,
|
|
272
|
+
limit: Annotated[
|
|
273
|
+
int,
|
|
274
|
+
typer.Option("--limit", "-l", help="Limit number of results"),
|
|
275
|
+
] = 15,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Show DPI statistics for a specific client."""
|
|
278
|
+
|
|
279
|
+
async def _dpi():
|
|
280
|
+
client = UniFiLocalClient()
|
|
281
|
+
dpi_enabled = await check_dpi_enabled(client)
|
|
282
|
+
|
|
283
|
+
# If not a MAC address, try to find by name
|
|
284
|
+
mac = identifier
|
|
285
|
+
if ":" not in identifier and "-" not in identifier:
|
|
286
|
+
# Search for client by name
|
|
287
|
+
clients = await client.list_clients()
|
|
288
|
+
all_clients = await client.list_all_clients()
|
|
289
|
+
all_clients.extend(clients)
|
|
290
|
+
|
|
291
|
+
found = None
|
|
292
|
+
for c in all_clients:
|
|
293
|
+
name = c.get("name", c.get("hostname", ""))
|
|
294
|
+
if name.lower() == identifier.lower():
|
|
295
|
+
found = c
|
|
296
|
+
break
|
|
297
|
+
if identifier.lower() in name.lower():
|
|
298
|
+
found = c
|
|
299
|
+
|
|
300
|
+
if found:
|
|
301
|
+
mac = found.get("mac", identifier)
|
|
302
|
+
else:
|
|
303
|
+
return None, identifier, dpi_enabled
|
|
304
|
+
|
|
305
|
+
dpi_data = await client.get_client_dpi(mac)
|
|
306
|
+
return dpi_data, mac, dpi_enabled
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
dpi_data, mac, dpi_enabled = run_with_spinner(_dpi(), "Fetching client DPI...")
|
|
310
|
+
except LocalAPIError as e:
|
|
311
|
+
print_error(str(e))
|
|
312
|
+
raise typer.Exit(1)
|
|
313
|
+
|
|
314
|
+
if dpi_data is None:
|
|
315
|
+
print_error(f"Client '{identifier}' not found")
|
|
316
|
+
raise typer.Exit(1)
|
|
317
|
+
|
|
318
|
+
# Aggregate the data
|
|
319
|
+
aggregated = aggregate_dpi_data(dpi_data)[:limit] if dpi_data else []
|
|
320
|
+
|
|
321
|
+
if not aggregated:
|
|
322
|
+
if not dpi_enabled:
|
|
323
|
+
print_warning("DPI is not enabled on this controller")
|
|
324
|
+
console.print("[dim]Enable Traffic Identification in Network Settings to use DPI.[/dim]")
|
|
325
|
+
else:
|
|
326
|
+
console.print(f"[dim]No DPI data collected for {mac}.[/dim]")
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
if output == OutputFormat.JSON:
|
|
330
|
+
output_json(aggregated)
|
|
331
|
+
elif output == OutputFormat.CSV:
|
|
332
|
+
columns = [
|
|
333
|
+
("name", "Application"),
|
|
334
|
+
("rx_bytes", "Download (bytes)"),
|
|
335
|
+
("tx_bytes", "Upload (bytes)"),
|
|
336
|
+
]
|
|
337
|
+
output_csv(aggregated, columns)
|
|
338
|
+
else:
|
|
339
|
+
from rich.table import Table
|
|
340
|
+
|
|
341
|
+
console.print()
|
|
342
|
+
console.print(f"[bold cyan]DPI Statistics: {mac}[/bold cyan]")
|
|
343
|
+
console.print("─" * 40)
|
|
344
|
+
console.print()
|
|
345
|
+
|
|
346
|
+
table = Table(show_header=True, header_style="bold")
|
|
347
|
+
table.add_column("Application")
|
|
348
|
+
table.add_column("Download", justify="right")
|
|
349
|
+
table.add_column("Upload", justify="right")
|
|
350
|
+
|
|
351
|
+
total_rx = 0
|
|
352
|
+
total_tx = 0
|
|
353
|
+
|
|
354
|
+
for item in aggregated:
|
|
355
|
+
rx = item["rx_bytes"]
|
|
356
|
+
tx = item["tx_bytes"]
|
|
357
|
+
total_rx += rx
|
|
358
|
+
total_tx += tx
|
|
359
|
+
|
|
360
|
+
table.add_row(
|
|
361
|
+
item["name"],
|
|
362
|
+
format_bytes(rx),
|
|
363
|
+
format_bytes(tx),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
console.print(table)
|
|
367
|
+
console.print()
|
|
368
|
+
console.print(f"[dim]Total: {format_bytes(total_rx)} down, {format_bytes(total_tx)} up[/dim]")
|
|
369
|
+
console.print()
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Events and alarms commands for local controller."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import Annotated, Any
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ui_cli.local_client import LocalAPIError, UniFiLocalClient
|
|
10
|
+
from ui_cli.output import (
|
|
11
|
+
OutputFormat,
|
|
12
|
+
console,
|
|
13
|
+
output_csv,
|
|
14
|
+
output_json,
|
|
15
|
+
output_table,
|
|
16
|
+
print_error,
|
|
17
|
+
print_success,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(name="events", help="Events and alarms management", no_args_is_help=True)
|
|
21
|
+
alarms_app = typer.Typer(name="alarms", help="Alarm management", no_args_is_help=True)
|
|
22
|
+
app.add_typer(alarms_app, name="alarms")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def format_timestamp(ts: int | None) -> str:
|
|
26
|
+
"""Format Unix timestamp to readable string."""
|
|
27
|
+
if not ts:
|
|
28
|
+
return ""
|
|
29
|
+
try:
|
|
30
|
+
dt = datetime.fromtimestamp(ts / 1000, tz=timezone.utc)
|
|
31
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
32
|
+
except (ValueError, OSError):
|
|
33
|
+
return str(ts)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_event_message(event: dict[str, Any]) -> str:
|
|
37
|
+
"""Format event into human-readable message."""
|
|
38
|
+
key = event.get("key", "")
|
|
39
|
+
msg = event.get("msg", "")
|
|
40
|
+
|
|
41
|
+
# If there's a direct message, use it
|
|
42
|
+
if msg:
|
|
43
|
+
return msg
|
|
44
|
+
|
|
45
|
+
# Build message from event data
|
|
46
|
+
parts = []
|
|
47
|
+
|
|
48
|
+
# Client events
|
|
49
|
+
if "user" in event or "client" in event:
|
|
50
|
+
client_name = event.get("user", event.get("client", ""))
|
|
51
|
+
if client_name:
|
|
52
|
+
parts.append(client_name)
|
|
53
|
+
|
|
54
|
+
# Network/SSID
|
|
55
|
+
if "ssid" in event:
|
|
56
|
+
parts.append(f"on {event['ssid']}")
|
|
57
|
+
|
|
58
|
+
# AP name
|
|
59
|
+
if "ap_name" in event:
|
|
60
|
+
parts.append(f"via {event['ap_name']}")
|
|
61
|
+
|
|
62
|
+
# Device events
|
|
63
|
+
if "sw_name" in event:
|
|
64
|
+
parts.append(event["sw_name"])
|
|
65
|
+
elif "gw_name" in event:
|
|
66
|
+
parts.append(event["gw_name"])
|
|
67
|
+
|
|
68
|
+
if parts:
|
|
69
|
+
return " ".join(parts)
|
|
70
|
+
|
|
71
|
+
# Fallback to key
|
|
72
|
+
return key.replace("EVT_", "").replace("_", " ").title()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_event_type(event: dict[str, Any]) -> str:
|
|
76
|
+
"""Extract event type from event key."""
|
|
77
|
+
key = event.get("key", "unknown")
|
|
78
|
+
# Remove EVT_ prefix and clean up
|
|
79
|
+
if key.startswith("EVT_"):
|
|
80
|
+
key = key[4:]
|
|
81
|
+
return key.lower()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_alarm_severity(alarm: dict[str, Any]) -> tuple[str, str]:
|
|
85
|
+
"""Get severity display and style for alarm."""
|
|
86
|
+
# Check for severity hints in the alarm
|
|
87
|
+
key = alarm.get("key", "").lower()
|
|
88
|
+
|
|
89
|
+
if any(x in key for x in ["critical", "disconnect", "lost", "down", "offline"]):
|
|
90
|
+
return "critical", "red"
|
|
91
|
+
elif any(x in key for x in ["warning", "rogue", "radar", "high"]):
|
|
92
|
+
return "warning", "yellow"
|
|
93
|
+
else:
|
|
94
|
+
return "info", "cyan"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ========== Events Commands ==========
|
|
98
|
+
|
|
99
|
+
@app.command("list")
|
|
100
|
+
def list_events(
|
|
101
|
+
limit: Annotated[
|
|
102
|
+
int,
|
|
103
|
+
typer.Option("--limit", "-l", help="Number of events to retrieve"),
|
|
104
|
+
] = 50,
|
|
105
|
+
event_type: Annotated[
|
|
106
|
+
str | None,
|
|
107
|
+
typer.Option("--type", "-t", help="Filter by event type"),
|
|
108
|
+
] = None,
|
|
109
|
+
output: Annotated[
|
|
110
|
+
OutputFormat,
|
|
111
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
112
|
+
] = OutputFormat.TABLE,
|
|
113
|
+
) -> None:
|
|
114
|
+
"""List recent events."""
|
|
115
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
116
|
+
|
|
117
|
+
async def _list():
|
|
118
|
+
client = UniFiLocalClient()
|
|
119
|
+
return await client.get_events(limit=limit)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
events = run_with_spinner(_list(), "Fetching events...")
|
|
123
|
+
except LocalAPIError as e:
|
|
124
|
+
print_error(str(e))
|
|
125
|
+
raise typer.Exit(1)
|
|
126
|
+
|
|
127
|
+
if not events:
|
|
128
|
+
console.print("[dim]No events found[/dim]")
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
# Filter by type if specified
|
|
132
|
+
if event_type:
|
|
133
|
+
event_type_lower = event_type.lower()
|
|
134
|
+
events = [e for e in events if event_type_lower in get_event_type(e)]
|
|
135
|
+
|
|
136
|
+
if output == OutputFormat.JSON:
|
|
137
|
+
output_json(events)
|
|
138
|
+
elif output == OutputFormat.CSV:
|
|
139
|
+
columns = [
|
|
140
|
+
("time", "Time"),
|
|
141
|
+
("key", "Type"),
|
|
142
|
+
("msg", "Message"),
|
|
143
|
+
]
|
|
144
|
+
# Transform for CSV
|
|
145
|
+
csv_data = []
|
|
146
|
+
for e in events:
|
|
147
|
+
csv_data.append({
|
|
148
|
+
"time": format_timestamp(e.get("time")),
|
|
149
|
+
"key": get_event_type(e),
|
|
150
|
+
"msg": format_event_message(e),
|
|
151
|
+
})
|
|
152
|
+
output_csv(csv_data, columns)
|
|
153
|
+
else:
|
|
154
|
+
from rich.table import Table
|
|
155
|
+
|
|
156
|
+
table = Table(title="Recent Events", show_header=True, header_style="bold cyan")
|
|
157
|
+
table.add_column("Time", style="dim")
|
|
158
|
+
table.add_column("Type")
|
|
159
|
+
table.add_column("Message")
|
|
160
|
+
|
|
161
|
+
for event in events:
|
|
162
|
+
time_str = format_timestamp(event.get("time"))
|
|
163
|
+
evt_type = get_event_type(event)
|
|
164
|
+
message = format_event_message(event)
|
|
165
|
+
table.add_row(time_str, evt_type, message)
|
|
166
|
+
|
|
167
|
+
console.print(table)
|
|
168
|
+
console.print(f"\n[dim]{len(events)} event(s)[/dim]")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ========== Alarms Commands ==========
|
|
172
|
+
|
|
173
|
+
@alarms_app.command("list")
|
|
174
|
+
def list_alarms(
|
|
175
|
+
include_archived: Annotated[
|
|
176
|
+
bool,
|
|
177
|
+
typer.Option("--all", "-a", help="Include archived alarms"),
|
|
178
|
+
] = False,
|
|
179
|
+
output: Annotated[
|
|
180
|
+
OutputFormat,
|
|
181
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
182
|
+
] = OutputFormat.TABLE,
|
|
183
|
+
) -> None:
|
|
184
|
+
"""List alarms."""
|
|
185
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
186
|
+
|
|
187
|
+
async def _list():
|
|
188
|
+
client = UniFiLocalClient()
|
|
189
|
+
return await client.get_alarms(archived=include_archived)
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
alarms = run_with_spinner(_list(), "Fetching alarms...")
|
|
193
|
+
except LocalAPIError as e:
|
|
194
|
+
print_error(str(e))
|
|
195
|
+
raise typer.Exit(1)
|
|
196
|
+
|
|
197
|
+
if not alarms:
|
|
198
|
+
console.print("[green]No active alarms[/green]")
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
if output == OutputFormat.JSON:
|
|
202
|
+
output_json(alarms)
|
|
203
|
+
elif output == OutputFormat.CSV:
|
|
204
|
+
columns = [
|
|
205
|
+
("_id", "ID"),
|
|
206
|
+
("time", "Time"),
|
|
207
|
+
("key", "Type"),
|
|
208
|
+
("msg", "Message"),
|
|
209
|
+
("archived", "Archived"),
|
|
210
|
+
]
|
|
211
|
+
csv_data = []
|
|
212
|
+
for a in alarms:
|
|
213
|
+
csv_data.append({
|
|
214
|
+
"_id": a.get("_id", ""),
|
|
215
|
+
"time": format_timestamp(a.get("time")),
|
|
216
|
+
"key": get_event_type(a),
|
|
217
|
+
"msg": format_event_message(a),
|
|
218
|
+
"archived": "Yes" if a.get("archived") else "No",
|
|
219
|
+
})
|
|
220
|
+
output_csv(csv_data, columns)
|
|
221
|
+
else:
|
|
222
|
+
from rich.table import Table
|
|
223
|
+
|
|
224
|
+
title = "All Alarms" if include_archived else "Active Alarms"
|
|
225
|
+
table = Table(title=title, show_header=True, header_style="bold cyan")
|
|
226
|
+
table.add_column("ID", style="dim")
|
|
227
|
+
table.add_column("Time", style="dim")
|
|
228
|
+
table.add_column("Severity")
|
|
229
|
+
table.add_column("Message")
|
|
230
|
+
if include_archived:
|
|
231
|
+
table.add_column("Status")
|
|
232
|
+
|
|
233
|
+
for alarm in alarms:
|
|
234
|
+
alarm_id = alarm.get("_id", "")
|
|
235
|
+
time_str = format_timestamp(alarm.get("time"))
|
|
236
|
+
severity, style = get_alarm_severity(alarm)
|
|
237
|
+
message = format_event_message(alarm)
|
|
238
|
+
|
|
239
|
+
severity_display = f"[{style}]{severity}[/{style}]"
|
|
240
|
+
|
|
241
|
+
if include_archived:
|
|
242
|
+
status = "[dim]archived[/dim]" if alarm.get("archived") else "[green]active[/green]"
|
|
243
|
+
table.add_row(alarm_id, time_str, severity_display, message, status)
|
|
244
|
+
else:
|
|
245
|
+
table.add_row(alarm_id, time_str, severity_display, message)
|
|
246
|
+
|
|
247
|
+
console.print(table)
|
|
248
|
+
|
|
249
|
+
active_count = sum(1 for a in alarms if not a.get("archived"))
|
|
250
|
+
if include_archived:
|
|
251
|
+
archived_count = sum(1 for a in alarms if a.get("archived"))
|
|
252
|
+
console.print(f"\n[dim]{active_count} active, {archived_count} archived[/dim]")
|
|
253
|
+
else:
|
|
254
|
+
console.print(f"\n[dim]{active_count} active alarm(s)[/dim]")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@alarms_app.command("archive")
|
|
258
|
+
def archive_alarm(
|
|
259
|
+
alarm_id: Annotated[str, typer.Argument(help="Alarm ID to archive")],
|
|
260
|
+
yes: Annotated[
|
|
261
|
+
bool,
|
|
262
|
+
typer.Option("--yes", "-y", help="Skip confirmation"),
|
|
263
|
+
] = False,
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Archive an alarm."""
|
|
266
|
+
|
|
267
|
+
if not yes:
|
|
268
|
+
confirm = typer.confirm(f"Archive alarm {alarm_id}?")
|
|
269
|
+
if not confirm:
|
|
270
|
+
console.print("[dim]Cancelled[/dim]")
|
|
271
|
+
raise typer.Exit(0)
|
|
272
|
+
|
|
273
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
274
|
+
|
|
275
|
+
async def _archive():
|
|
276
|
+
client = UniFiLocalClient()
|
|
277
|
+
return await client.archive_alarm(alarm_id)
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
success = run_with_spinner(_archive(), "Archiving alarm...")
|
|
281
|
+
except LocalAPIError as e:
|
|
282
|
+
print_error(str(e))
|
|
283
|
+
raise typer.Exit(1)
|
|
284
|
+
|
|
285
|
+
if success:
|
|
286
|
+
print_success(f"Alarm {alarm_id} archived")
|
|
287
|
+
else:
|
|
288
|
+
print_error(f"Failed to archive alarm {alarm_id}")
|
|
289
|
+
raise typer.Exit(1)
|