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/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""UniFi Site Manager CLI - Manage your UniFi infrastructure from the command line."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _version_file_path() -> Path:
|
|
8
|
+
"""Return the repository version file path when running from a source checkout."""
|
|
9
|
+
return Path(__file__).resolve().parents[2] / "VERSION"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read_version_file() -> str:
|
|
13
|
+
"""Read the repository version during local source execution."""
|
|
14
|
+
version_file = _version_file_path()
|
|
15
|
+
if version_file.exists():
|
|
16
|
+
return version_file.read_text(encoding="utf-8").strip()
|
|
17
|
+
return "0.0.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _detect_version() -> str:
|
|
21
|
+
"""Prefer the checkout version file and fall back to installed package metadata."""
|
|
22
|
+
version_file = _version_file_path()
|
|
23
|
+
if version_file.exists():
|
|
24
|
+
return version_file.read_text(encoding="utf-8").strip()
|
|
25
|
+
try:
|
|
26
|
+
return version("ui-cli")
|
|
27
|
+
except PackageNotFoundError:
|
|
28
|
+
return "0.0.0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__version__ = _detect_version()
|
ui_cli/client.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Async HTTP client for UniFi Site Manager API."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ui_cli.config import settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIError(Exception):
|
|
11
|
+
"""Base exception for API errors."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, message: str, status_code: int | None = None):
|
|
14
|
+
self.message = message
|
|
15
|
+
self.status_code = status_code
|
|
16
|
+
super().__init__(self.message)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AuthenticationError(APIError):
|
|
20
|
+
"""Raised when API key is invalid or missing."""
|
|
21
|
+
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RateLimitError(APIError):
|
|
26
|
+
"""Raised when rate limit is exceeded."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, retry_after: int | None = None):
|
|
29
|
+
super().__init__(message, status_code=429)
|
|
30
|
+
self.retry_after = retry_after
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class UniFiClient:
|
|
34
|
+
"""Async client for UniFi Site Manager API."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
api_key: str | None = None,
|
|
39
|
+
base_url: str | None = None,
|
|
40
|
+
timeout: int | None = None,
|
|
41
|
+
):
|
|
42
|
+
self.api_key = api_key or settings.api_key
|
|
43
|
+
self.base_url = (base_url or settings.api_url).rstrip("/")
|
|
44
|
+
self.timeout = timeout or settings.timeout
|
|
45
|
+
|
|
46
|
+
if not self.api_key:
|
|
47
|
+
raise AuthenticationError(
|
|
48
|
+
"API key not configured. Set UNIFI_API_KEY environment variable or create a .env file."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def _get_headers(self) -> dict[str, str]:
|
|
52
|
+
"""Get request headers with authentication."""
|
|
53
|
+
return {
|
|
54
|
+
"X-API-Key": self.api_key,
|
|
55
|
+
"Accept": "application/json",
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async def _request(
|
|
60
|
+
self,
|
|
61
|
+
method: str,
|
|
62
|
+
endpoint: str,
|
|
63
|
+
params: dict[str, Any] | None = None,
|
|
64
|
+
) -> dict[str, Any]:
|
|
65
|
+
"""Make an authenticated request to the API."""
|
|
66
|
+
url = f"{self.base_url}/{endpoint.lstrip('/')}"
|
|
67
|
+
|
|
68
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
69
|
+
response = await client.request(
|
|
70
|
+
method=method,
|
|
71
|
+
url=url,
|
|
72
|
+
headers=self._get_headers(),
|
|
73
|
+
params=params,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Handle errors
|
|
77
|
+
if response.status_code == 401:
|
|
78
|
+
raise AuthenticationError("Invalid API key", status_code=401)
|
|
79
|
+
elif response.status_code == 429:
|
|
80
|
+
retry_after = response.headers.get("Retry-After")
|
|
81
|
+
raise RateLimitError(
|
|
82
|
+
"Rate limit exceeded",
|
|
83
|
+
retry_after=int(retry_after) if retry_after else None,
|
|
84
|
+
)
|
|
85
|
+
elif response.status_code >= 400:
|
|
86
|
+
raise APIError(
|
|
87
|
+
f"API error: {response.text}",
|
|
88
|
+
status_code=response.status_code,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return response.json()
|
|
92
|
+
|
|
93
|
+
async def get(
|
|
94
|
+
self,
|
|
95
|
+
endpoint: str,
|
|
96
|
+
params: dict[str, Any] | None = None,
|
|
97
|
+
) -> dict[str, Any]:
|
|
98
|
+
"""Make a GET request."""
|
|
99
|
+
return await self._request("GET", endpoint, params=params)
|
|
100
|
+
|
|
101
|
+
# ========== Hosts ==========
|
|
102
|
+
|
|
103
|
+
async def list_hosts(self) -> list[dict[str, Any]]:
|
|
104
|
+
"""List all hosts associated with the account."""
|
|
105
|
+
response = await self.get("/hosts")
|
|
106
|
+
return response.get("data", [])
|
|
107
|
+
|
|
108
|
+
async def get_host(self, host_id: str) -> dict[str, Any]:
|
|
109
|
+
"""Get details for a specific host."""
|
|
110
|
+
response = await self.get(f"/hosts/{host_id}")
|
|
111
|
+
return response.get("data", {})
|
|
112
|
+
|
|
113
|
+
# ========== Sites ==========
|
|
114
|
+
|
|
115
|
+
async def list_sites(self) -> list[dict[str, Any]]:
|
|
116
|
+
"""List all sites."""
|
|
117
|
+
response = await self.get("/sites")
|
|
118
|
+
return response.get("data", [])
|
|
119
|
+
|
|
120
|
+
# ========== Devices ==========
|
|
121
|
+
|
|
122
|
+
async def list_devices_raw(
|
|
123
|
+
self,
|
|
124
|
+
host_ids: list[str] | None = None,
|
|
125
|
+
) -> list[dict[str, Any]]:
|
|
126
|
+
"""List all devices grouped by host (raw API response)."""
|
|
127
|
+
params = {}
|
|
128
|
+
if host_ids:
|
|
129
|
+
params["hostIds[]"] = host_ids
|
|
130
|
+
response = await self.get("/devices", params=params if params else None)
|
|
131
|
+
return response.get("data", [])
|
|
132
|
+
|
|
133
|
+
async def list_devices(
|
|
134
|
+
self,
|
|
135
|
+
host_ids: list[str] | None = None,
|
|
136
|
+
) -> list[dict[str, Any]]:
|
|
137
|
+
"""List all devices, flattened with host info included."""
|
|
138
|
+
raw_data = await self.list_devices_raw(host_ids=host_ids)
|
|
139
|
+
|
|
140
|
+
# Flatten: API returns devices grouped by host
|
|
141
|
+
devices = []
|
|
142
|
+
for host_group in raw_data:
|
|
143
|
+
host_id = host_group.get("hostId", "")
|
|
144
|
+
host_name = host_group.get("hostName", "")
|
|
145
|
+
for device in host_group.get("devices", []):
|
|
146
|
+
# Add host info to each device
|
|
147
|
+
device["hostId"] = host_id
|
|
148
|
+
device["hostName"] = host_name
|
|
149
|
+
devices.append(device)
|
|
150
|
+
|
|
151
|
+
return devices
|
|
152
|
+
|
|
153
|
+
# ========== ISP Metrics (Early Access API) ==========
|
|
154
|
+
|
|
155
|
+
async def get_isp_metrics(
|
|
156
|
+
self,
|
|
157
|
+
metric_type: str = "1h",
|
|
158
|
+
duration_hours: int | None = None,
|
|
159
|
+
) -> list[dict[str, Any]]:
|
|
160
|
+
"""Get ISP metrics.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
metric_type: Either '5m' (24h retention) or '1h' (30d retention)
|
|
164
|
+
duration_hours: Hours of data to retrieve. Defaults to 24 for 5m, 168 (7 days) for 1h
|
|
165
|
+
|
|
166
|
+
Note: Uses Early Access API endpoint (/ea/)
|
|
167
|
+
"""
|
|
168
|
+
from datetime import datetime, timedelta, timezone
|
|
169
|
+
|
|
170
|
+
# Set default duration based on metric type
|
|
171
|
+
if duration_hours is None:
|
|
172
|
+
duration_hours = 24 if metric_type == "5m" else 168 # 7 days for hourly
|
|
173
|
+
|
|
174
|
+
# Calculate timestamps
|
|
175
|
+
end_time = datetime.now(timezone.utc)
|
|
176
|
+
begin_time = end_time - timedelta(hours=duration_hours)
|
|
177
|
+
|
|
178
|
+
params = {
|
|
179
|
+
"beginTimestamp": begin_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
180
|
+
"endTimestamp": end_time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# ISP metrics uses EA endpoint, not v1
|
|
184
|
+
ea_url = self.base_url.replace("/v1", "/ea")
|
|
185
|
+
url = f"{ea_url}/isp-metrics/{metric_type}"
|
|
186
|
+
|
|
187
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
188
|
+
response = await client.get(url, headers=self._get_headers(), params=params)
|
|
189
|
+
|
|
190
|
+
if response.status_code == 401:
|
|
191
|
+
raise AuthenticationError("Invalid API key", status_code=401)
|
|
192
|
+
elif response.status_code == 429:
|
|
193
|
+
retry_after = response.headers.get("Retry-After")
|
|
194
|
+
raise RateLimitError(
|
|
195
|
+
"Rate limit exceeded",
|
|
196
|
+
retry_after=int(retry_after) if retry_after else None,
|
|
197
|
+
)
|
|
198
|
+
elif response.status_code >= 400:
|
|
199
|
+
raise APIError(
|
|
200
|
+
f"API error: {response.text}",
|
|
201
|
+
status_code=response.status_code,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
raw_data = response.json().get("data", [])
|
|
205
|
+
|
|
206
|
+
# Flatten the nested structure
|
|
207
|
+
metrics = []
|
|
208
|
+
for site_data in raw_data:
|
|
209
|
+
site_id = site_data.get("siteId", "")
|
|
210
|
+
host_id = site_data.get("hostId", "")
|
|
211
|
+
for period in site_data.get("periods", []):
|
|
212
|
+
wan_data = period.get("data", {}).get("wan", {})
|
|
213
|
+
metrics.append({
|
|
214
|
+
"siteId": site_id,
|
|
215
|
+
"hostId": host_id,
|
|
216
|
+
"timestamp": period.get("metricTime", ""),
|
|
217
|
+
"avgLatency": wan_data.get("avgLatency"),
|
|
218
|
+
"maxLatency": wan_data.get("maxLatency"),
|
|
219
|
+
"downloadKbps": wan_data.get("download_kbps"),
|
|
220
|
+
"uploadKbps": wan_data.get("upload_kbps"),
|
|
221
|
+
"uptime": wan_data.get("uptime"),
|
|
222
|
+
"downtime": wan_data.get("downtime"),
|
|
223
|
+
"packetLoss": wan_data.get("packetLoss"),
|
|
224
|
+
"ispName": wan_data.get("ispName"),
|
|
225
|
+
"ispAsn": wan_data.get("ispAsn"),
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
return metrics
|
|
229
|
+
|
|
230
|
+
# ========== SD-WAN (Early Access API) ==========
|
|
231
|
+
|
|
232
|
+
async def _ea_get(self, endpoint: str) -> dict[str, Any]:
|
|
233
|
+
"""Make a GET request to the Early Access API."""
|
|
234
|
+
ea_url = self.base_url.replace("/v1", "/ea")
|
|
235
|
+
url = f"{ea_url}/{endpoint.lstrip('/')}"
|
|
236
|
+
|
|
237
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
238
|
+
response = await client.get(url, headers=self._get_headers())
|
|
239
|
+
|
|
240
|
+
if response.status_code == 401:
|
|
241
|
+
raise AuthenticationError("Invalid API key", status_code=401)
|
|
242
|
+
elif response.status_code == 429:
|
|
243
|
+
retry_after = response.headers.get("Retry-After")
|
|
244
|
+
raise RateLimitError(
|
|
245
|
+
"Rate limit exceeded",
|
|
246
|
+
retry_after=int(retry_after) if retry_after else None,
|
|
247
|
+
)
|
|
248
|
+
elif response.status_code >= 400:
|
|
249
|
+
raise APIError(
|
|
250
|
+
f"API error: {response.text}",
|
|
251
|
+
status_code=response.status_code,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return response.json()
|
|
255
|
+
|
|
256
|
+
async def list_sdwan_configs(self) -> list[dict[str, Any]]:
|
|
257
|
+
"""List all SD-WAN configurations."""
|
|
258
|
+
response = await self._ea_get("/sd-wan-configs")
|
|
259
|
+
return response.get("data", [])
|
|
260
|
+
|
|
261
|
+
async def get_sdwan_config(self, config_id: str) -> dict[str, Any]:
|
|
262
|
+
"""Get details for a specific SD-WAN configuration."""
|
|
263
|
+
response = await self._ea_get(f"/sd-wan-configs/{config_id}")
|
|
264
|
+
return response.get("data", {})
|
|
265
|
+
|
|
266
|
+
async def get_sdwan_status(self, config_id: str) -> dict[str, Any]:
|
|
267
|
+
"""Get deployment status for a specific SD-WAN configuration."""
|
|
268
|
+
response = await self._ea_get(f"/sd-wan-configs/{config_id}/status")
|
|
269
|
+
return response.get("data", {})
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules."""
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Device management commands."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ui_cli.client import APIError, UniFiClient
|
|
11
|
+
from ui_cli.output import (
|
|
12
|
+
OutputFormat,
|
|
13
|
+
output_count_table,
|
|
14
|
+
output_csv,
|
|
15
|
+
output_json,
|
|
16
|
+
print_error,
|
|
17
|
+
render_output,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
app = typer.Typer(help="Manage UniFi devices")
|
|
21
|
+
|
|
22
|
+
# Column definitions for devices table
|
|
23
|
+
DEVICE_COLUMNS = [
|
|
24
|
+
("id", "ID"),
|
|
25
|
+
("name", "Name"),
|
|
26
|
+
("model", "Model"),
|
|
27
|
+
("ip", "IP Address"),
|
|
28
|
+
("mac", "MAC"),
|
|
29
|
+
("productLine", "Product Line"),
|
|
30
|
+
("status", "Status"),
|
|
31
|
+
("version", "Version"),
|
|
32
|
+
("hostName", "Host"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GroupBy(str, Enum):
|
|
37
|
+
"""Device grouping options."""
|
|
38
|
+
|
|
39
|
+
HOST = "host"
|
|
40
|
+
MODEL = "model"
|
|
41
|
+
STATUS = "status"
|
|
42
|
+
PRODUCT_LINE = "product-line"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.command("list")
|
|
46
|
+
def list_devices(
|
|
47
|
+
host: Annotated[
|
|
48
|
+
str | None,
|
|
49
|
+
typer.Option(
|
|
50
|
+
"--host",
|
|
51
|
+
"-H",
|
|
52
|
+
help="Filter devices by host ID",
|
|
53
|
+
),
|
|
54
|
+
] = None,
|
|
55
|
+
output: Annotated[
|
|
56
|
+
OutputFormat,
|
|
57
|
+
typer.Option(
|
|
58
|
+
"--output",
|
|
59
|
+
"-o",
|
|
60
|
+
help="Output format: table, json, or csv",
|
|
61
|
+
),
|
|
62
|
+
] = OutputFormat.TABLE,
|
|
63
|
+
verbose: Annotated[
|
|
64
|
+
bool,
|
|
65
|
+
typer.Option(
|
|
66
|
+
"--verbose",
|
|
67
|
+
"-v",
|
|
68
|
+
help="Show detailed request/response information",
|
|
69
|
+
),
|
|
70
|
+
] = False,
|
|
71
|
+
) -> None:
|
|
72
|
+
"""List all UniFi devices managed by your hosts."""
|
|
73
|
+
|
|
74
|
+
async def _list() -> list:
|
|
75
|
+
client = UniFiClient()
|
|
76
|
+
host_ids = [host] if host else None
|
|
77
|
+
return await client.list_devices(host_ids=host_ids)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
devices = asyncio.run(_list())
|
|
81
|
+
|
|
82
|
+
if verbose:
|
|
83
|
+
typer.echo(f"Found {len(devices)} device(s)")
|
|
84
|
+
|
|
85
|
+
render_output(
|
|
86
|
+
data=devices,
|
|
87
|
+
output_format=output,
|
|
88
|
+
columns=DEVICE_COLUMNS,
|
|
89
|
+
title="UniFi Devices",
|
|
90
|
+
verbose=verbose,
|
|
91
|
+
)
|
|
92
|
+
except APIError as e:
|
|
93
|
+
print_error(e.message)
|
|
94
|
+
raise typer.Exit(1)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command("count")
|
|
98
|
+
def count_devices(
|
|
99
|
+
by: Annotated[
|
|
100
|
+
GroupBy | None,
|
|
101
|
+
typer.Option(
|
|
102
|
+
"--by",
|
|
103
|
+
"-b",
|
|
104
|
+
help="Group count by: host, model, status, or product-line",
|
|
105
|
+
),
|
|
106
|
+
] = None,
|
|
107
|
+
host: Annotated[
|
|
108
|
+
str | None,
|
|
109
|
+
typer.Option(
|
|
110
|
+
"--host",
|
|
111
|
+
"-H",
|
|
112
|
+
help="Filter devices by host ID",
|
|
113
|
+
),
|
|
114
|
+
] = None,
|
|
115
|
+
output: Annotated[
|
|
116
|
+
OutputFormat,
|
|
117
|
+
typer.Option(
|
|
118
|
+
"--output",
|
|
119
|
+
"-o",
|
|
120
|
+
help="Output format: table, json, or csv",
|
|
121
|
+
),
|
|
122
|
+
] = OutputFormat.TABLE,
|
|
123
|
+
verbose: Annotated[
|
|
124
|
+
bool,
|
|
125
|
+
typer.Option(
|
|
126
|
+
"--verbose",
|
|
127
|
+
"-v",
|
|
128
|
+
help="Show detailed request/response information",
|
|
129
|
+
),
|
|
130
|
+
] = False,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""Count devices with optional grouping."""
|
|
133
|
+
|
|
134
|
+
async def _list() -> list:
|
|
135
|
+
client = UniFiClient()
|
|
136
|
+
host_ids = [host] if host else None
|
|
137
|
+
return await client.list_devices(host_ids=host_ids)
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
devices = asyncio.run(_list())
|
|
141
|
+
|
|
142
|
+
if by is None:
|
|
143
|
+
# Simple total count
|
|
144
|
+
count_data = {"total": len(devices)}
|
|
145
|
+
|
|
146
|
+
if output == OutputFormat.JSON:
|
|
147
|
+
output_json(count_data, verbose=verbose)
|
|
148
|
+
elif output == OutputFormat.CSV:
|
|
149
|
+
output_csv([count_data])
|
|
150
|
+
else:
|
|
151
|
+
typer.echo(f"Total devices: {len(devices)}")
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
# Group by specified field
|
|
155
|
+
key_map = {
|
|
156
|
+
GroupBy.HOST: ("hostName", "Host"),
|
|
157
|
+
GroupBy.MODEL: ("model", "Model"),
|
|
158
|
+
GroupBy.STATUS: ("status", "Status"),
|
|
159
|
+
GroupBy.PRODUCT_LINE: ("productLine", "Product Line"),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
field_key, header = key_map[by]
|
|
163
|
+
counts: Counter = Counter()
|
|
164
|
+
|
|
165
|
+
for device in devices:
|
|
166
|
+
value = device.get(field_key, "Unknown") or "Unknown"
|
|
167
|
+
counts[value] += 1
|
|
168
|
+
|
|
169
|
+
if output == OutputFormat.JSON:
|
|
170
|
+
output_json(dict(counts), verbose=verbose)
|
|
171
|
+
elif output == OutputFormat.CSV:
|
|
172
|
+
csv_data = [{header: k, "Count": v} for k, v in counts.items()]
|
|
173
|
+
output_csv(csv_data)
|
|
174
|
+
else:
|
|
175
|
+
output_count_table(
|
|
176
|
+
counts=dict(counts),
|
|
177
|
+
group_header=header,
|
|
178
|
+
count_header="Count",
|
|
179
|
+
title=f"Device Count by {header}",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if verbose:
|
|
183
|
+
typer.echo(f"Total: {sum(counts.values())} device(s)")
|
|
184
|
+
|
|
185
|
+
except APIError as e:
|
|
186
|
+
print_error(e.message)
|
|
187
|
+
raise typer.Exit(1)
|