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.
Files changed (46) hide show
  1. ui_cli/__init__.py +31 -0
  2. ui_cli/client.py +269 -0
  3. ui_cli/commands/__init__.py +1 -0
  4. ui_cli/commands/devices.py +187 -0
  5. ui_cli/commands/groups.py +503 -0
  6. ui_cli/commands/hosts.py +114 -0
  7. ui_cli/commands/isp.py +100 -0
  8. ui_cli/commands/local/__init__.py +63 -0
  9. ui_cli/commands/local/apgroups.py +445 -0
  10. ui_cli/commands/local/clients.py +1537 -0
  11. ui_cli/commands/local/config.py +758 -0
  12. ui_cli/commands/local/devices.py +570 -0
  13. ui_cli/commands/local/dpi.py +369 -0
  14. ui_cli/commands/local/events.py +289 -0
  15. ui_cli/commands/local/firewall.py +285 -0
  16. ui_cli/commands/local/health.py +195 -0
  17. ui_cli/commands/local/networks.py +426 -0
  18. ui_cli/commands/local/portfwd.py +153 -0
  19. ui_cli/commands/local/stats.py +234 -0
  20. ui_cli/commands/local/utils.py +85 -0
  21. ui_cli/commands/local/vouchers.py +410 -0
  22. ui_cli/commands/local/wan.py +302 -0
  23. ui_cli/commands/local/wlans.py +257 -0
  24. ui_cli/commands/mcp.py +416 -0
  25. ui_cli/commands/sdwan.py +168 -0
  26. ui_cli/commands/sites.py +65 -0
  27. ui_cli/commands/speedtest.py +192 -0
  28. ui_cli/commands/status.py +410 -0
  29. ui_cli/commands/version.py +13 -0
  30. ui_cli/config.py +106 -0
  31. ui_cli/groups.py +567 -0
  32. ui_cli/local_client.py +897 -0
  33. ui_cli/main.py +61 -0
  34. ui_cli/models.py +188 -0
  35. ui_cli/output.py +251 -0
  36. ui_cli-1.2.1.dist-info/METADATA +1315 -0
  37. ui_cli-1.2.1.dist-info/RECORD +46 -0
  38. ui_cli-1.2.1.dist-info/WHEEL +4 -0
  39. ui_cli-1.2.1.dist-info/entry_points.txt +3 -0
  40. ui_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
  41. ui_mcp/ARCHITECTURE.md +243 -0
  42. ui_mcp/README.md +235 -0
  43. ui_mcp/__init__.py +7 -0
  44. ui_mcp/__main__.py +10 -0
  45. ui_mcp/cli_runner.py +112 -0
  46. 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)