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,234 @@
1
+ """Traffic statistics 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
+ print_error,
16
+ )
17
+
18
+ app = typer.Typer(name="stats", help="Traffic statistics", no_args_is_help=True)
19
+
20
+
21
+ def format_bytes(bytes_val: int | float | None) -> str:
22
+ """Format bytes to human-readable form."""
23
+ if not bytes_val or bytes_val == 0:
24
+ return "0 B"
25
+
26
+ units = ["B", "KB", "MB", "GB", "TB"]
27
+ unit_index = 0
28
+ value = float(bytes_val)
29
+
30
+ while value >= 1024 and unit_index < len(units) - 1:
31
+ value /= 1024
32
+ unit_index += 1
33
+
34
+ if unit_index == 0:
35
+ return f"{int(value)} {units[unit_index]}"
36
+ return f"{value:.1f} {units[unit_index]}"
37
+
38
+
39
+ def format_timestamp(ts: int | float | None, include_time: bool = False) -> str:
40
+ """Format Unix timestamp to date/time string."""
41
+ if not ts:
42
+ return "-"
43
+ try:
44
+ # Convert milliseconds to seconds if needed
45
+ if ts > 1e12:
46
+ ts = ts / 1000
47
+ dt = datetime.fromtimestamp(ts, tz=timezone.utc).astimezone()
48
+ if include_time:
49
+ return dt.strftime("%Y-%m-%d %H:%M")
50
+ return dt.strftime("%Y-%m-%d")
51
+ except (ValueError, OSError):
52
+ return str(ts)
53
+
54
+
55
+ def get_traffic_bytes(stat: dict[str, Any]) -> tuple[int, int]:
56
+ """Extract download/upload bytes from stat record."""
57
+ # Try WAN stats first (more accurate for internet traffic)
58
+ rx = stat.get("wan-rx_bytes", 0) or stat.get("rx_bytes", 0)
59
+ tx = stat.get("wan-tx_bytes", 0) or stat.get("tx_bytes", 0)
60
+ return int(rx or 0), int(tx or 0)
61
+
62
+
63
+ @app.command("daily")
64
+ def daily_stats(
65
+ days: Annotated[
66
+ int,
67
+ typer.Option("--days", "-d", help="Number of days to show"),
68
+ ] = 30,
69
+ output: Annotated[
70
+ OutputFormat,
71
+ typer.Option("--output", "-o", help="Output format"),
72
+ ] = OutputFormat.TABLE,
73
+ ) -> None:
74
+ """Show daily traffic statistics."""
75
+ from ui_cli.commands.local.utils import run_with_spinner
76
+
77
+ async def _stats():
78
+ client = UniFiLocalClient()
79
+ return await client.get_daily_stats(days=days)
80
+
81
+ try:
82
+ stats = run_with_spinner(_stats(), "Fetching daily stats...")
83
+ except LocalAPIError as e:
84
+ print_error(str(e))
85
+ raise typer.Exit(1)
86
+
87
+ if not stats:
88
+ console.print("[dim]No daily statistics available[/dim]")
89
+ return
90
+
91
+ # Sort by time (most recent first)
92
+ stats.sort(key=lambda s: s.get("time", 0), reverse=True)
93
+
94
+ if output == OutputFormat.JSON:
95
+ output_json(stats)
96
+ elif output == OutputFormat.CSV:
97
+ columns = [
98
+ ("date", "Date"),
99
+ ("rx_bytes", "Download (bytes)"),
100
+ ("tx_bytes", "Upload (bytes)"),
101
+ ("total_bytes", "Total (bytes)"),
102
+ ("num_sta", "Clients"),
103
+ ]
104
+ csv_data = []
105
+ for s in stats:
106
+ rx, tx = get_traffic_bytes(s)
107
+ csv_data.append({
108
+ "date": format_timestamp(s.get("time")),
109
+ "rx_bytes": rx,
110
+ "tx_bytes": tx,
111
+ "total_bytes": rx + tx,
112
+ "num_sta": s.get("num_sta", 0),
113
+ })
114
+ output_csv(csv_data, columns)
115
+ else:
116
+ from rich.table import Table
117
+
118
+ table = Table(title="Daily Traffic Statistics", show_header=True, header_style="bold cyan")
119
+ table.add_column("Date")
120
+ table.add_column("Download", justify="right")
121
+ table.add_column("Upload", justify="right")
122
+ table.add_column("Total", justify="right")
123
+ table.add_column("Clients", justify="right")
124
+
125
+ total_rx = 0
126
+ total_tx = 0
127
+
128
+ for s in stats:
129
+ date = format_timestamp(s.get("time"))
130
+ rx, tx = get_traffic_bytes(s)
131
+ total_rx += rx
132
+ total_tx += tx
133
+ total = rx + tx
134
+ num_sta = s.get("num_sta", 0)
135
+
136
+ table.add_row(
137
+ date,
138
+ format_bytes(rx),
139
+ format_bytes(tx),
140
+ format_bytes(total),
141
+ str(num_sta) if num_sta else "-",
142
+ )
143
+
144
+ console.print(table)
145
+ console.print()
146
+ console.print(f"[dim]Total: {format_bytes(total_rx)} down, {format_bytes(total_tx)} up ({format_bytes(total_rx + total_tx)} total)[/dim]")
147
+ console.print()
148
+
149
+
150
+ @app.command("hourly")
151
+ def hourly_stats(
152
+ hours: Annotated[
153
+ int,
154
+ typer.Option("--hours", "-h", help="Number of hours to show"),
155
+ ] = 24,
156
+ output: Annotated[
157
+ OutputFormat,
158
+ typer.Option("--output", "-o", help="Output format"),
159
+ ] = OutputFormat.TABLE,
160
+ ) -> None:
161
+ """Show hourly traffic statistics."""
162
+ from ui_cli.commands.local.utils import run_with_spinner
163
+
164
+ async def _stats():
165
+ client = UniFiLocalClient()
166
+ return await client.get_hourly_stats(hours=hours)
167
+
168
+ try:
169
+ stats = run_with_spinner(_stats(), "Fetching hourly stats...")
170
+ except LocalAPIError as e:
171
+ print_error(str(e))
172
+ raise typer.Exit(1)
173
+
174
+ if not stats:
175
+ console.print("[dim]No hourly statistics available[/dim]")
176
+ return
177
+
178
+ # Sort by time (most recent first)
179
+ stats.sort(key=lambda s: s.get("time", 0), reverse=True)
180
+
181
+ if output == OutputFormat.JSON:
182
+ output_json(stats)
183
+ elif output == OutputFormat.CSV:
184
+ columns = [
185
+ ("time", "Time"),
186
+ ("rx_bytes", "Download (bytes)"),
187
+ ("tx_bytes", "Upload (bytes)"),
188
+ ("total_bytes", "Total (bytes)"),
189
+ ("num_sta", "Clients"),
190
+ ]
191
+ csv_data = []
192
+ for s in stats:
193
+ rx, tx = get_traffic_bytes(s)
194
+ csv_data.append({
195
+ "time": format_timestamp(s.get("time"), include_time=True),
196
+ "rx_bytes": rx,
197
+ "tx_bytes": tx,
198
+ "total_bytes": rx + tx,
199
+ "num_sta": s.get("num_sta", 0),
200
+ })
201
+ output_csv(csv_data, columns)
202
+ else:
203
+ from rich.table import Table
204
+
205
+ table = Table(title="Hourly Traffic Statistics", show_header=True, header_style="bold cyan")
206
+ table.add_column("Time")
207
+ table.add_column("Download", justify="right")
208
+ table.add_column("Upload", justify="right")
209
+ table.add_column("Total", justify="right")
210
+ table.add_column("Clients", justify="right")
211
+
212
+ total_rx = 0
213
+ total_tx = 0
214
+
215
+ for s in stats:
216
+ time_str = format_timestamp(s.get("time"), include_time=True)
217
+ rx, tx = get_traffic_bytes(s)
218
+ total_rx += rx
219
+ total_tx += tx
220
+ total = rx + tx
221
+ num_sta = s.get("num_sta", 0)
222
+
223
+ table.add_row(
224
+ time_str,
225
+ format_bytes(rx),
226
+ format_bytes(tx),
227
+ format_bytes(total),
228
+ str(num_sta) if num_sta else "-",
229
+ )
230
+
231
+ console.print(table)
232
+ console.print()
233
+ console.print(f"[dim]Total: {format_bytes(total_rx)} down, {format_bytes(total_tx)} up ({format_bytes(total_rx + total_tx)} total)[/dim]")
234
+ console.print()
@@ -0,0 +1,85 @@
1
+ """Utility functions for local commands."""
2
+
3
+ import asyncio
4
+ import os
5
+ from contextlib import contextmanager
6
+ from typing import TypeVar
7
+
8
+ from rich.progress import Progress, SpinnerColumn, TextColumn
9
+
10
+ from ui_cli.output import console
11
+
12
+ T = TypeVar("T")
13
+
14
+ # Store timeout override globally for subcommands to access
15
+ _timeout_override: int | None = None
16
+
17
+ # Quick timeout value in seconds
18
+ QUICK_TIMEOUT = 5
19
+
20
+
21
+ def is_spinner_disabled() -> bool:
22
+ """Check if spinners should be disabled.
23
+
24
+ Spinners are disabled when:
25
+ - UNIFI_NO_SPINNER=1 or UNIFI_NO_SPINNER=true
26
+ - CI=true (common CI/CD environment variable)
27
+ - NO_COLOR is set (accessibility/CI convention)
28
+ """
29
+ no_spinner = os.environ.get("UNIFI_NO_SPINNER", "").lower()
30
+ if no_spinner in ("1", "true", "yes"):
31
+ return True
32
+
33
+ # Common CI/CD environment variables
34
+ if os.environ.get("CI", "").lower() == "true":
35
+ return True
36
+
37
+ # NO_COLOR is a convention for disabling color/formatting
38
+ if os.environ.get("NO_COLOR"):
39
+ return True
40
+
41
+ return False
42
+
43
+
44
+ def set_timeout_override(timeout: int | None) -> None:
45
+ """Set the timeout override value."""
46
+ global _timeout_override
47
+ _timeout_override = timeout
48
+
49
+
50
+ def get_timeout() -> int | None:
51
+ """Get the timeout override if --quick or --timeout was specified."""
52
+ return _timeout_override
53
+
54
+
55
+ @contextmanager
56
+ def spinner(message: str = "Connecting..."):
57
+ """Context manager that shows a spinner while executing.
58
+
59
+ Usage:
60
+ with spinner("Fetching clients..."):
61
+ result = asyncio.run(async_operation())
62
+ """
63
+ with Progress(
64
+ SpinnerColumn(),
65
+ TextColumn(f"[cyan]{message}"),
66
+ console=console,
67
+ transient=True,
68
+ ) as progress:
69
+ progress.add_task(message, total=None)
70
+ yield
71
+
72
+
73
+ def run_with_spinner(coro, message: str = "Connecting...") -> T:
74
+ """Run an async coroutine with a spinner.
75
+
76
+ Spinner is automatically disabled in CI/CD environments.
77
+
78
+ Usage:
79
+ result = run_with_spinner(client.list_clients(), "Fetching clients...")
80
+ """
81
+ if is_spinner_disabled():
82
+ return asyncio.run(coro)
83
+
84
+ with spinner(message):
85
+ return asyncio.run(coro)