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,410 @@
|
|
|
1
|
+
"""Voucher management commands for guest WiFi access."""
|
|
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="vouchers", help="Guest WiFi voucher management", no_args_is_help=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_duration(minutes: int | None) -> str:
|
|
24
|
+
"""Format duration in human-readable form."""
|
|
25
|
+
if not minutes:
|
|
26
|
+
return "-"
|
|
27
|
+
|
|
28
|
+
if minutes < 60:
|
|
29
|
+
return f"{minutes}m"
|
|
30
|
+
elif minutes < 1440:
|
|
31
|
+
hours = minutes // 60
|
|
32
|
+
return f"{hours}h"
|
|
33
|
+
else:
|
|
34
|
+
days = minutes // 1440
|
|
35
|
+
return f"{days}d"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def format_quota(mb: int | None) -> str:
|
|
39
|
+
"""Format data quota in human-readable form."""
|
|
40
|
+
if not mb or mb == 0:
|
|
41
|
+
return "No limit"
|
|
42
|
+
|
|
43
|
+
if mb >= 1024:
|
|
44
|
+
gb = mb / 1024
|
|
45
|
+
return f"{gb:.1f} GB"
|
|
46
|
+
return f"{mb} MB"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def format_timestamp(ts: int | None) -> str:
|
|
50
|
+
"""Format Unix timestamp to date string."""
|
|
51
|
+
if not ts:
|
|
52
|
+
return "-"
|
|
53
|
+
try:
|
|
54
|
+
dt = datetime.fromtimestamp(ts, tz=timezone.utc)
|
|
55
|
+
return dt.strftime("%Y-%m-%d")
|
|
56
|
+
except (ValueError, OSError):
|
|
57
|
+
return str(ts)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def format_code(code: str | None) -> str:
|
|
61
|
+
"""Format voucher code with dash for readability."""
|
|
62
|
+
if not code:
|
|
63
|
+
return "-"
|
|
64
|
+
# Insert dash in middle if not present
|
|
65
|
+
if "-" not in code and len(code) == 10:
|
|
66
|
+
return f"{code[:5]}-{code[5:]}"
|
|
67
|
+
return code
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def is_voucher_expired(voucher: dict[str, Any]) -> bool:
|
|
71
|
+
"""Check if voucher is expired."""
|
|
72
|
+
create_time = voucher.get("create_time", 0)
|
|
73
|
+
duration = voucher.get("duration", 0) # in minutes
|
|
74
|
+
if create_time and duration:
|
|
75
|
+
expires_at = create_time + (duration * 60) # convert to seconds
|
|
76
|
+
return datetime.now(timezone.utc).timestamp() > expires_at
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_voucher_status(voucher: dict[str, Any]) -> tuple[str, str]:
|
|
81
|
+
"""Get voucher status and style."""
|
|
82
|
+
used = voucher.get("used", 0)
|
|
83
|
+
quota = voucher.get("quota", 1) # multi-use count
|
|
84
|
+
|
|
85
|
+
# Check if expired
|
|
86
|
+
if is_voucher_expired(voucher):
|
|
87
|
+
return "expired", "red"
|
|
88
|
+
|
|
89
|
+
if used >= quota:
|
|
90
|
+
return "used", "dim"
|
|
91
|
+
elif used > 0:
|
|
92
|
+
return "partial", "yellow"
|
|
93
|
+
else:
|
|
94
|
+
return "unused", "green"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("list")
|
|
98
|
+
def list_vouchers(
|
|
99
|
+
unused: Annotated[
|
|
100
|
+
bool,
|
|
101
|
+
typer.Option("--unused", "-u", help="Show only unused vouchers"),
|
|
102
|
+
] = False,
|
|
103
|
+
used: Annotated[
|
|
104
|
+
bool,
|
|
105
|
+
typer.Option("--used", help="Show only used vouchers"),
|
|
106
|
+
] = False,
|
|
107
|
+
output: Annotated[
|
|
108
|
+
OutputFormat,
|
|
109
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
110
|
+
] = OutputFormat.TABLE,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""List all vouchers."""
|
|
113
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
114
|
+
|
|
115
|
+
async def _list():
|
|
116
|
+
client = UniFiLocalClient()
|
|
117
|
+
return await client.get_vouchers()
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
vouchers = run_with_spinner(_list(), "Fetching vouchers...")
|
|
121
|
+
except LocalAPIError as e:
|
|
122
|
+
print_error(str(e))
|
|
123
|
+
raise typer.Exit(1)
|
|
124
|
+
|
|
125
|
+
if not vouchers:
|
|
126
|
+
console.print("[dim]No vouchers found[/dim]")
|
|
127
|
+
return
|
|
128
|
+
|
|
129
|
+
# Filter if requested
|
|
130
|
+
if unused:
|
|
131
|
+
vouchers = [v for v in vouchers if v.get("used", 0) == 0]
|
|
132
|
+
elif used:
|
|
133
|
+
vouchers = [v for v in vouchers if v.get("used", 0) > 0]
|
|
134
|
+
|
|
135
|
+
if not vouchers:
|
|
136
|
+
console.print("[dim]No matching vouchers[/dim]")
|
|
137
|
+
return
|
|
138
|
+
|
|
139
|
+
if output == OutputFormat.JSON:
|
|
140
|
+
output_json(vouchers)
|
|
141
|
+
elif output == OutputFormat.CSV:
|
|
142
|
+
columns = [
|
|
143
|
+
("_id", "ID"),
|
|
144
|
+
("code", "Code"),
|
|
145
|
+
("duration", "Duration (min)"),
|
|
146
|
+
("quota", "Uses"),
|
|
147
|
+
("used", "Used"),
|
|
148
|
+
("qos_usage_quota", "Quota (MB)"),
|
|
149
|
+
("note", "Note"),
|
|
150
|
+
("create_time", "Created"),
|
|
151
|
+
]
|
|
152
|
+
csv_data = []
|
|
153
|
+
for v in vouchers:
|
|
154
|
+
csv_data.append({
|
|
155
|
+
"_id": v.get("_id", ""),
|
|
156
|
+
"code": format_code(v.get("code")),
|
|
157
|
+
"duration": v.get("duration", 0),
|
|
158
|
+
"quota": v.get("quota", 1),
|
|
159
|
+
"used": v.get("used", 0),
|
|
160
|
+
"qos_usage_quota": v.get("qos_usage_quota", ""),
|
|
161
|
+
"note": v.get("note", ""),
|
|
162
|
+
"create_time": format_timestamp(v.get("create_time")),
|
|
163
|
+
})
|
|
164
|
+
output_csv(csv_data, columns)
|
|
165
|
+
else:
|
|
166
|
+
from rich.table import Table
|
|
167
|
+
|
|
168
|
+
table = Table(title="Guest Vouchers", show_header=True, header_style="bold cyan")
|
|
169
|
+
table.add_column("ID", style="dim")
|
|
170
|
+
table.add_column("Code")
|
|
171
|
+
table.add_column("Duration")
|
|
172
|
+
table.add_column("Quota")
|
|
173
|
+
table.add_column("Used")
|
|
174
|
+
table.add_column("Status")
|
|
175
|
+
table.add_column("Note", style="dim")
|
|
176
|
+
|
|
177
|
+
for v in vouchers:
|
|
178
|
+
voucher_id = v.get("_id", "")
|
|
179
|
+
code = format_code(v.get("code"))
|
|
180
|
+
duration = format_duration(v.get("duration", 0))
|
|
181
|
+
quota = format_quota(v.get("qos_usage_quota"))
|
|
182
|
+
multi_use = v.get("quota", 1)
|
|
183
|
+
used_count = v.get("used", 0)
|
|
184
|
+
used_str = f"{used_count}/{multi_use}"
|
|
185
|
+
note = v.get("note", "") or ""
|
|
186
|
+
status, style = get_voucher_status(v)
|
|
187
|
+
|
|
188
|
+
table.add_row(
|
|
189
|
+
voucher_id,
|
|
190
|
+
code,
|
|
191
|
+
duration,
|
|
192
|
+
quota,
|
|
193
|
+
used_str,
|
|
194
|
+
f"[{style}]{status}[/{style}]",
|
|
195
|
+
note[:20] + "..." if len(note) > 20 else note,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
console.print(table)
|
|
199
|
+
console.print(f"\n[dim]{len(vouchers)} voucher(s)[/dim]")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@app.command("create")
|
|
203
|
+
def create_voucher(
|
|
204
|
+
count: Annotated[
|
|
205
|
+
int,
|
|
206
|
+
typer.Option("--count", "-c", help="Number of vouchers to create"),
|
|
207
|
+
] = 1,
|
|
208
|
+
duration: Annotated[
|
|
209
|
+
int,
|
|
210
|
+
typer.Option("--duration", "-d", help="Duration in minutes (1440 = 24h)"),
|
|
211
|
+
] = 1440,
|
|
212
|
+
quota: Annotated[
|
|
213
|
+
int,
|
|
214
|
+
typer.Option("--quota", "-q", help="Data quota in MB (0 = unlimited)"),
|
|
215
|
+
] = 0,
|
|
216
|
+
up_limit: Annotated[
|
|
217
|
+
int,
|
|
218
|
+
typer.Option("--up", help="Upload limit in kbps (0 = unlimited)"),
|
|
219
|
+
] = 0,
|
|
220
|
+
down_limit: Annotated[
|
|
221
|
+
int,
|
|
222
|
+
typer.Option("--down", help="Download limit in kbps (0 = unlimited)"),
|
|
223
|
+
] = 0,
|
|
224
|
+
multi_use: Annotated[
|
|
225
|
+
int,
|
|
226
|
+
typer.Option("--multi-use", "-m", help="Number of uses per voucher"),
|
|
227
|
+
] = 1,
|
|
228
|
+
note: Annotated[
|
|
229
|
+
str | None,
|
|
230
|
+
typer.Option("--note", "-n", help="Note/description"),
|
|
231
|
+
] = None,
|
|
232
|
+
output: Annotated[
|
|
233
|
+
OutputFormat,
|
|
234
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
235
|
+
] = OutputFormat.TABLE,
|
|
236
|
+
) -> None:
|
|
237
|
+
"""Create new voucher(s)."""
|
|
238
|
+
|
|
239
|
+
async def _create():
|
|
240
|
+
client = UniFiLocalClient()
|
|
241
|
+
return await client.create_voucher(
|
|
242
|
+
count=count,
|
|
243
|
+
duration=duration,
|
|
244
|
+
quota=quota,
|
|
245
|
+
up_limit=up_limit,
|
|
246
|
+
down_limit=down_limit,
|
|
247
|
+
multi_use=multi_use,
|
|
248
|
+
note=note,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
result = run_with_spinner(_create(), "Creating voucher...")
|
|
253
|
+
except LocalAPIError as e:
|
|
254
|
+
print_error(str(e))
|
|
255
|
+
raise typer.Exit(1)
|
|
256
|
+
|
|
257
|
+
if not result:
|
|
258
|
+
print_error("Failed to create vouchers")
|
|
259
|
+
raise typer.Exit(1)
|
|
260
|
+
|
|
261
|
+
# Create API returns minimal data, fetch full voucher list to get details
|
|
262
|
+
# Filter by create_time from the result
|
|
263
|
+
create_time = result[0].get("create_time", 0) if result else 0
|
|
264
|
+
|
|
265
|
+
async def _fetch():
|
|
266
|
+
client = UniFiLocalClient()
|
|
267
|
+
vouchers = await client.get_vouchers()
|
|
268
|
+
# Filter vouchers created at or after our create_time
|
|
269
|
+
return [v for v in vouchers if v.get("create_time", 0) >= create_time][:count]
|
|
270
|
+
|
|
271
|
+
try:
|
|
272
|
+
created = run_with_spinner(_fetch(), "Fetching created vouchers...")
|
|
273
|
+
except LocalAPIError:
|
|
274
|
+
created = []
|
|
275
|
+
|
|
276
|
+
if not created:
|
|
277
|
+
print_success(f"Created {count} voucher(s)")
|
|
278
|
+
console.print("[dim]Run 'ui lo vouchers list' to see them[/dim]")
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
if output == OutputFormat.JSON:
|
|
282
|
+
output_json(created)
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
# Table output
|
|
286
|
+
from rich.table import Table
|
|
287
|
+
|
|
288
|
+
console.print()
|
|
289
|
+
print_success(f"Created {len(created)} voucher(s):")
|
|
290
|
+
console.print()
|
|
291
|
+
|
|
292
|
+
table = Table(show_header=True, header_style="bold cyan", box=None)
|
|
293
|
+
table.add_column("Code")
|
|
294
|
+
table.add_column("Duration")
|
|
295
|
+
table.add_column("Quota")
|
|
296
|
+
if multi_use > 1:
|
|
297
|
+
table.add_column("Uses")
|
|
298
|
+
|
|
299
|
+
for v in created:
|
|
300
|
+
code = format_code(v.get("code"))
|
|
301
|
+
dur = format_duration(duration)
|
|
302
|
+
q = format_quota(quota)
|
|
303
|
+
|
|
304
|
+
if multi_use > 1:
|
|
305
|
+
table.add_row(f"[green]{code}[/green]", dur, q, str(multi_use))
|
|
306
|
+
else:
|
|
307
|
+
table.add_row(f"[green]{code}[/green]", dur, q)
|
|
308
|
+
|
|
309
|
+
console.print(table)
|
|
310
|
+
console.print()
|
|
311
|
+
console.print("[dim]Tip: Share these codes with guests for WiFi access[/dim]")
|
|
312
|
+
console.print()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@app.command("revoke")
|
|
316
|
+
def revoke_voucher(
|
|
317
|
+
voucher_id: Annotated[str, typer.Argument(help="Voucher ID to revoke")],
|
|
318
|
+
yes: Annotated[
|
|
319
|
+
bool,
|
|
320
|
+
typer.Option("--yes", "-y", help="Skip confirmation"),
|
|
321
|
+
] = False,
|
|
322
|
+
) -> None:
|
|
323
|
+
"""Revoke/delete a voucher."""
|
|
324
|
+
|
|
325
|
+
if not yes:
|
|
326
|
+
confirm = typer.confirm(f"Revoke voucher {voucher_id}?")
|
|
327
|
+
if not confirm:
|
|
328
|
+
console.print("[dim]Cancelled[/dim]")
|
|
329
|
+
raise typer.Exit(0)
|
|
330
|
+
|
|
331
|
+
async def _revoke():
|
|
332
|
+
client = UniFiLocalClient()
|
|
333
|
+
return await client.revoke_voucher(voucher_id)
|
|
334
|
+
|
|
335
|
+
try:
|
|
336
|
+
success = run_with_spinner(_revoke(), "Revoking voucher...")
|
|
337
|
+
except LocalAPIError as e:
|
|
338
|
+
print_error(str(e))
|
|
339
|
+
raise typer.Exit(1)
|
|
340
|
+
|
|
341
|
+
if success:
|
|
342
|
+
print_success(f"Voucher {voucher_id} revoked")
|
|
343
|
+
else:
|
|
344
|
+
print_error(f"Failed to revoke voucher {voucher_id}")
|
|
345
|
+
raise typer.Exit(1)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@app.command("delete-all")
|
|
349
|
+
def delete_all_vouchers(
|
|
350
|
+
yes: Annotated[
|
|
351
|
+
bool,
|
|
352
|
+
typer.Option("--yes", "-y", help="Skip confirmation"),
|
|
353
|
+
] = False,
|
|
354
|
+
expired_only: Annotated[
|
|
355
|
+
bool,
|
|
356
|
+
typer.Option("--expired", "-e", help="Delete only expired vouchers"),
|
|
357
|
+
] = False,
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Delete all vouchers."""
|
|
360
|
+
|
|
361
|
+
async def _get_vouchers():
|
|
362
|
+
client = UniFiLocalClient()
|
|
363
|
+
return await client.get_vouchers()
|
|
364
|
+
|
|
365
|
+
try:
|
|
366
|
+
vouchers = run_with_spinner(_get_vouchers(), "Fetching vouchers...")
|
|
367
|
+
except LocalAPIError as e:
|
|
368
|
+
print_error(str(e))
|
|
369
|
+
raise typer.Exit(1)
|
|
370
|
+
|
|
371
|
+
if not vouchers:
|
|
372
|
+
console.print("[dim]No vouchers to delete[/dim]")
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
# Filter to expired only if requested
|
|
376
|
+
if expired_only:
|
|
377
|
+
vouchers = [v for v in vouchers if is_voucher_expired(v)]
|
|
378
|
+
if not vouchers:
|
|
379
|
+
console.print("[dim]No expired vouchers to delete[/dim]")
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
count = len(vouchers)
|
|
383
|
+
label = "expired vouchers" if expired_only else "vouchers"
|
|
384
|
+
|
|
385
|
+
if not yes:
|
|
386
|
+
confirm = typer.confirm(f"Delete all {count} {label}?")
|
|
387
|
+
if not confirm:
|
|
388
|
+
console.print("[dim]Cancelled[/dim]")
|
|
389
|
+
raise typer.Exit(0)
|
|
390
|
+
|
|
391
|
+
async def _delete_all():
|
|
392
|
+
client = UniFiLocalClient()
|
|
393
|
+
deleted = 0
|
|
394
|
+
for v in vouchers:
|
|
395
|
+
voucher_id = v.get("_id")
|
|
396
|
+
if voucher_id:
|
|
397
|
+
try:
|
|
398
|
+
if await client.revoke_voucher(voucher_id):
|
|
399
|
+
deleted += 1
|
|
400
|
+
except LocalAPIError:
|
|
401
|
+
pass
|
|
402
|
+
return deleted
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
deleted = run_with_spinner(_delete_all(), "Deleting vouchers...")
|
|
406
|
+
except LocalAPIError as e:
|
|
407
|
+
print_error(str(e))
|
|
408
|
+
raise typer.Exit(1)
|
|
409
|
+
|
|
410
|
+
print_success(f"Deleted {deleted}/{count} {label}")
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""WAN configuration commands for local controller."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Any
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ui_cli.local_client import LocalAPIError, UniFiLocalClient
|
|
8
|
+
from ui_cli.output import (
|
|
9
|
+
OutputFormat,
|
|
10
|
+
console,
|
|
11
|
+
output_csv,
|
|
12
|
+
output_json,
|
|
13
|
+
print_error,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(name="wan", help="WAN configuration (upstream DNS, type)", no_args_is_help=True)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_wan(network: dict[str, Any]) -> bool:
|
|
20
|
+
return network.get("purpose") in ("wan", "wan2")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _pref(network: dict[str, Any]) -> str:
|
|
24
|
+
return network.get("wan_dns_preference") or "auto"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@app.command("list")
|
|
28
|
+
def list_wans(
|
|
29
|
+
output: Annotated[
|
|
30
|
+
OutputFormat,
|
|
31
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
32
|
+
] = OutputFormat.TABLE,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""List WAN uplinks with their DNS settings."""
|
|
35
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
36
|
+
|
|
37
|
+
async def _list():
|
|
38
|
+
client = UniFiLocalClient()
|
|
39
|
+
nets = await client.get_networks()
|
|
40
|
+
return [n for n in nets if _is_wan(n)]
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
wans = run_with_spinner(_list(), "Fetching WAN networks...")
|
|
44
|
+
except LocalAPIError as e:
|
|
45
|
+
print_error(str(e))
|
|
46
|
+
raise typer.Exit(1)
|
|
47
|
+
|
|
48
|
+
if not wans:
|
|
49
|
+
console.print("[dim]No WAN networks found[/dim]")
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
if output == OutputFormat.JSON:
|
|
53
|
+
output_json(wans)
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
if output == OutputFormat.CSV:
|
|
57
|
+
columns = [
|
|
58
|
+
("_id", "ID"),
|
|
59
|
+
("name", "Name"),
|
|
60
|
+
("purpose", "Purpose"),
|
|
61
|
+
("wan_type", "Type"),
|
|
62
|
+
("wan_dns_preference", "DNS Pref"),
|
|
63
|
+
("wan_dns1", "DNS1"),
|
|
64
|
+
("wan_dns2", "DNS2"),
|
|
65
|
+
]
|
|
66
|
+
output_csv(wans, columns)
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
from rich.table import Table
|
|
70
|
+
|
|
71
|
+
table = Table(title="WAN Uplinks", show_header=True, header_style="bold cyan")
|
|
72
|
+
table.add_column("ID", style="dim")
|
|
73
|
+
table.add_column("Name")
|
|
74
|
+
table.add_column("Purpose")
|
|
75
|
+
table.add_column("Type")
|
|
76
|
+
table.add_column("DNS Pref")
|
|
77
|
+
table.add_column("DNS1")
|
|
78
|
+
table.add_column("DNS2")
|
|
79
|
+
|
|
80
|
+
for n in wans:
|
|
81
|
+
table.add_row(
|
|
82
|
+
n.get("_id", ""),
|
|
83
|
+
n.get("name", ""),
|
|
84
|
+
n.get("purpose", ""),
|
|
85
|
+
n.get("wan_type", "-"),
|
|
86
|
+
_pref(n),
|
|
87
|
+
n.get("wan_dns1", "") or "-",
|
|
88
|
+
n.get("wan_dns2", "") or "-",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
console.print(table)
|
|
92
|
+
console.print(f"\n[dim]{len(wans)} WAN(s)[/dim]")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@app.command("get")
|
|
96
|
+
def get_wan(
|
|
97
|
+
wan_id: Annotated[str, typer.Argument(help="WAN ID or name")],
|
|
98
|
+
output: Annotated[
|
|
99
|
+
OutputFormat,
|
|
100
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
101
|
+
] = OutputFormat.TABLE,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Show WAN details (DNS, IPv6 DNS, gateway, type)."""
|
|
104
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
105
|
+
|
|
106
|
+
async def _get():
|
|
107
|
+
client = UniFiLocalClient()
|
|
108
|
+
nets = await client.get_networks()
|
|
109
|
+
wans = [n for n in nets if _is_wan(n)]
|
|
110
|
+
for n in wans:
|
|
111
|
+
if n.get("_id") == wan_id or n.get("name", "").lower() == wan_id.lower():
|
|
112
|
+
return n
|
|
113
|
+
for n in wans:
|
|
114
|
+
if wan_id.lower() in n.get("name", "").lower():
|
|
115
|
+
return n
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
wan = run_with_spinner(_get(), "Finding WAN...")
|
|
120
|
+
except LocalAPIError as e:
|
|
121
|
+
print_error(str(e))
|
|
122
|
+
raise typer.Exit(1)
|
|
123
|
+
|
|
124
|
+
if not wan:
|
|
125
|
+
print_error(f"WAN '{wan_id}' not found")
|
|
126
|
+
raise typer.Exit(1)
|
|
127
|
+
|
|
128
|
+
if output == OutputFormat.JSON:
|
|
129
|
+
output_json(wan)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
from rich.table import Table
|
|
133
|
+
|
|
134
|
+
console.print()
|
|
135
|
+
console.print(f"[bold cyan]WAN: {wan.get('name', 'Unknown')}[/bold cyan]")
|
|
136
|
+
console.print("─" * 40)
|
|
137
|
+
console.print()
|
|
138
|
+
|
|
139
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
140
|
+
table.add_column("Key", style="dim")
|
|
141
|
+
table.add_column("Value")
|
|
142
|
+
|
|
143
|
+
table.add_row("ID:", wan.get("_id", ""))
|
|
144
|
+
table.add_row("Purpose:", wan.get("purpose", ""))
|
|
145
|
+
table.add_row("Type:", wan.get("wan_type", "-"))
|
|
146
|
+
table.add_row("", "")
|
|
147
|
+
table.add_row("DNS Preference:", _pref(wan))
|
|
148
|
+
table.add_row("DNS1:", wan.get("wan_dns1", "") or "-")
|
|
149
|
+
table.add_row("DNS2:", wan.get("wan_dns2", "") or "-")
|
|
150
|
+
table.add_row("", "")
|
|
151
|
+
table.add_row("IPv6 DNS Preference:", wan.get("wan_ipv6_dns_preference", "auto"))
|
|
152
|
+
table.add_row("IPv6 DNS1:", wan.get("wan_ipv6_dns1", "") or "-")
|
|
153
|
+
table.add_row("IPv6 DNS2:", wan.get("wan_ipv6_dns2", "") or "-")
|
|
154
|
+
|
|
155
|
+
console.print(table)
|
|
156
|
+
console.print()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@app.command("update")
|
|
160
|
+
def update_wan(
|
|
161
|
+
wan_ids: Annotated[
|
|
162
|
+
list[str],
|
|
163
|
+
typer.Argument(help="WAN ID(s) or name(s) — pass multiple to update in one call"),
|
|
164
|
+
],
|
|
165
|
+
dns1: Annotated[
|
|
166
|
+
str | None,
|
|
167
|
+
typer.Option("--dns1", help="Primary upstream DNS server (wan_dns1)"),
|
|
168
|
+
] = None,
|
|
169
|
+
dns2: Annotated[
|
|
170
|
+
str | None,
|
|
171
|
+
typer.Option("--dns2", help="Secondary upstream DNS server (wan_dns2)"),
|
|
172
|
+
] = None,
|
|
173
|
+
dns6_1: Annotated[
|
|
174
|
+
str | None,
|
|
175
|
+
typer.Option("--dns6-1", help="Primary IPv6 upstream DNS server (wan_ipv6_dns1)"),
|
|
176
|
+
] = None,
|
|
177
|
+
dns6_2: Annotated[
|
|
178
|
+
str | None,
|
|
179
|
+
typer.Option("--dns6-2", help="Secondary IPv6 upstream DNS server (wan_ipv6_dns2)"),
|
|
180
|
+
] = None,
|
|
181
|
+
auto: Annotated[
|
|
182
|
+
bool,
|
|
183
|
+
typer.Option(
|
|
184
|
+
"--auto",
|
|
185
|
+
help="Switch DNS preference back to auto (ISP-provided); clears dns1/dns2",
|
|
186
|
+
),
|
|
187
|
+
] = False,
|
|
188
|
+
output: Annotated[
|
|
189
|
+
OutputFormat,
|
|
190
|
+
typer.Option("--output", "-o", help="Output format"),
|
|
191
|
+
] = OutputFormat.TABLE,
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Update WAN DNS settings.
|
|
194
|
+
|
|
195
|
+
Setting --dns1 or --dns2 implies wan_dns_preference=manual. --auto reverts
|
|
196
|
+
to ISP-provided DNS and clears the manual DNS entries.
|
|
197
|
+
"""
|
|
198
|
+
from ui_cli.commands.local.utils import run_with_spinner
|
|
199
|
+
from ui_cli.output import print_success
|
|
200
|
+
|
|
201
|
+
v4_requested = dns1 is not None or dns2 is not None
|
|
202
|
+
v6_requested = dns6_1 is not None or dns6_2 is not None
|
|
203
|
+
|
|
204
|
+
if not v4_requested and not v6_requested and not auto:
|
|
205
|
+
print_error("At least one option required (--dns1, --dns2, --dns6-1, --dns6-2, --auto)")
|
|
206
|
+
raise typer.Exit(1)
|
|
207
|
+
|
|
208
|
+
if auto and (v4_requested or v6_requested):
|
|
209
|
+
print_error("--auto cannot be combined with --dns1/--dns2/--dns6-1/--dns6-2")
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
|
|
212
|
+
def _resolve(wans: list[dict[str, Any]], needle: str) -> dict[str, Any] | None:
|
|
213
|
+
for n in wans:
|
|
214
|
+
if n.get("_id") == needle or n.get("name", "").lower() == needle.lower():
|
|
215
|
+
return n
|
|
216
|
+
for n in wans:
|
|
217
|
+
if needle.lower() in n.get("name", "").lower():
|
|
218
|
+
return n
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
async def _update_one(
|
|
222
|
+
client: UniFiLocalClient,
|
|
223
|
+
wans: list[dict[str, Any]],
|
|
224
|
+
needle: str,
|
|
225
|
+
) -> tuple[dict[str, Any] | None, str | None]:
|
|
226
|
+
wan = _resolve(wans, needle)
|
|
227
|
+
if not wan:
|
|
228
|
+
return None, f"WAN '{needle}' not found"
|
|
229
|
+
|
|
230
|
+
net_id = wan["_id"]
|
|
231
|
+
payload: dict[str, Any] = {"_id": net_id}
|
|
232
|
+
|
|
233
|
+
if auto:
|
|
234
|
+
payload["wan_dns_preference"] = "auto"
|
|
235
|
+
payload["wan_dns1"] = ""
|
|
236
|
+
payload["wan_dns2"] = ""
|
|
237
|
+
elif v4_requested:
|
|
238
|
+
payload["wan_dns_preference"] = "manual"
|
|
239
|
+
if dns1 is not None:
|
|
240
|
+
payload["wan_dns1"] = dns1
|
|
241
|
+
if dns2 is not None:
|
|
242
|
+
payload["wan_dns2"] = dns2
|
|
243
|
+
|
|
244
|
+
if v6_requested:
|
|
245
|
+
payload["wan_ipv6_dns_preference"] = "manual"
|
|
246
|
+
if dns6_1 is not None:
|
|
247
|
+
payload["wan_ipv6_dns1"] = dns6_1
|
|
248
|
+
if dns6_2 is not None:
|
|
249
|
+
payload["wan_ipv6_dns2"] = dns6_2
|
|
250
|
+
|
|
251
|
+
updated = await client.update_network(net_id, payload)
|
|
252
|
+
return updated, None
|
|
253
|
+
|
|
254
|
+
async def _update_all():
|
|
255
|
+
client = UniFiLocalClient()
|
|
256
|
+
nets = await client.get_networks()
|
|
257
|
+
wans = [n for n in nets if _is_wan(n)]
|
|
258
|
+
results: list[tuple[str, dict[str, Any] | None, str | None]] = []
|
|
259
|
+
for needle in wan_ids:
|
|
260
|
+
result, err = await _update_one(client, wans, needle)
|
|
261
|
+
results.append((needle, result, err))
|
|
262
|
+
return results
|
|
263
|
+
|
|
264
|
+
try:
|
|
265
|
+
results = run_with_spinner(_update_all(), "Updating WAN(s)...")
|
|
266
|
+
except LocalAPIError as e:
|
|
267
|
+
print_error(str(e))
|
|
268
|
+
raise typer.Exit(1)
|
|
269
|
+
|
|
270
|
+
if output == OutputFormat.JSON:
|
|
271
|
+
output_json([
|
|
272
|
+
{"wan": needle, "error": err, "result": result}
|
|
273
|
+
for needle, result, err in results
|
|
274
|
+
])
|
|
275
|
+
raise typer.Exit(1 if any(err for _, _, err in results) else 0)
|
|
276
|
+
|
|
277
|
+
exit_code = 0
|
|
278
|
+
for needle, result, err in results:
|
|
279
|
+
if err:
|
|
280
|
+
print_error(f"[{needle}] {err}")
|
|
281
|
+
exit_code = 1
|
|
282
|
+
continue
|
|
283
|
+
if not result:
|
|
284
|
+
print_error(f"[{needle}] Update failed - no response from controller")
|
|
285
|
+
exit_code = 1
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
name = result.get("name", needle)
|
|
289
|
+
print_success(f"Updated WAN '{name}'")
|
|
290
|
+
console.print(f" DNS Pref: {result.get('wan_dns_preference', 'auto')}")
|
|
291
|
+
if result.get("wan_dns1") or result.get("wan_dns2"):
|
|
292
|
+
console.print(
|
|
293
|
+
f" DNS: {result.get('wan_dns1', '') or '-'}, {result.get('wan_dns2', '') or '-'}"
|
|
294
|
+
)
|
|
295
|
+
if result.get("wan_ipv6_dns1") or result.get("wan_ipv6_dns2"):
|
|
296
|
+
console.print(
|
|
297
|
+
f" IPv6 DNS: {result.get('wan_ipv6_dns1', '') or '-'}, "
|
|
298
|
+
f"{result.get('wan_ipv6_dns2', '') or '-'}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
if exit_code:
|
|
302
|
+
raise typer.Exit(exit_code)
|