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,503 @@
|
|
|
1
|
+
"""Client groups management commands."""
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from ui_cli.groups import GroupManager, AutoGroupRules
|
|
7
|
+
from ui_cli.output import output_table, output_json, output_csv
|
|
8
|
+
|
|
9
|
+
app = typer.Typer(
|
|
10
|
+
name="groups",
|
|
11
|
+
help="Manage client groups for bulk actions",
|
|
12
|
+
no_args_is_help=True,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
console = Console()
|
|
16
|
+
|
|
17
|
+
# Column definitions for output
|
|
18
|
+
GROUP_COLUMNS = [
|
|
19
|
+
("name", "Name"),
|
|
20
|
+
("type", "Type"),
|
|
21
|
+
("member_count", "Members"),
|
|
22
|
+
("description", "Description"),
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
MEMBER_COLUMNS = [
|
|
26
|
+
("alias", "Alias"),
|
|
27
|
+
("mac", "MAC"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# -----------------------------------------------------------------------------
|
|
32
|
+
# Group CRUD Commands
|
|
33
|
+
# -----------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.command("list")
|
|
37
|
+
def list_groups(
|
|
38
|
+
output: str = typer.Option("table", "-o", "--output", help="Output format"),
|
|
39
|
+
) -> None:
|
|
40
|
+
"""List all groups."""
|
|
41
|
+
gm = GroupManager()
|
|
42
|
+
groups = gm.list_groups()
|
|
43
|
+
|
|
44
|
+
data = []
|
|
45
|
+
for slug, group in groups:
|
|
46
|
+
member_count: str | int = len(group.members) if group.members else 0
|
|
47
|
+
if group.type == "auto":
|
|
48
|
+
member_count = "(auto)"
|
|
49
|
+
data.append({
|
|
50
|
+
"slug": slug,
|
|
51
|
+
"name": group.name,
|
|
52
|
+
"type": group.type,
|
|
53
|
+
"member_count": member_count,
|
|
54
|
+
"description": group.description or "",
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if output == "json":
|
|
58
|
+
output_json(data)
|
|
59
|
+
elif output == "csv":
|
|
60
|
+
output_csv(data, GROUP_COLUMNS)
|
|
61
|
+
else:
|
|
62
|
+
if not data:
|
|
63
|
+
console.print("[dim]No groups defined. Create one with:[/dim]")
|
|
64
|
+
console.print(" ./ui groups create \"My Group\"")
|
|
65
|
+
return
|
|
66
|
+
output_table(data, GROUP_COLUMNS)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# Alias for list
|
|
70
|
+
@app.command("ls", hidden=True)
|
|
71
|
+
def list_groups_alias(
|
|
72
|
+
output: str = typer.Option("table", "-o", "--output"),
|
|
73
|
+
) -> None:
|
|
74
|
+
"""List all groups (alias for list)."""
|
|
75
|
+
list_groups(output=output)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command("create")
|
|
79
|
+
def create_group(
|
|
80
|
+
name: str = typer.Argument(..., help="Group name"),
|
|
81
|
+
description: str = typer.Option(None, "-d", "--description", help="Description"),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Create a static group."""
|
|
84
|
+
gm = GroupManager()
|
|
85
|
+
try:
|
|
86
|
+
slug, group = gm.create_group(name, description)
|
|
87
|
+
console.print(f"[green]Created group:[/green] {group.name} [dim]({slug})[/dim]")
|
|
88
|
+
except ValueError as e:
|
|
89
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
90
|
+
raise typer.Exit(1)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command("show")
|
|
94
|
+
def show_group(
|
|
95
|
+
name: str = typer.Argument(..., help="Group name or slug"),
|
|
96
|
+
output: str = typer.Option("table", "-o", "--output", help="Output format"),
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Show group details and members."""
|
|
99
|
+
gm = GroupManager()
|
|
100
|
+
result = gm.get_group(name)
|
|
101
|
+
|
|
102
|
+
if not result:
|
|
103
|
+
console.print(f"[red]Error:[/red] Group '{name}' not found")
|
|
104
|
+
raise typer.Exit(1)
|
|
105
|
+
|
|
106
|
+
slug, group = result
|
|
107
|
+
|
|
108
|
+
if output == "json":
|
|
109
|
+
output_json({"slug": slug, **group.model_dump()})
|
|
110
|
+
return
|
|
111
|
+
|
|
112
|
+
# Header info
|
|
113
|
+
console.print(f"\n[bold]Group:[/bold] {group.name}")
|
|
114
|
+
console.print(f"[bold]Type:[/bold] {group.type}")
|
|
115
|
+
if group.description:
|
|
116
|
+
console.print(f"[bold]Description:[/bold] {group.description}")
|
|
117
|
+
|
|
118
|
+
if group.type == "auto" and group.rules:
|
|
119
|
+
console.print("\n[bold]Rules:[/bold]")
|
|
120
|
+
rules = group.rules.model_dump(exclude_none=True)
|
|
121
|
+
for key, values in rules.items():
|
|
122
|
+
console.print(f" {key}: {', '.join(values)}")
|
|
123
|
+
console.print("\n[dim]Use './ui lo clients list -g {slug}' to see matching clients[/dim]")
|
|
124
|
+
|
|
125
|
+
if group.type == "static":
|
|
126
|
+
console.print(f"[bold]Members:[/bold] {len(group.members or [])}")
|
|
127
|
+
console.print(f"[bold]Created:[/bold] {group.created_at:%Y-%m-%d %H:%M}")
|
|
128
|
+
console.print(f"[bold]Updated:[/bold] {group.updated_at:%Y-%m-%d %H:%M}")
|
|
129
|
+
|
|
130
|
+
if group.members:
|
|
131
|
+
console.print()
|
|
132
|
+
member_data = [
|
|
133
|
+
{"alias": m.alias or "-", "mac": m.mac}
|
|
134
|
+
for m in group.members
|
|
135
|
+
]
|
|
136
|
+
output_table(member_data, MEMBER_COLUMNS)
|
|
137
|
+
else:
|
|
138
|
+
console.print("\n[dim]No members. Add with:[/dim]")
|
|
139
|
+
console.print(f" ./ui groups add {slug} <mac-address>")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command("delete")
|
|
143
|
+
def delete_group(
|
|
144
|
+
name: str = typer.Argument(..., help="Group name or slug"),
|
|
145
|
+
yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation"),
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Delete a group."""
|
|
148
|
+
gm = GroupManager()
|
|
149
|
+
result = gm.get_group(name)
|
|
150
|
+
|
|
151
|
+
if not result:
|
|
152
|
+
console.print(f"[red]Error:[/red] Group '{name}' not found")
|
|
153
|
+
raise typer.Exit(1)
|
|
154
|
+
|
|
155
|
+
slug, group = result
|
|
156
|
+
|
|
157
|
+
if not yes:
|
|
158
|
+
confirm = typer.confirm(f"Delete group '{group.name}'?")
|
|
159
|
+
if not confirm:
|
|
160
|
+
raise typer.Abort()
|
|
161
|
+
|
|
162
|
+
gm.delete_group(slug)
|
|
163
|
+
console.print(f"[green]Deleted group:[/green] {group.name}")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# Alias for delete
|
|
167
|
+
@app.command("rm", hidden=True)
|
|
168
|
+
def delete_group_alias(
|
|
169
|
+
name: str = typer.Argument(..., help="Group name or slug"),
|
|
170
|
+
yes: bool = typer.Option(False, "-y", "--yes"),
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Delete a group (alias for delete)."""
|
|
173
|
+
delete_group(name=name, yes=yes)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
# -----------------------------------------------------------------------------
|
|
177
|
+
# Group Edit Command
|
|
178
|
+
# -----------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command("edit")
|
|
182
|
+
def edit_group(
|
|
183
|
+
name: str = typer.Argument(..., help="Group name or slug"),
|
|
184
|
+
new_name: str = typer.Option(None, "-n", "--name", help="New name"),
|
|
185
|
+
description: str = typer.Option(None, "-d", "--description", help="New description"),
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Edit group name or description."""
|
|
188
|
+
gm = GroupManager()
|
|
189
|
+
|
|
190
|
+
if not new_name and description is None:
|
|
191
|
+
console.print("[red]Error:[/red] Specify --name or --description")
|
|
192
|
+
raise typer.Exit(1)
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
slug, group = gm.update_group(
|
|
196
|
+
name,
|
|
197
|
+
new_name=new_name,
|
|
198
|
+
description=description if description is not None else ...,
|
|
199
|
+
)
|
|
200
|
+
console.print(f"[green]Updated group:[/green] {group.name} [dim]({slug})[/dim]")
|
|
201
|
+
except ValueError as e:
|
|
202
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
203
|
+
raise typer.Exit(1)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# -----------------------------------------------------------------------------
|
|
207
|
+
# Member CRUD Commands
|
|
208
|
+
# -----------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@app.command("add")
|
|
212
|
+
def add_members(
|
|
213
|
+
group: str = typer.Argument(..., help="Group name or slug"),
|
|
214
|
+
clients: list[str] = typer.Argument(..., help="Client MAC addresses"),
|
|
215
|
+
alias: str = typer.Option(None, "-a", "--alias", help="Alias (single client only)"),
|
|
216
|
+
) -> None:
|
|
217
|
+
"""Add member(s) to a static group."""
|
|
218
|
+
gm = GroupManager()
|
|
219
|
+
result = gm.get_group(group)
|
|
220
|
+
|
|
221
|
+
if not result:
|
|
222
|
+
console.print(f"[red]Error:[/red] Group '{group}' not found")
|
|
223
|
+
raise typer.Exit(1)
|
|
224
|
+
|
|
225
|
+
_, grp = result
|
|
226
|
+
if grp.type != "static":
|
|
227
|
+
console.print("[red]Error:[/red] Cannot add members to auto groups")
|
|
228
|
+
console.print("[dim]Auto groups use rules to match clients dynamically[/dim]")
|
|
229
|
+
raise typer.Exit(1)
|
|
230
|
+
|
|
231
|
+
if alias and len(clients) > 1:
|
|
232
|
+
console.print("[red]Error:[/red] --alias can only be used with a single client")
|
|
233
|
+
raise typer.Exit(1)
|
|
234
|
+
|
|
235
|
+
for client in clients:
|
|
236
|
+
try:
|
|
237
|
+
mac = GroupManager.normalize_mac(client)
|
|
238
|
+
client_alias = alias if len(clients) == 1 else None
|
|
239
|
+
gm.add_member(group, mac, client_alias)
|
|
240
|
+
display = client_alias or mac
|
|
241
|
+
console.print(f"[green]Added:[/green] {display}")
|
|
242
|
+
except ValueError as e:
|
|
243
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@app.command("remove")
|
|
247
|
+
def remove_members(
|
|
248
|
+
group: str = typer.Argument(..., help="Group name or slug"),
|
|
249
|
+
clients: list[str] = typer.Argument(..., help="Client MACs or aliases"),
|
|
250
|
+
) -> None:
|
|
251
|
+
"""Remove member(s) from a static group."""
|
|
252
|
+
gm = GroupManager()
|
|
253
|
+
|
|
254
|
+
result = gm.get_group(group)
|
|
255
|
+
if not result:
|
|
256
|
+
console.print(f"[red]Error:[/red] Group '{group}' not found")
|
|
257
|
+
raise typer.Exit(1)
|
|
258
|
+
|
|
259
|
+
for client in clients:
|
|
260
|
+
try:
|
|
261
|
+
if gm.remove_member(group, client):
|
|
262
|
+
console.print(f"[green]Removed:[/green] {client}")
|
|
263
|
+
else:
|
|
264
|
+
console.print(f"[yellow]Not found:[/yellow] {client}")
|
|
265
|
+
except ValueError as e:
|
|
266
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@app.command("alias")
|
|
270
|
+
def set_alias(
|
|
271
|
+
group: str = typer.Argument(..., help="Group name or slug"),
|
|
272
|
+
client: str = typer.Argument(..., help="Client MAC or current alias"),
|
|
273
|
+
alias: str = typer.Argument(None, help="New alias (omit to clear)"),
|
|
274
|
+
clear: bool = typer.Option(False, "--clear", help="Clear the alias"),
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Set or clear a member's alias."""
|
|
277
|
+
gm = GroupManager()
|
|
278
|
+
|
|
279
|
+
new_alias = None if clear else alias
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
if gm.update_member(group, client, alias=new_alias):
|
|
283
|
+
if new_alias:
|
|
284
|
+
console.print(f"[green]Set alias:[/green] {new_alias}")
|
|
285
|
+
else:
|
|
286
|
+
console.print(f"[green]Cleared alias for:[/green] {client}")
|
|
287
|
+
else:
|
|
288
|
+
console.print(f"[red]Error:[/red] Member not found: {client}")
|
|
289
|
+
raise typer.Exit(1)
|
|
290
|
+
except ValueError as e:
|
|
291
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
292
|
+
raise typer.Exit(1)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@app.command("members")
|
|
296
|
+
def list_members(
|
|
297
|
+
group: str = typer.Argument(..., help="Group name or slug"),
|
|
298
|
+
output: str = typer.Option("table", "-o", "--output", help="Output format"),
|
|
299
|
+
) -> None:
|
|
300
|
+
"""List group members."""
|
|
301
|
+
gm = GroupManager()
|
|
302
|
+
|
|
303
|
+
result = gm.get_group(group)
|
|
304
|
+
if not result:
|
|
305
|
+
console.print(f"[red]Error:[/red] Group '{group}' not found")
|
|
306
|
+
raise typer.Exit(1)
|
|
307
|
+
|
|
308
|
+
_, grp = result
|
|
309
|
+
|
|
310
|
+
if grp.type != "static":
|
|
311
|
+
console.print("[yellow]Auto groups have dynamic membership based on rules[/yellow]")
|
|
312
|
+
console.print(f"Use: ./ui lo clients list -g {group}")
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
members = gm.list_members(group)
|
|
317
|
+
except ValueError as e:
|
|
318
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
319
|
+
raise typer.Exit(1)
|
|
320
|
+
|
|
321
|
+
data = [{"alias": m.alias or "-", "mac": m.mac} for m in members]
|
|
322
|
+
|
|
323
|
+
if output == "json":
|
|
324
|
+
output_json(data)
|
|
325
|
+
elif output == "csv":
|
|
326
|
+
output_csv(data, MEMBER_COLUMNS)
|
|
327
|
+
else:
|
|
328
|
+
if not data:
|
|
329
|
+
console.print("[dim]No members in group[/dim]")
|
|
330
|
+
return
|
|
331
|
+
output_table(data, MEMBER_COLUMNS)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
@app.command("clear")
|
|
335
|
+
def clear_members(
|
|
336
|
+
group: str = typer.Argument(..., help="Group name or slug"),
|
|
337
|
+
yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation"),
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Remove all members from a static group."""
|
|
340
|
+
gm = GroupManager()
|
|
341
|
+
|
|
342
|
+
result = gm.get_group(group)
|
|
343
|
+
if not result:
|
|
344
|
+
console.print(f"[red]Error:[/red] Group '{group}' not found")
|
|
345
|
+
raise typer.Exit(1)
|
|
346
|
+
|
|
347
|
+
_, grp = result
|
|
348
|
+
member_count = len(grp.members or [])
|
|
349
|
+
|
|
350
|
+
if member_count == 0:
|
|
351
|
+
console.print("[dim]Group has no members[/dim]")
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
if not yes:
|
|
355
|
+
confirm = typer.confirm(f"Remove all {member_count} members from '{grp.name}'?")
|
|
356
|
+
if not confirm:
|
|
357
|
+
raise typer.Abort()
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
gm.clear_members(group)
|
|
361
|
+
console.print(f"[green]Cleared {member_count} members[/green]")
|
|
362
|
+
except ValueError as e:
|
|
363
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
364
|
+
raise typer.Exit(1)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# -----------------------------------------------------------------------------
|
|
368
|
+
# Auto Group Command
|
|
369
|
+
# -----------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@app.command("auto")
|
|
373
|
+
def create_auto_group(
|
|
374
|
+
name: str = typer.Argument(..., help="Group name"),
|
|
375
|
+
vendor: list[str] = typer.Option(None, "--vendor", help="Vendor/OUI patterns"),
|
|
376
|
+
name_pattern: list[str] = typer.Option(None, "--name", help="Client name patterns"),
|
|
377
|
+
hostname: list[str] = typer.Option(None, "--hostname", help="Hostname patterns"),
|
|
378
|
+
network: list[str] = typer.Option(None, "--network", help="Network/SSID names"),
|
|
379
|
+
ip: list[str] = typer.Option(None, "--ip", help="IP patterns/ranges"),
|
|
380
|
+
mac: list[str] = typer.Option(None, "--mac", help="MAC prefix patterns"),
|
|
381
|
+
conn_type: list[str] = typer.Option(None, "--type", help="Connection type: wired, wireless"),
|
|
382
|
+
description: str = typer.Option(None, "-d", "--description", help="Description"),
|
|
383
|
+
dry_run: bool = typer.Option(False, "--dry-run", "--preview", help="Preview without creating"),
|
|
384
|
+
) -> None:
|
|
385
|
+
"""Create an auto group with pattern rules.
|
|
386
|
+
|
|
387
|
+
Auto groups dynamically match clients based on rules.
|
|
388
|
+
Multiple rules of the same type use OR logic.
|
|
389
|
+
Different rule types use AND logic.
|
|
390
|
+
|
|
391
|
+
Pattern syntax:
|
|
392
|
+
- Exact: "Apple"
|
|
393
|
+
- Wildcard: "*phone*", "iPhone*"
|
|
394
|
+
- Regex: "~^iPhone-[0-9]+"
|
|
395
|
+
- Multiple: "Apple,Samsung" (comma-separated)
|
|
396
|
+
|
|
397
|
+
Examples:
|
|
398
|
+
./ui groups auto "Apple Devices" --vendor "Apple"
|
|
399
|
+
./ui groups auto "IoT" --vendor "Philips,LIFX,Ring"
|
|
400
|
+
./ui groups auto "Cameras" --name "*camera*,*cam*"
|
|
401
|
+
./ui groups auto "Guest WiFi" --network "Guest"
|
|
402
|
+
./ui groups auto "Servers" --ip "192.168.1.100-200"
|
|
403
|
+
"""
|
|
404
|
+
# Build rules
|
|
405
|
+
rules = AutoGroupRules(
|
|
406
|
+
vendor=vendor,
|
|
407
|
+
name=name_pattern,
|
|
408
|
+
hostname=hostname,
|
|
409
|
+
network=network,
|
|
410
|
+
ip=ip,
|
|
411
|
+
mac=mac,
|
|
412
|
+
conn_type=conn_type,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Check at least one rule specified
|
|
416
|
+
if not any([vendor, name_pattern, hostname, network, ip, mac, conn_type]):
|
|
417
|
+
console.print("[red]Error:[/red] Specify at least one rule")
|
|
418
|
+
console.print("[dim]Available: --vendor, --name, --hostname, --network, --ip, --mac, --type[/dim]")
|
|
419
|
+
raise typer.Exit(1)
|
|
420
|
+
|
|
421
|
+
gm = GroupManager()
|
|
422
|
+
|
|
423
|
+
# Show rules
|
|
424
|
+
rules_dict = rules.model_dump(exclude_none=True)
|
|
425
|
+
|
|
426
|
+
if dry_run:
|
|
427
|
+
console.print(f"\n[bold]Preview:[/bold] Auto group '{name}'")
|
|
428
|
+
if description:
|
|
429
|
+
console.print(f"[bold]Description:[/bold] {description}")
|
|
430
|
+
console.print("\n[bold]Rules:[/bold]")
|
|
431
|
+
for key, val in rules_dict.items():
|
|
432
|
+
console.print(f" {key}: {', '.join(val)}")
|
|
433
|
+
console.print("\n[yellow]Dry run - group not created[/yellow]")
|
|
434
|
+
console.print("Run without --dry-run to create")
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
slug, group = gm.create_group(name, description, "auto", rules)
|
|
439
|
+
console.print(f"[green]Created auto group:[/green] {group.name} [dim]({slug})[/dim]")
|
|
440
|
+
console.print("\n[bold]Rules:[/bold]")
|
|
441
|
+
for key, val in rules_dict.items():
|
|
442
|
+
console.print(f" {key}: {', '.join(val)}")
|
|
443
|
+
console.print(f"\n[dim]View matches: ./ui lo clients list -g {slug}[/dim]")
|
|
444
|
+
except ValueError as e:
|
|
445
|
+
console.print(f"[red]Error:[/red] {e}")
|
|
446
|
+
raise typer.Exit(1)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# -----------------------------------------------------------------------------
|
|
450
|
+
# Import/Export Commands
|
|
451
|
+
# -----------------------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@app.command("export")
|
|
455
|
+
def export_groups(
|
|
456
|
+
output_file: str = typer.Option(None, "-o", "--output", help="Output file path"),
|
|
457
|
+
) -> None:
|
|
458
|
+
"""Export all groups to JSON."""
|
|
459
|
+
gm = GroupManager()
|
|
460
|
+
data = gm.export_groups()
|
|
461
|
+
|
|
462
|
+
if output_file:
|
|
463
|
+
from pathlib import Path
|
|
464
|
+
Path(output_file).write_text(
|
|
465
|
+
__import__("json").dumps(data, indent=2, default=str)
|
|
466
|
+
)
|
|
467
|
+
console.print(f"[green]Exported to:[/green] {output_file}")
|
|
468
|
+
else:
|
|
469
|
+
output_json(data)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@app.command("import")
|
|
473
|
+
def import_groups(
|
|
474
|
+
input_file: str = typer.Argument(..., help="JSON file to import"),
|
|
475
|
+
replace: bool = typer.Option(False, "--replace", help="Replace all existing groups"),
|
|
476
|
+
yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation"),
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Import groups from JSON file."""
|
|
479
|
+
from pathlib import Path
|
|
480
|
+
import json
|
|
481
|
+
|
|
482
|
+
path = Path(input_file)
|
|
483
|
+
if not path.exists():
|
|
484
|
+
console.print(f"[red]Error:[/red] File not found: {input_file}")
|
|
485
|
+
raise typer.Exit(1)
|
|
486
|
+
|
|
487
|
+
try:
|
|
488
|
+
data = json.loads(path.read_text())
|
|
489
|
+
except json.JSONDecodeError as e:
|
|
490
|
+
console.print(f"[red]Error:[/red] Invalid JSON: {e}")
|
|
491
|
+
raise typer.Exit(1)
|
|
492
|
+
|
|
493
|
+
group_count = len(data.get("groups", {}))
|
|
494
|
+
|
|
495
|
+
if replace and not yes:
|
|
496
|
+
confirm = typer.confirm(f"Replace all groups with {group_count} from file?")
|
|
497
|
+
if not confirm:
|
|
498
|
+
raise typer.Abort()
|
|
499
|
+
|
|
500
|
+
gm = GroupManager()
|
|
501
|
+
imported = gm.import_groups(data, replace=replace)
|
|
502
|
+
action = "Replaced with" if replace else "Imported"
|
|
503
|
+
console.print(f"[green]{action} {imported} groups[/green]")
|
ui_cli/commands/hosts.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Host 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 hosts (consoles and controllers)")
|
|
12
|
+
|
|
13
|
+
# Column definitions for hosts table
|
|
14
|
+
HOST_COLUMNS = [
|
|
15
|
+
("id", "ID"),
|
|
16
|
+
("reportedState.hostname", "Hostname"),
|
|
17
|
+
("type", "Type"),
|
|
18
|
+
("ipAddress", "IP Address"),
|
|
19
|
+
("reportedState.version", "Version"),
|
|
20
|
+
("owner", "Owner"),
|
|
21
|
+
("isBlocked", "Blocked"),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("list")
|
|
26
|
+
def list_hosts(
|
|
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 hosts (consoles/controllers) associated with your account."""
|
|
45
|
+
|
|
46
|
+
async def _list() -> list:
|
|
47
|
+
client = UniFiClient()
|
|
48
|
+
return await client.list_hosts()
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
hosts = asyncio.run(_list())
|
|
52
|
+
|
|
53
|
+
if verbose:
|
|
54
|
+
typer.echo(f"Found {len(hosts)} host(s)")
|
|
55
|
+
|
|
56
|
+
render_output(
|
|
57
|
+
data=hosts,
|
|
58
|
+
output_format=output,
|
|
59
|
+
columns=HOST_COLUMNS,
|
|
60
|
+
title="UniFi Hosts",
|
|
61
|
+
verbose=verbose,
|
|
62
|
+
)
|
|
63
|
+
except APIError as e:
|
|
64
|
+
print_error(e.message)
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@app.command("get")
|
|
69
|
+
def get_host(
|
|
70
|
+
host_id: Annotated[
|
|
71
|
+
str,
|
|
72
|
+
typer.Argument(help="The unique identifier of the host"),
|
|
73
|
+
],
|
|
74
|
+
output: Annotated[
|
|
75
|
+
OutputFormat,
|
|
76
|
+
typer.Option(
|
|
77
|
+
"--output",
|
|
78
|
+
"-o",
|
|
79
|
+
help="Output format: table, json, or csv",
|
|
80
|
+
),
|
|
81
|
+
] = OutputFormat.TABLE,
|
|
82
|
+
verbose: Annotated[
|
|
83
|
+
bool,
|
|
84
|
+
typer.Option(
|
|
85
|
+
"--verbose",
|
|
86
|
+
"-v",
|
|
87
|
+
help="Show detailed request/response information",
|
|
88
|
+
),
|
|
89
|
+
] = False,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Get detailed information about a specific host."""
|
|
92
|
+
|
|
93
|
+
async def _get() -> dict:
|
|
94
|
+
client = UniFiClient()
|
|
95
|
+
return await client.get_host(host_id)
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
host = asyncio.run(_get())
|
|
99
|
+
|
|
100
|
+
if not host:
|
|
101
|
+
print_error(f"Host '{host_id}' not found")
|
|
102
|
+
raise typer.Exit(1)
|
|
103
|
+
|
|
104
|
+
render_output(
|
|
105
|
+
data=host,
|
|
106
|
+
output_format=output,
|
|
107
|
+
columns=HOST_COLUMNS,
|
|
108
|
+
title=f"Host: {host_id}",
|
|
109
|
+
verbose=verbose,
|
|
110
|
+
is_single=True,
|
|
111
|
+
)
|
|
112
|
+
except APIError as e:
|
|
113
|
+
print_error(e.message)
|
|
114
|
+
raise typer.Exit(1)
|