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,285 @@
1
+ """Firewall commands for local controller."""
2
+
3
+ import asyncio
4
+ from typing import Annotated, Any
5
+
6
+ import typer
7
+
8
+ from ui_cli.local_client import LocalAPIError, UniFiLocalClient
9
+ from ui_cli.output import (
10
+ OutputFormat,
11
+ console,
12
+ output_csv,
13
+ output_json,
14
+ print_error,
15
+ )
16
+
17
+ app = typer.Typer(name="firewall", help="Firewall rules and groups", no_args_is_help=True)
18
+
19
+
20
+ # Ruleset display names
21
+ RULESET_NAMES = {
22
+ "WAN_IN": "WAN In",
23
+ "WAN_OUT": "WAN Out",
24
+ "WAN_LOCAL": "WAN Local",
25
+ "LAN_IN": "LAN In",
26
+ "LAN_OUT": "LAN Out",
27
+ "LAN_LOCAL": "LAN Local",
28
+ "GUEST_IN": "Guest In",
29
+ "GUEST_OUT": "Guest Out",
30
+ "GUEST_LOCAL": "Guest Local",
31
+ }
32
+
33
+
34
+ def format_action(action: str) -> tuple[str, str]:
35
+ """Format action with color."""
36
+ action_lower = action.lower()
37
+ if action_lower == "accept":
38
+ return "accept", "green"
39
+ elif action_lower == "drop":
40
+ return "drop", "red"
41
+ elif action_lower == "reject":
42
+ return "reject", "yellow"
43
+ return action, "white"
44
+
45
+
46
+ def format_protocol(rule: dict[str, Any]) -> str:
47
+ """Format protocol for display."""
48
+ protocol = rule.get("protocol", "all")
49
+ if protocol == "all":
50
+ return "any"
51
+ return protocol.upper()
52
+
53
+
54
+ def format_address(rule: dict[str, Any], prefix: str) -> str:
55
+ """Format source or destination address."""
56
+ # Check for network type
57
+ net_type = rule.get(f"{prefix}_network_type", "")
58
+ if net_type == "ADDRv4":
59
+ addr = rule.get(f"{prefix}_address", "")
60
+ return addr if addr else "any"
61
+
62
+ # Check for firewall group
63
+ group = rule.get(f"{prefix}_firewallgroup_ids", [])
64
+ if group:
65
+ return f"group:{len(group)}"
66
+
67
+ # Check for specific network
68
+ network = rule.get(f"{prefix}_network", "")
69
+ if network:
70
+ return network
71
+
72
+ return "any"
73
+
74
+
75
+ def format_port(rule: dict[str, Any], prefix: str) -> str:
76
+ """Format port information."""
77
+ port = rule.get(f"{prefix}_port", "")
78
+ if port:
79
+ return str(port)
80
+ return "*"
81
+
82
+
83
+ def get_ruleset_order(ruleset: str) -> int:
84
+ """Get sort order for rulesets."""
85
+ order = ["WAN_IN", "WAN_OUT", "WAN_LOCAL", "LAN_IN", "LAN_OUT", "LAN_LOCAL", "GUEST_IN", "GUEST_OUT", "GUEST_LOCAL"]
86
+ try:
87
+ return order.index(ruleset)
88
+ except ValueError:
89
+ return 100
90
+
91
+
92
+ @app.command("list")
93
+ def list_rules(
94
+ ruleset: Annotated[
95
+ str | None,
96
+ typer.Option("--ruleset", "-r", help="Filter by ruleset (e.g., WAN_IN, LAN_IN)"),
97
+ ] = None,
98
+ output: Annotated[
99
+ OutputFormat,
100
+ typer.Option("--output", "-o", help="Output format"),
101
+ ] = OutputFormat.TABLE,
102
+ verbose: Annotated[
103
+ bool,
104
+ typer.Option("--verbose", "-v", help="Show additional details"),
105
+ ] = False,
106
+ ) -> None:
107
+ """List firewall rules."""
108
+ from ui_cli.commands.local.utils import run_with_spinner
109
+
110
+ async def _list():
111
+ client = UniFiLocalClient()
112
+ return await client.get_firewall_rules()
113
+
114
+ try:
115
+ rules = run_with_spinner(_list(), "Fetching firewall rules...")
116
+ except LocalAPIError as e:
117
+ print_error(str(e))
118
+ raise typer.Exit(1)
119
+
120
+ if not rules:
121
+ console.print("[dim]No firewall rules found[/dim]")
122
+ return
123
+
124
+ # Filter by ruleset if specified
125
+ if ruleset:
126
+ ruleset_upper = ruleset.upper()
127
+ rules = [r for r in rules if r.get("ruleset", "").upper() == ruleset_upper]
128
+ if not rules:
129
+ console.print(f"[dim]No rules found for ruleset '{ruleset}'[/dim]")
130
+ return
131
+
132
+ # Sort by ruleset then by rule index
133
+ rules.sort(key=lambda r: (get_ruleset_order(r.get("ruleset", "")), r.get("rule_index", 0)))
134
+
135
+ if output == OutputFormat.JSON:
136
+ output_json(rules)
137
+ elif output == OutputFormat.CSV:
138
+ columns = [
139
+ ("name", "Name"),
140
+ ("ruleset", "Ruleset"),
141
+ ("action", "Action"),
142
+ ("protocol", "Protocol"),
143
+ ("src_address", "Source"),
144
+ ("dst_address", "Destination"),
145
+ ("enabled", "Enabled"),
146
+ ]
147
+ csv_data = []
148
+ for r in rules:
149
+ csv_data.append({
150
+ "name": r.get("name", ""),
151
+ "ruleset": r.get("ruleset", ""),
152
+ "action": r.get("action", ""),
153
+ "protocol": format_protocol(r),
154
+ "src_address": format_address(r, "src"),
155
+ "dst_address": format_address(r, "dst"),
156
+ "enabled": "Yes" if r.get("enabled", True) else "No",
157
+ })
158
+ output_csv(csv_data, columns)
159
+ else:
160
+ from rich.table import Table
161
+
162
+ table = Table(title="Firewall Rules", show_header=True, header_style="bold cyan")
163
+ table.add_column("Name")
164
+ table.add_column("Ruleset")
165
+ table.add_column("Action")
166
+ table.add_column("Protocol")
167
+ table.add_column("Source")
168
+ table.add_column("Destination")
169
+ if verbose:
170
+ table.add_column("Src Port")
171
+ table.add_column("Dst Port")
172
+ table.add_column("Enabled")
173
+
174
+ for r in rules:
175
+ name = r.get("name", "(unnamed)")
176
+ ruleset_name = RULESET_NAMES.get(r.get("ruleset", ""), r.get("ruleset", ""))
177
+ action, action_style = format_action(r.get("action", ""))
178
+ protocol = format_protocol(r)
179
+ src = format_address(r, "src")
180
+ dst = format_address(r, "dst")
181
+ enabled = "[green]✓[/green]" if r.get("enabled", True) else "[dim]✗[/dim]"
182
+
183
+ if verbose:
184
+ src_port = format_port(r, "src")
185
+ dst_port = format_port(r, "dst")
186
+ table.add_row(
187
+ name,
188
+ ruleset_name,
189
+ f"[{action_style}]{action}[/{action_style}]",
190
+ protocol,
191
+ src,
192
+ dst,
193
+ src_port,
194
+ dst_port,
195
+ enabled,
196
+ )
197
+ else:
198
+ table.add_row(
199
+ name,
200
+ ruleset_name,
201
+ f"[{action_style}]{action}[/{action_style}]",
202
+ protocol,
203
+ src,
204
+ dst,
205
+ enabled,
206
+ )
207
+
208
+ console.print(table)
209
+ console.print(f"\n[dim]{len(rules)} rule(s)[/dim]")
210
+
211
+
212
+ @app.command("groups")
213
+ def list_groups(
214
+ output: Annotated[
215
+ OutputFormat,
216
+ typer.Option("--output", "-o", help="Output format"),
217
+ ] = OutputFormat.TABLE,
218
+ ) -> None:
219
+ """List firewall groups (address and port groups)."""
220
+ from ui_cli.commands.local.utils import run_with_spinner
221
+
222
+ async def _list():
223
+ client = UniFiLocalClient()
224
+ return await client.get_firewall_groups()
225
+
226
+ try:
227
+ groups = run_with_spinner(_list(), "Fetching firewall groups...")
228
+ except LocalAPIError as e:
229
+ print_error(str(e))
230
+ raise typer.Exit(1)
231
+
232
+ if not groups:
233
+ console.print("[dim]No firewall groups found[/dim]")
234
+ return
235
+
236
+ # Sort by type then name
237
+ groups.sort(key=lambda g: (g.get("group_type", ""), g.get("name", "")))
238
+
239
+ if output == OutputFormat.JSON:
240
+ output_json(groups)
241
+ elif output == OutputFormat.CSV:
242
+ columns = [
243
+ ("_id", "ID"),
244
+ ("name", "Name"),
245
+ ("group_type", "Type"),
246
+ ("members", "Members"),
247
+ ]
248
+ csv_data = []
249
+ for g in groups:
250
+ members = g.get("group_members", [])
251
+ csv_data.append({
252
+ "_id": g.get("_id", ""),
253
+ "name": g.get("name", ""),
254
+ "group_type": g.get("group_type", ""),
255
+ "members": ", ".join(members) if members else "",
256
+ })
257
+ output_csv(csv_data, columns)
258
+ else:
259
+ from rich.table import Table
260
+
261
+ table = Table(title="Firewall Groups", show_header=True, header_style="bold cyan")
262
+ table.add_column("ID", style="dim")
263
+ table.add_column("Name")
264
+ table.add_column("Type")
265
+ table.add_column("Members")
266
+
267
+ for g in groups:
268
+ group_id = g.get("_id", "")
269
+ name = g.get("name", "")
270
+ group_type = g.get("group_type", "")
271
+
272
+ # Format type for display
273
+ type_display = group_type.replace("-", " ").title()
274
+
275
+ # Format members
276
+ members = g.get("group_members", [])
277
+ if len(members) <= 3:
278
+ members_str = ", ".join(members) if members else "[dim]-[/dim]"
279
+ else:
280
+ members_str = f"{', '.join(members[:3])}... (+{len(members) - 3})"
281
+
282
+ table.add_row(group_id, name, type_display, members_str)
283
+
284
+ console.print(table)
285
+ console.print(f"\n[dim]{len(groups)} group(s)[/dim]")
@@ -0,0 +1,195 @@
1
+ """Site health command for local controller."""
2
+
3
+ from typing import Annotated, Any
4
+
5
+ import typer
6
+
7
+ from ui_cli.commands.local.utils import run_with_spinner
8
+ from ui_cli.local_client import LocalAPIError, UniFiLocalClient
9
+ from ui_cli.output import OutputFormat, console, output_json, print_error
10
+
11
+ app = typer.Typer(name="health", help="Site health status", invoke_without_command=True)
12
+
13
+
14
+ def get_status_indicator(status: str, score: int | None = None) -> tuple[str, str]:
15
+ """Get status indicator and style based on status/score."""
16
+ status_lower = status.lower() if status else ""
17
+
18
+ if status_lower == "ok" or (score is not None and score >= 90):
19
+ return "🟢", "green"
20
+ elif status_lower in ("warning", "warn") or (score is not None and score >= 70):
21
+ return "🟡", "yellow"
22
+ elif status_lower in ("error", "critical", "unhealthy") or (score is not None and score < 70):
23
+ return "🔴", "red"
24
+ else:
25
+ return "⚪", "dim"
26
+
27
+
28
+ def format_subsystem_name(subsystem: str) -> str:
29
+ """Format subsystem name for display."""
30
+ name_map = {
31
+ "www": "Internet",
32
+ "wan": "WAN",
33
+ "lan": "LAN",
34
+ "wlan": "WLAN",
35
+ "vpn": "VPN",
36
+ "speedtest": "Speed Test",
37
+ "dhcp": "DHCP",
38
+ "dns": "DNS",
39
+ }
40
+ return name_map.get(subsystem.lower(), subsystem.upper())
41
+
42
+
43
+ def extract_issues(health_data: list[dict[str, Any]]) -> list[str]:
44
+ """Extract issues from health data."""
45
+ issues = []
46
+
47
+ for subsystem in health_data:
48
+ name = format_subsystem_name(subsystem.get("subsystem", ""))
49
+ status = subsystem.get("status", "").lower()
50
+ sub_name = subsystem.get("subsystem", "")
51
+
52
+ # Check for disconnected devices (common across subsystems)
53
+ num_disconnected = subsystem.get("num_disconnected", 0)
54
+ if num_disconnected > 0:
55
+ if sub_name == "lan":
56
+ issues.append(f"{name}: {num_disconnected} switch(es) disconnected")
57
+ elif sub_name == "wlan":
58
+ issues.append(f"{name}: {num_disconnected} AP(s) disconnected")
59
+ elif sub_name == "wan":
60
+ issues.append(f"{name}: {num_disconnected} gateway(s) disconnected")
61
+ else:
62
+ issues.append(f"{name}: {num_disconnected} device(s) disconnected")
63
+
64
+ # Check for pending devices
65
+ num_pending = subsystem.get("num_pending", 0)
66
+ if num_pending > 0:
67
+ issues.append(f"{name}: {num_pending} device(s) pending adoption")
68
+
69
+ # WLAN specific checks
70
+ if sub_name == "wlan":
71
+ num_disabled = subsystem.get("num_disabled", 0)
72
+ if num_disabled > 0:
73
+ issues.append(f"WLAN: {num_disabled} AP(s) disabled")
74
+
75
+ # WAN specific checks
76
+ if sub_name == "wan":
77
+ if subsystem.get("gw_wan_uptime", float("inf")) < 3600:
78
+ uptime = subsystem.get("gw_wan_uptime", 0)
79
+ issues.append(f"WAN: Connection recently restored ({uptime // 60} min ago)")
80
+
81
+ # LAN specific checks
82
+ if sub_name == "lan":
83
+ num_sw = subsystem.get("num_sw", 0)
84
+ num_adopted = subsystem.get("num_adopted", 0)
85
+ if num_sw > 0 and num_adopted < num_sw:
86
+ pending = num_sw - num_adopted
87
+ issues.append(f"LAN: {pending} switch(es) not adopted")
88
+
89
+ return issues
90
+
91
+
92
+ @app.callback(invoke_without_command=True)
93
+ def health(
94
+ ctx: typer.Context,
95
+ output: Annotated[
96
+ OutputFormat,
97
+ typer.Option("--output", "-o", help="Output format"),
98
+ ] = OutputFormat.TABLE,
99
+ verbose: Annotated[
100
+ bool,
101
+ typer.Option("--verbose", "-v", help="Show additional details"),
102
+ ] = False,
103
+ ) -> None:
104
+ """Show site health summary."""
105
+ if ctx.invoked_subcommand is not None:
106
+ return
107
+
108
+ async def _health():
109
+ client = UniFiLocalClient()
110
+ return await client.get_health()
111
+
112
+ try:
113
+ health_data = run_with_spinner(_health(), "Checking health...")
114
+ except LocalAPIError as e:
115
+ print_error(str(e))
116
+ raise typer.Exit(1)
117
+
118
+ if not health_data:
119
+ console.print("[dim]No health data available[/dim]")
120
+ return
121
+
122
+ if output == OutputFormat.JSON:
123
+ output_json(health_data)
124
+ return
125
+
126
+ # Table output
127
+ from rich.table import Table
128
+
129
+ console.print()
130
+ console.print("[bold cyan]Site Health[/bold cyan]")
131
+ console.print("─" * 40)
132
+ console.print()
133
+
134
+ table = Table(show_header=True, header_style="bold", box=None)
135
+ table.add_column("Subsystem", style="cyan")
136
+ table.add_column("Status")
137
+ if verbose:
138
+ table.add_column("Details", style="dim")
139
+
140
+ overall_ok = True
141
+ overall_warning = False
142
+
143
+ for subsystem in health_data:
144
+ name = format_subsystem_name(subsystem.get("subsystem", "unknown"))
145
+ status = subsystem.get("status", "unknown")
146
+ indicator, style = get_status_indicator(status)
147
+
148
+ if status.lower() not in ("ok", ""):
149
+ if style == "red":
150
+ overall_ok = False
151
+ elif style == "yellow":
152
+ overall_warning = True
153
+
154
+ status_display = f"{indicator} [{style}]{status.upper()}[/{style}]"
155
+
156
+ if verbose:
157
+ # Build details string
158
+ details = []
159
+ if "num_user" in subsystem:
160
+ details.append(f"{subsystem['num_user']} users")
161
+ if "num_sta" in subsystem:
162
+ details.append(f"{subsystem['num_sta']} clients")
163
+ if "num_ap" in subsystem:
164
+ details.append(f"{subsystem['num_ap']} APs")
165
+ if "tx_bytes-r" in subsystem:
166
+ tx = subsystem.get("tx_bytes-r", 0) / 1024 / 1024
167
+ rx = subsystem.get("rx_bytes-r", 0) / 1024 / 1024
168
+ details.append(f"↑{tx:.1f} ↓{rx:.1f} MB/s")
169
+ if "latency" in subsystem:
170
+ details.append(f"{subsystem['latency']}ms latency")
171
+
172
+ table.add_row(name, status_display, ", ".join(details) if details else "-")
173
+ else:
174
+ table.add_row(name, status_display)
175
+
176
+ console.print(table)
177
+
178
+ # Overall status
179
+ console.print()
180
+ if overall_ok and not overall_warning:
181
+ console.print(" Overall: [green]🟢 Healthy[/green]")
182
+ elif overall_warning:
183
+ console.print(" Overall: [yellow]🟡 Warning[/yellow]")
184
+ else:
185
+ console.print(" Overall: [red]🔴 Issues Detected[/red]")
186
+
187
+ # Show issues if any
188
+ issues = extract_issues(health_data)
189
+ if issues:
190
+ console.print()
191
+ console.print(" [bold]Notes:[/bold]")
192
+ for issue in issues:
193
+ console.print(f" • {issue}")
194
+
195
+ console.print()