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
ui_cli/commands/sdwan.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""SD-WAN configuration commands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ui_cli.client import APIError, UniFiClient
|
|
9
|
+
from ui_cli.output import OutputFormat, print_error, render_output
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Manage SD-WAN configurations")
|
|
12
|
+
|
|
13
|
+
# Column definitions for SD-WAN configs table
|
|
14
|
+
SDWAN_COLUMNS = [
|
|
15
|
+
("id", "ID"),
|
|
16
|
+
("name", "Name"),
|
|
17
|
+
("type", "Type"),
|
|
18
|
+
("variant", "Variant"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
# Column definitions for SD-WAN status table
|
|
22
|
+
SDWAN_STATUS_COLUMNS = [
|
|
23
|
+
("fingerprint", "Fingerprint"),
|
|
24
|
+
("status", "Status"),
|
|
25
|
+
("progress", "Progress"),
|
|
26
|
+
("updatedAt", "Updated At"),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@app.command("list")
|
|
31
|
+
def list_configs(
|
|
32
|
+
output: Annotated[
|
|
33
|
+
OutputFormat,
|
|
34
|
+
typer.Option(
|
|
35
|
+
"--output",
|
|
36
|
+
"-o",
|
|
37
|
+
help="Output format: table, json, or csv",
|
|
38
|
+
),
|
|
39
|
+
] = OutputFormat.TABLE,
|
|
40
|
+
verbose: Annotated[
|
|
41
|
+
bool,
|
|
42
|
+
typer.Option(
|
|
43
|
+
"--verbose",
|
|
44
|
+
"-v",
|
|
45
|
+
help="Show detailed request/response information",
|
|
46
|
+
),
|
|
47
|
+
] = False,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""List all SD-WAN configurations."""
|
|
50
|
+
|
|
51
|
+
async def _list() -> list:
|
|
52
|
+
client = UniFiClient()
|
|
53
|
+
return await client.list_sdwan_configs()
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
configs = asyncio.run(_list())
|
|
57
|
+
|
|
58
|
+
if verbose:
|
|
59
|
+
typer.echo(f"Found {len(configs)} SD-WAN configuration(s)")
|
|
60
|
+
|
|
61
|
+
render_output(
|
|
62
|
+
data=configs,
|
|
63
|
+
output_format=output,
|
|
64
|
+
columns=SDWAN_COLUMNS,
|
|
65
|
+
title="SD-WAN Configurations",
|
|
66
|
+
verbose=verbose,
|
|
67
|
+
)
|
|
68
|
+
except APIError as e:
|
|
69
|
+
print_error(e.message)
|
|
70
|
+
raise typer.Exit(1)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.command("get")
|
|
74
|
+
def get_config(
|
|
75
|
+
config_id: Annotated[
|
|
76
|
+
str,
|
|
77
|
+
typer.Argument(help="The unique identifier of the SD-WAN config"),
|
|
78
|
+
],
|
|
79
|
+
output: Annotated[
|
|
80
|
+
OutputFormat,
|
|
81
|
+
typer.Option(
|
|
82
|
+
"--output",
|
|
83
|
+
"-o",
|
|
84
|
+
help="Output format: table, json, or csv",
|
|
85
|
+
),
|
|
86
|
+
] = OutputFormat.TABLE,
|
|
87
|
+
verbose: Annotated[
|
|
88
|
+
bool,
|
|
89
|
+
typer.Option(
|
|
90
|
+
"--verbose",
|
|
91
|
+
"-v",
|
|
92
|
+
help="Show detailed request/response information",
|
|
93
|
+
),
|
|
94
|
+
] = False,
|
|
95
|
+
) -> None:
|
|
96
|
+
"""Get detailed information about a specific SD-WAN configuration."""
|
|
97
|
+
|
|
98
|
+
async def _get() -> dict:
|
|
99
|
+
client = UniFiClient()
|
|
100
|
+
return await client.get_sdwan_config(config_id)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
config = asyncio.run(_get())
|
|
104
|
+
|
|
105
|
+
if not config:
|
|
106
|
+
print_error(f"SD-WAN config '{config_id}' not found")
|
|
107
|
+
raise typer.Exit(1)
|
|
108
|
+
|
|
109
|
+
render_output(
|
|
110
|
+
data=config,
|
|
111
|
+
output_format=output,
|
|
112
|
+
columns=SDWAN_COLUMNS,
|
|
113
|
+
title=f"SD-WAN Config: {config_id}",
|
|
114
|
+
verbose=verbose,
|
|
115
|
+
is_single=True,
|
|
116
|
+
)
|
|
117
|
+
except APIError as e:
|
|
118
|
+
print_error(e.message)
|
|
119
|
+
raise typer.Exit(1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@app.command("status")
|
|
123
|
+
def get_status(
|
|
124
|
+
config_id: Annotated[
|
|
125
|
+
str,
|
|
126
|
+
typer.Argument(help="The unique identifier of the SD-WAN config"),
|
|
127
|
+
],
|
|
128
|
+
output: Annotated[
|
|
129
|
+
OutputFormat,
|
|
130
|
+
typer.Option(
|
|
131
|
+
"--output",
|
|
132
|
+
"-o",
|
|
133
|
+
help="Output format: table, json, or csv",
|
|
134
|
+
),
|
|
135
|
+
] = OutputFormat.TABLE,
|
|
136
|
+
verbose: Annotated[
|
|
137
|
+
bool,
|
|
138
|
+
typer.Option(
|
|
139
|
+
"--verbose",
|
|
140
|
+
"-v",
|
|
141
|
+
help="Show detailed request/response information",
|
|
142
|
+
),
|
|
143
|
+
] = False,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Get deployment status of a specific SD-WAN configuration."""
|
|
146
|
+
|
|
147
|
+
async def _get() -> dict:
|
|
148
|
+
client = UniFiClient()
|
|
149
|
+
return await client.get_sdwan_status(config_id)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
status = asyncio.run(_get())
|
|
153
|
+
|
|
154
|
+
if not status:
|
|
155
|
+
print_error(f"Status for SD-WAN config '{config_id}' not found")
|
|
156
|
+
raise typer.Exit(1)
|
|
157
|
+
|
|
158
|
+
render_output(
|
|
159
|
+
data=status,
|
|
160
|
+
output_format=output,
|
|
161
|
+
columns=SDWAN_STATUS_COLUMNS,
|
|
162
|
+
title=f"SD-WAN Status: {config_id}",
|
|
163
|
+
verbose=verbose,
|
|
164
|
+
is_single=True,
|
|
165
|
+
)
|
|
166
|
+
except APIError as e:
|
|
167
|
+
print_error(e.message)
|
|
168
|
+
raise typer.Exit(1)
|
ui_cli/commands/sites.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Site management commands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Annotated
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from ui_cli.client import APIError, UniFiClient
|
|
9
|
+
from ui_cli.output import OutputFormat, print_error, render_output
|
|
10
|
+
|
|
11
|
+
app = typer.Typer(help="Manage UniFi sites")
|
|
12
|
+
|
|
13
|
+
# Column definitions for sites table
|
|
14
|
+
SITE_COLUMNS = [
|
|
15
|
+
("siteId", "Site ID"),
|
|
16
|
+
("meta.name", "Name"),
|
|
17
|
+
("meta.desc", "Description"),
|
|
18
|
+
("hostId", "Host ID"),
|
|
19
|
+
("meta.timezone", "Timezone"),
|
|
20
|
+
("permission", "Permission"),
|
|
21
|
+
("isOwner", "Owner"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("list")
|
|
26
|
+
def list_sites(
|
|
27
|
+
output: Annotated[
|
|
28
|
+
OutputFormat,
|
|
29
|
+
typer.Option(
|
|
30
|
+
"--output",
|
|
31
|
+
"-o",
|
|
32
|
+
help="Output format: table, json, or csv",
|
|
33
|
+
),
|
|
34
|
+
] = OutputFormat.TABLE,
|
|
35
|
+
verbose: Annotated[
|
|
36
|
+
bool,
|
|
37
|
+
typer.Option(
|
|
38
|
+
"--verbose",
|
|
39
|
+
"-v",
|
|
40
|
+
help="Show detailed request/response information",
|
|
41
|
+
),
|
|
42
|
+
] = False,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""List all sites from hosts running UniFi Network application."""
|
|
45
|
+
|
|
46
|
+
async def _list() -> list:
|
|
47
|
+
client = UniFiClient()
|
|
48
|
+
return await client.list_sites()
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
sites = asyncio.run(_list())
|
|
52
|
+
|
|
53
|
+
if verbose:
|
|
54
|
+
typer.echo(f"Found {len(sites)} site(s)")
|
|
55
|
+
|
|
56
|
+
render_output(
|
|
57
|
+
data=sites,
|
|
58
|
+
output_format=output,
|
|
59
|
+
columns=SITE_COLUMNS,
|
|
60
|
+
title="UniFi Sites",
|
|
61
|
+
verbose=verbose,
|
|
62
|
+
)
|
|
63
|
+
except APIError as e:
|
|
64
|
+
print_error(e.message)
|
|
65
|
+
raise typer.Exit(1)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Speed test command - run speed test on gateway."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import time
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ui_cli.local_client import LocalAPIError, UniFiLocalClient
|
|
10
|
+
from ui_cli.output import OutputFormat, console, output_json, print_error
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(help="Run speed test on gateway", invoke_without_command=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def run_speedtest(client: UniFiLocalClient) -> dict:
|
|
16
|
+
"""Trigger speed test and wait for results."""
|
|
17
|
+
# Trigger the speed test
|
|
18
|
+
response = await client.post("/cmd/devmgr", data={"cmd": "speedtest"})
|
|
19
|
+
|
|
20
|
+
if response.get("meta", {}).get("rc") != "ok":
|
|
21
|
+
raise LocalAPIError("Failed to start speed test")
|
|
22
|
+
|
|
23
|
+
return response
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def get_speedtest_status(client: UniFiLocalClient) -> dict | None:
|
|
27
|
+
"""Get current speed test status."""
|
|
28
|
+
response = await client.post("/cmd/devmgr", data={"cmd": "speedtest-status"})
|
|
29
|
+
data = response.get("data", [])
|
|
30
|
+
return data[0] if data else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def get_latest_speedtest(client: UniFiLocalClient) -> dict | None:
|
|
34
|
+
"""Get most recent speed test result from health endpoint."""
|
|
35
|
+
# Speed test data is in the www (Internet) subsystem of health
|
|
36
|
+
response = await client.get("/stat/health")
|
|
37
|
+
data = response.get("data", [])
|
|
38
|
+
|
|
39
|
+
for subsystem in data:
|
|
40
|
+
if subsystem.get("subsystem") == "www":
|
|
41
|
+
return subsystem
|
|
42
|
+
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def format_speed(bps: float | None) -> str:
|
|
47
|
+
"""Format speed in appropriate units."""
|
|
48
|
+
if bps is None or bps == 0:
|
|
49
|
+
return "-"
|
|
50
|
+
|
|
51
|
+
mbps = bps / 1_000_000
|
|
52
|
+
if mbps >= 1000:
|
|
53
|
+
return f"{mbps / 1000:.1f} Gbps"
|
|
54
|
+
elif mbps >= 1:
|
|
55
|
+
return f"{mbps:.1f} Mbps"
|
|
56
|
+
else:
|
|
57
|
+
kbps = bps / 1000
|
|
58
|
+
return f"{kbps:.0f} Kbps"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def format_latency(ms: float | None) -> str:
|
|
62
|
+
"""Format latency."""
|
|
63
|
+
if ms is None:
|
|
64
|
+
return "-"
|
|
65
|
+
return f"{ms:.0f} ms"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.callback(invoke_without_command=True)
|
|
69
|
+
def speedtest(
|
|
70
|
+
ctx: typer.Context,
|
|
71
|
+
run: Annotated[
|
|
72
|
+
bool,
|
|
73
|
+
typer.Option("--run", "-r", help="Run a new speed test"),
|
|
74
|
+
] = False,
|
|
75
|
+
output: Annotated[
|
|
76
|
+
OutputFormat,
|
|
77
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
78
|
+
] = OutputFormat.TABLE,
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Show last speed test result or run a new test."""
|
|
81
|
+
if ctx.invoked_subcommand is not None:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
async def _speedtest():
|
|
85
|
+
client = UniFiLocalClient()
|
|
86
|
+
|
|
87
|
+
if run:
|
|
88
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
|
|
89
|
+
|
|
90
|
+
# Run a new speed test
|
|
91
|
+
await run_speedtest(client)
|
|
92
|
+
|
|
93
|
+
# Poll for completion using health endpoint with progress bar
|
|
94
|
+
max_wait = 90 # seconds (speed tests can take a while)
|
|
95
|
+
start = time.time()
|
|
96
|
+
|
|
97
|
+
with Progress(
|
|
98
|
+
SpinnerColumn(),
|
|
99
|
+
TextColumn("[cyan]Running speed test..."),
|
|
100
|
+
BarColumn(bar_width=30),
|
|
101
|
+
TimeElapsedColumn(),
|
|
102
|
+
console=console,
|
|
103
|
+
transient=True,
|
|
104
|
+
) as progress:
|
|
105
|
+
task = progress.add_task("speedtest", total=max_wait)
|
|
106
|
+
|
|
107
|
+
while time.time() - start < max_wait:
|
|
108
|
+
await asyncio.sleep(2)
|
|
109
|
+
elapsed = time.time() - start
|
|
110
|
+
progress.update(task, completed=min(elapsed, max_wait))
|
|
111
|
+
|
|
112
|
+
# Check www subsystem for speedtest_status
|
|
113
|
+
health = await client.get("/stat/health")
|
|
114
|
+
for subsystem in health.get("data", []):
|
|
115
|
+
if subsystem.get("subsystem") == "www":
|
|
116
|
+
status = subsystem.get("speedtest_status", "")
|
|
117
|
+
if status.lower() != "running":
|
|
118
|
+
progress.update(task, completed=max_wait)
|
|
119
|
+
break
|
|
120
|
+
else:
|
|
121
|
+
continue
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
# Get the latest result
|
|
125
|
+
return await get_latest_speedtest(client)
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
result = asyncio.run(_speedtest())
|
|
129
|
+
except LocalAPIError as e:
|
|
130
|
+
print_error(str(e))
|
|
131
|
+
raise typer.Exit(1)
|
|
132
|
+
|
|
133
|
+
if not result:
|
|
134
|
+
console.print("[dim]No speed test results found. Run with --run to start a test.[/dim]")
|
|
135
|
+
return
|
|
136
|
+
|
|
137
|
+
if output == OutputFormat.JSON:
|
|
138
|
+
output_json(result)
|
|
139
|
+
return
|
|
140
|
+
|
|
141
|
+
# Table output
|
|
142
|
+
from datetime import datetime, timezone
|
|
143
|
+
from rich.table import Table
|
|
144
|
+
|
|
145
|
+
console.print()
|
|
146
|
+
console.print("[bold cyan]Speed Test Results[/bold cyan]")
|
|
147
|
+
console.print("─" * 40)
|
|
148
|
+
console.print()
|
|
149
|
+
|
|
150
|
+
# Format timestamp (speedtest_lastrun is in seconds)
|
|
151
|
+
ts = result.get("speedtest_lastrun", 0)
|
|
152
|
+
if ts:
|
|
153
|
+
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
154
|
+
time_str = dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
155
|
+
else:
|
|
156
|
+
time_str = "-"
|
|
157
|
+
|
|
158
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
159
|
+
table.add_column("Key", style="dim")
|
|
160
|
+
table.add_column("Value")
|
|
161
|
+
|
|
162
|
+
# Status
|
|
163
|
+
status = result.get("speedtest_status", "")
|
|
164
|
+
if status:
|
|
165
|
+
if status.lower() == "running":
|
|
166
|
+
table.add_row("Status:", f"[yellow]{status}[/yellow]")
|
|
167
|
+
else:
|
|
168
|
+
table.add_row("Status:", f"[green]{status}[/green]")
|
|
169
|
+
|
|
170
|
+
table.add_row("Last Run:", time_str)
|
|
171
|
+
table.add_row("", "")
|
|
172
|
+
|
|
173
|
+
# Download (xput_down is in Mbps)
|
|
174
|
+
download = result.get("xput_down", 0)
|
|
175
|
+
if download:
|
|
176
|
+
table.add_row("Download:", f"[green]{download} Mbps[/green]")
|
|
177
|
+
else:
|
|
178
|
+
table.add_row("Download:", "-")
|
|
179
|
+
|
|
180
|
+
# Upload (xput_up is in Mbps)
|
|
181
|
+
upload = result.get("xput_up", 0)
|
|
182
|
+
if upload:
|
|
183
|
+
table.add_row("Upload:", f"[blue]{upload} Mbps[/blue]")
|
|
184
|
+
else:
|
|
185
|
+
table.add_row("Upload:", "-")
|
|
186
|
+
|
|
187
|
+
# Latency
|
|
188
|
+
latency = result.get("speedtest_ping", result.get("latency", 0))
|
|
189
|
+
table.add_row("Latency:", format_latency(latency))
|
|
190
|
+
|
|
191
|
+
console.print(table)
|
|
192
|
+
console.print()
|