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,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)
|