systemlink-cli 1.3.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.
- slcli/__init__.py +1 -0
- slcli/__main__.py +23 -0
- slcli/_version.py +4 -0
- slcli/asset_click.py +1289 -0
- slcli/cli_formatters.py +218 -0
- slcli/cli_utils.py +504 -0
- slcli/comment_click.py +602 -0
- slcli/completion_click.py +418 -0
- slcli/config.py +81 -0
- slcli/config_click.py +498 -0
- slcli/dff_click.py +979 -0
- slcli/dff_decorators.py +24 -0
- slcli/example_click.py +404 -0
- slcli/example_loader.py +274 -0
- slcli/example_provisioner.py +2777 -0
- slcli/examples/README.md +134 -0
- slcli/examples/_schema/schema-v1.0.json +169 -0
- slcli/examples/demo-complete-workflow/README.md +323 -0
- slcli/examples/demo-complete-workflow/config.yaml +638 -0
- slcli/examples/demo-test-plans/README.md +132 -0
- slcli/examples/demo-test-plans/config.yaml +154 -0
- slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
- slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
- slcli/examples/exercise-7-1-test-plans/README.md +93 -0
- slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
- slcli/examples/spec-compliance-notebooks/README.md +140 -0
- slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
- slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
- slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
- slcli/feed_click.py +892 -0
- slcli/file_click.py +932 -0
- slcli/function_click.py +1400 -0
- slcli/function_templates.py +85 -0
- slcli/main.py +406 -0
- slcli/mcp_click.py +269 -0
- slcli/mcp_server.py +748 -0
- slcli/notebook_click.py +1770 -0
- slcli/platform.py +345 -0
- slcli/policy_click.py +679 -0
- slcli/policy_utils.py +411 -0
- slcli/profiles.py +411 -0
- slcli/response_handlers.py +359 -0
- slcli/routine_click.py +763 -0
- slcli/skill_click.py +253 -0
- slcli/skills/slcli/SKILL.md +713 -0
- slcli/skills/slcli/references/analysis-recipes.md +474 -0
- slcli/skills/slcli/references/filtering.md +236 -0
- slcli/skills/systemlink-webapp/SKILL.md +744 -0
- slcli/skills/systemlink-webapp/references/deployment.md +123 -0
- slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
- slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
- slcli/ssl_trust.py +93 -0
- slcli/system_click.py +2216 -0
- slcli/table_utils.py +124 -0
- slcli/tag_click.py +794 -0
- slcli/templates_click.py +599 -0
- slcli/testmonitor_click.py +1667 -0
- slcli/universal_handlers.py +305 -0
- slcli/user_click.py +1218 -0
- slcli/utils.py +832 -0
- slcli/web_editor.py +295 -0
- slcli/webapp_click.py +981 -0
- slcli/workflow_preview.py +287 -0
- slcli/workflows_click.py +988 -0
- slcli/workitem_click.py +2258 -0
- slcli/workspace_click.py +576 -0
- slcli/workspace_utils.py +206 -0
- systemlink_cli-1.3.1.dist-info/METADATA +20 -0
- systemlink_cli-1.3.1.dist-info/RECORD +74 -0
- systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
- systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
- systemlink_cli-1.3.1.dist-info/licenses/LICENSE +21 -0
slcli/system_click.py
ADDED
|
@@ -0,0 +1,2216 @@
|
|
|
1
|
+
"""CLI commands for managing SystemLink systems.
|
|
2
|
+
|
|
3
|
+
Provides CLI commands for listing, querying, and managing systems in the
|
|
4
|
+
Systems Management service (nisysmgmt v1). Supports filtering by alias,
|
|
5
|
+
connection state, OS, installed packages, keywords, and properties.
|
|
6
|
+
Also provides job management and system metadata updates.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import concurrent.futures
|
|
10
|
+
import datetime
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
import questionary
|
|
19
|
+
|
|
20
|
+
from .cli_utils import validate_output_format
|
|
21
|
+
from .universal_handlers import FilteredResponse, UniversalResponseHandler
|
|
22
|
+
from .utils import (
|
|
23
|
+
ExitCodes,
|
|
24
|
+
check_readonly_mode,
|
|
25
|
+
format_success,
|
|
26
|
+
get_base_url,
|
|
27
|
+
get_workspace_map,
|
|
28
|
+
handle_api_error,
|
|
29
|
+
make_api_request,
|
|
30
|
+
)
|
|
31
|
+
from .workspace_utils import (
|
|
32
|
+
get_effective_workspace,
|
|
33
|
+
get_workspace_display_name,
|
|
34
|
+
resolve_workspace_filter,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _get_sysmgmt_base_url() -> str:
|
|
39
|
+
"""Get the base URL for the Systems Management API."""
|
|
40
|
+
return f"{get_base_url()}/nisysmgmt/v1"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_apm_base_url() -> str:
|
|
44
|
+
"""Get the base URL for the Asset Performance Management API."""
|
|
45
|
+
return f"{get_base_url()}/niapm/v1"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _get_alarm_base_url() -> str:
|
|
49
|
+
"""Get the base URL for the Alarm Management API."""
|
|
50
|
+
return f"{get_base_url()}/nialarm/v1"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _get_testmonitor_base_url() -> str:
|
|
54
|
+
"""Get the base URL for the Test Monitor API."""
|
|
55
|
+
return f"{get_base_url()}/nitestmonitor/v2"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _get_workitem_base_url() -> str:
|
|
59
|
+
"""Get the base URL for the Work Items API."""
|
|
60
|
+
return f"{get_base_url()}/niworkitem/v1"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Projection for list queries — only include fields needed for display.
|
|
64
|
+
# This dramatically reduces response payload size.
|
|
65
|
+
# Uses the dot-path ``as`` alias syntax supported by the systems API.
|
|
66
|
+
_LIST_PROJECTION = (
|
|
67
|
+
"new(id, alias, workspace, "
|
|
68
|
+
"connected.data.state as connected, "
|
|
69
|
+
"grains.data.kernel as kernel, "
|
|
70
|
+
"grains.data.osversion as osversion, "
|
|
71
|
+
"grains.data.host as host, "
|
|
72
|
+
"grains.data.cpuarch as cpuarch, "
|
|
73
|
+
"grains.data.deviceclass as deviceclass, "
|
|
74
|
+
"keywords.data as keywords, "
|
|
75
|
+
"packages.data as packages)"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _calculate_column_widths() -> List[int]:
|
|
80
|
+
"""Calculate dynamic column widths based on terminal size.
|
|
81
|
+
|
|
82
|
+
The ID column expands to fill available terminal width.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of column widths: [alias, host, state, os, workspace, id]
|
|
86
|
+
"""
|
|
87
|
+
# Get terminal width, default to 120 if detection fails
|
|
88
|
+
try:
|
|
89
|
+
terminal_width = shutil.get_terminal_size().columns
|
|
90
|
+
except Exception:
|
|
91
|
+
terminal_width = 120
|
|
92
|
+
|
|
93
|
+
# Fixed widths for non-ID columns
|
|
94
|
+
alias_width = 24
|
|
95
|
+
host_width = 18
|
|
96
|
+
state_width = 14
|
|
97
|
+
os_width = 10
|
|
98
|
+
workspace_width = 16
|
|
99
|
+
|
|
100
|
+
# Account for table borders and padding for 6 columns.
|
|
101
|
+
# Row layout: "│ c1 │ c2 │ c3 │ c4 │ c5 │ c6 │" = 7 bars + 12 spaces = 19
|
|
102
|
+
border_overhead = 19
|
|
103
|
+
|
|
104
|
+
# Calculate remaining space for ID column
|
|
105
|
+
fixed_columns = alias_width + host_width + state_width + os_width + workspace_width
|
|
106
|
+
id_width = terminal_width - fixed_columns - border_overhead
|
|
107
|
+
|
|
108
|
+
# Ensure minimum ID width of 20, maximum of 80
|
|
109
|
+
id_width = max(20, min(80, id_width))
|
|
110
|
+
|
|
111
|
+
return [alias_width, host_width, state_width, os_width, workspace_width, id_width]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _calculate_job_column_widths() -> List[int]:
|
|
115
|
+
"""Calculate dynamic column widths for job list based on terminal size.
|
|
116
|
+
|
|
117
|
+
The Target System column expands to fill available terminal width.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
List of column widths: [jid, state, created, target]
|
|
121
|
+
"""
|
|
122
|
+
try:
|
|
123
|
+
terminal_width = shutil.get_terminal_size().columns
|
|
124
|
+
except Exception:
|
|
125
|
+
terminal_width = 120
|
|
126
|
+
|
|
127
|
+
# Fixed widths for non-target columns
|
|
128
|
+
jid_width = 36
|
|
129
|
+
state_width = 14
|
|
130
|
+
created_width = 24
|
|
131
|
+
|
|
132
|
+
# Account for table borders and padding for 4 columns.
|
|
133
|
+
# Row layout: "│ c1 │ c2 │ c3 │ c4 │" = 5 bars + 8 spaces = 13
|
|
134
|
+
border_overhead = 13
|
|
135
|
+
|
|
136
|
+
# Calculate remaining space for Target System column
|
|
137
|
+
fixed_columns = jid_width + state_width + created_width
|
|
138
|
+
target_width = terminal_width - fixed_columns - border_overhead
|
|
139
|
+
|
|
140
|
+
# Ensure minimum target width of 20, maximum of 80
|
|
141
|
+
target_width = max(20, min(80, target_width))
|
|
142
|
+
|
|
143
|
+
return [jid_width, state_width, created_width, target_width]
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _escape_filter_value(value: str) -> str:
|
|
147
|
+
"""Escape double quotes in filter values to prevent injection.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
value: Raw filter value from user input.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Escaped value safe for embedding in filter expressions.
|
|
154
|
+
"""
|
|
155
|
+
return value.replace('"', '\\"')
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _parse_properties(properties: Tuple[str, ...]) -> Dict[str, str]:
|
|
159
|
+
"""Parse key=value property strings into a dictionary.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
properties: Tuple of strings in "key=value" format.
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Dictionary mapping property keys to values.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
SystemExit: If any property string is not in key=value format.
|
|
169
|
+
"""
|
|
170
|
+
props_dict: Dict[str, str] = {}
|
|
171
|
+
for prop in properties:
|
|
172
|
+
if "=" not in prop:
|
|
173
|
+
click.echo(
|
|
174
|
+
f"✗ Invalid property format: {prop}. Use key=value",
|
|
175
|
+
err=True,
|
|
176
|
+
)
|
|
177
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
178
|
+
key, val = prop.split("=", 1)
|
|
179
|
+
props_dict[key.strip()] = val.strip()
|
|
180
|
+
return props_dict
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _build_system_filter(
|
|
184
|
+
alias: Optional[str] = None,
|
|
185
|
+
state: Optional[str] = None,
|
|
186
|
+
os_filter: Optional[str] = None,
|
|
187
|
+
host: Optional[str] = None,
|
|
188
|
+
has_keyword: Optional[Tuple[str, ...]] = None,
|
|
189
|
+
property_filters: Optional[Tuple[str, ...]] = None,
|
|
190
|
+
workspace_id: Optional[str] = None,
|
|
191
|
+
custom_filter: Optional[str] = None,
|
|
192
|
+
) -> Optional[str]:
|
|
193
|
+
"""Build API filter expression from convenience options.
|
|
194
|
+
|
|
195
|
+
Args:
|
|
196
|
+
alias: Filter by system alias (contains match).
|
|
197
|
+
state: Filter by connection state.
|
|
198
|
+
os_filter: Filter by OS kernel (contains match).
|
|
199
|
+
host: Filter by hostname (contains match).
|
|
200
|
+
has_keyword: Filter by keywords (systems must have these keywords).
|
|
201
|
+
property_filters: Filter by property key=value pairs.
|
|
202
|
+
workspace_id: Filter by workspace ID.
|
|
203
|
+
custom_filter: Advanced user-provided filter expression.
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
Combined filter expression string, or None if no filters.
|
|
207
|
+
"""
|
|
208
|
+
parts: List[str] = []
|
|
209
|
+
|
|
210
|
+
if alias:
|
|
211
|
+
escaped = _escape_filter_value(alias)
|
|
212
|
+
parts.append(f'alias.Contains("{escaped}")')
|
|
213
|
+
if state:
|
|
214
|
+
parts.append(f'connected.data.state = "{state}"')
|
|
215
|
+
if os_filter:
|
|
216
|
+
escaped = _escape_filter_value(os_filter)
|
|
217
|
+
parts.append(f'grains.data.kernel.Contains("{escaped}")')
|
|
218
|
+
if host:
|
|
219
|
+
escaped = _escape_filter_value(host)
|
|
220
|
+
parts.append(f'grains.data.host.Contains("{escaped}")')
|
|
221
|
+
if has_keyword:
|
|
222
|
+
for kw in has_keyword:
|
|
223
|
+
escaped = _escape_filter_value(kw)
|
|
224
|
+
parts.append(f'keywords.data.Contains("{escaped}")')
|
|
225
|
+
if property_filters:
|
|
226
|
+
for prop in property_filters:
|
|
227
|
+
if "=" not in prop:
|
|
228
|
+
click.echo(
|
|
229
|
+
f"✗ Invalid property filter '{prop}': expected KEY=VALUE format",
|
|
230
|
+
err=True,
|
|
231
|
+
)
|
|
232
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
233
|
+
key, val = prop.split("=", 1)
|
|
234
|
+
key = key.strip()
|
|
235
|
+
if not re.match(r"^[A-Za-z0-9_.]+$", key):
|
|
236
|
+
click.echo(
|
|
237
|
+
f"✗ Invalid property key '{key}': "
|
|
238
|
+
"only alphanumeric characters, underscores, and dots are allowed",
|
|
239
|
+
err=True,
|
|
240
|
+
)
|
|
241
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
242
|
+
escaped_val = _escape_filter_value(val.strip())
|
|
243
|
+
parts.append(f'properties.data.{key} = "{escaped_val}"')
|
|
244
|
+
if workspace_id:
|
|
245
|
+
escaped = _escape_filter_value(workspace_id)
|
|
246
|
+
parts.append(f'workspace = "{escaped}"')
|
|
247
|
+
if custom_filter:
|
|
248
|
+
parts.append(custom_filter)
|
|
249
|
+
|
|
250
|
+
return " and ".join(parts) if parts else None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _parse_systems_response(data: Any) -> List[Dict[str, Any]]:
|
|
254
|
+
"""Parse the systems query API response into a flat list.
|
|
255
|
+
|
|
256
|
+
The systems API returns a complex response that may be either:
|
|
257
|
+
- A list of ``{data: {...}, count: N}`` objects (one per system), or
|
|
258
|
+
- A dict with ``{data: [...], count: N}``.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
data: Raw JSON response from the systems query API.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Flat list of system dictionaries.
|
|
265
|
+
"""
|
|
266
|
+
items: List[Dict[str, Any]] = []
|
|
267
|
+
|
|
268
|
+
if isinstance(data, list):
|
|
269
|
+
for item in data:
|
|
270
|
+
if isinstance(item, dict):
|
|
271
|
+
inner = item.get("data", item)
|
|
272
|
+
if isinstance(inner, dict):
|
|
273
|
+
items.append(inner)
|
|
274
|
+
elif isinstance(inner, list):
|
|
275
|
+
items.extend(inner)
|
|
276
|
+
elif isinstance(data, dict):
|
|
277
|
+
inner = data.get("data", [])
|
|
278
|
+
if isinstance(inner, list):
|
|
279
|
+
items.extend(inner)
|
|
280
|
+
elif isinstance(inner, dict):
|
|
281
|
+
items.append(inner)
|
|
282
|
+
|
|
283
|
+
return items
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_simple_response(data: Any) -> List[Dict[str, Any]]:
|
|
287
|
+
"""Parse a simple query API response into a flat list.
|
|
288
|
+
|
|
289
|
+
Expects either ``{data: [...]}`` or a bare list.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
data: Raw JSON response.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Flat list of item dictionaries.
|
|
296
|
+
"""
|
|
297
|
+
if isinstance(data, dict):
|
|
298
|
+
inner = data.get("data", [])
|
|
299
|
+
if isinstance(inner, list):
|
|
300
|
+
return inner
|
|
301
|
+
elif isinstance(data, list):
|
|
302
|
+
return data
|
|
303
|
+
return []
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _query_all_items(
|
|
307
|
+
url: str,
|
|
308
|
+
filter_expr: Optional[str],
|
|
309
|
+
order_by: Optional[str],
|
|
310
|
+
response_parser: Any,
|
|
311
|
+
projection: Optional[str] = None,
|
|
312
|
+
take: Optional[int] = 10000,
|
|
313
|
+
) -> List[Dict[str, Any]]:
|
|
314
|
+
"""Query items using skip/take pagination.
|
|
315
|
+
|
|
316
|
+
Generic helper that works for both systems and jobs.
|
|
317
|
+
Fetches up to ``take`` items (default 10,000 for performance).
|
|
318
|
+
|
|
319
|
+
Args:
|
|
320
|
+
url: The API endpoint URL.
|
|
321
|
+
filter_expr: Optional API filter expression.
|
|
322
|
+
order_by: Field to order by.
|
|
323
|
+
response_parser: Callable that converts raw JSON into a list of dicts.
|
|
324
|
+
projection: Optional projection string for selecting fields.
|
|
325
|
+
take: Maximum number of items to fetch.
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
List of item objects (up to ``take`` count).
|
|
329
|
+
"""
|
|
330
|
+
all_items: List[Dict[str, Any]] = []
|
|
331
|
+
page_size = 100 # Use conservative batch size to avoid 500 errors
|
|
332
|
+
skip = 0
|
|
333
|
+
|
|
334
|
+
while True:
|
|
335
|
+
if take is not None:
|
|
336
|
+
remaining = take - len(all_items)
|
|
337
|
+
if remaining <= 0:
|
|
338
|
+
break
|
|
339
|
+
batch_size = min(page_size, remaining)
|
|
340
|
+
else:
|
|
341
|
+
batch_size = page_size
|
|
342
|
+
|
|
343
|
+
payload: Dict[str, Any] = {
|
|
344
|
+
"skip": skip,
|
|
345
|
+
"take": batch_size,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if filter_expr:
|
|
349
|
+
payload["filter"] = filter_expr
|
|
350
|
+
if order_by:
|
|
351
|
+
payload["orderBy"] = order_by
|
|
352
|
+
if projection:
|
|
353
|
+
payload["projection"] = projection
|
|
354
|
+
|
|
355
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
356
|
+
page_items = response_parser(resp.json())
|
|
357
|
+
page_count = len(page_items)
|
|
358
|
+
|
|
359
|
+
if page_count == 0:
|
|
360
|
+
break
|
|
361
|
+
|
|
362
|
+
all_items.extend(page_items)
|
|
363
|
+
skip += page_count
|
|
364
|
+
|
|
365
|
+
# Stop if we got fewer than requested (last page)
|
|
366
|
+
if page_count < batch_size:
|
|
367
|
+
break
|
|
368
|
+
if take is not None and len(all_items) >= take:
|
|
369
|
+
break
|
|
370
|
+
|
|
371
|
+
return all_items[:take] if take is not None else all_items
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _fetch_page(
|
|
375
|
+
url: str,
|
|
376
|
+
filter_expr: Optional[str],
|
|
377
|
+
order_by: Optional[str],
|
|
378
|
+
take: int,
|
|
379
|
+
skip: int,
|
|
380
|
+
response_parser: Any,
|
|
381
|
+
projection: Optional[str] = None,
|
|
382
|
+
) -> List[Dict[str, Any]]:
|
|
383
|
+
"""Fetch a single page of items from a query API.
|
|
384
|
+
|
|
385
|
+
Args:
|
|
386
|
+
url: The API endpoint URL.
|
|
387
|
+
filter_expr: Optional API filter expression.
|
|
388
|
+
order_by: Field to order by.
|
|
389
|
+
take: Number of items to fetch.
|
|
390
|
+
skip: Number of items to skip.
|
|
391
|
+
response_parser: Callable that converts raw JSON into a list of dicts.
|
|
392
|
+
projection: Optional projection string.
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
List of item objects for this page.
|
|
396
|
+
"""
|
|
397
|
+
payload: Dict[str, Any] = {
|
|
398
|
+
"skip": skip,
|
|
399
|
+
"take": take,
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if filter_expr:
|
|
403
|
+
payload["filter"] = filter_expr
|
|
404
|
+
if order_by:
|
|
405
|
+
payload["orderBy"] = order_by
|
|
406
|
+
if projection:
|
|
407
|
+
payload["projection"] = projection
|
|
408
|
+
|
|
409
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
410
|
+
return response_parser(resp.json())
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _handle_interactive_pagination(
|
|
414
|
+
url: str,
|
|
415
|
+
filter_expr: Optional[str],
|
|
416
|
+
order_by: Optional[str],
|
|
417
|
+
take: int,
|
|
418
|
+
formatter_func: Any,
|
|
419
|
+
headers: List[str],
|
|
420
|
+
column_widths: List[int],
|
|
421
|
+
empty_message: str,
|
|
422
|
+
item_label: str,
|
|
423
|
+
response_parser: Any,
|
|
424
|
+
client_filter: Any = None,
|
|
425
|
+
projection: Optional[str] = None,
|
|
426
|
+
) -> None:
|
|
427
|
+
"""Handle interactive skip/take pagination for table output.
|
|
428
|
+
|
|
429
|
+
Generic helper that works for both systems and jobs. The API does
|
|
430
|
+
not return a total count, so we use a skip-based approach: if a page
|
|
431
|
+
returns exactly ``take`` items, more may be available.
|
|
432
|
+
|
|
433
|
+
Args:
|
|
434
|
+
url: The API endpoint URL.
|
|
435
|
+
filter_expr: Optional API filter expression.
|
|
436
|
+
order_by: Field to order by.
|
|
437
|
+
take: Number of items per page.
|
|
438
|
+
formatter_func: Function to format each item for display.
|
|
439
|
+
headers: Column headers for the table.
|
|
440
|
+
column_widths: Column widths for the table.
|
|
441
|
+
empty_message: Message to display when no items are found.
|
|
442
|
+
item_label: Label for the items (e.g. "systems", "jobs").
|
|
443
|
+
response_parser: Callable that converts raw JSON into a list of dicts.
|
|
444
|
+
client_filter: Optional callable for client-side filtering of items.
|
|
445
|
+
projection: Optional projection string.
|
|
446
|
+
"""
|
|
447
|
+
from .table_utils import output_formatted_list
|
|
448
|
+
|
|
449
|
+
skip = 0
|
|
450
|
+
shown_count = 0
|
|
451
|
+
# When using client-side filtering we fetch larger batches, but use
|
|
452
|
+
# conservative size (100) to avoid HTTP 500 errors from the Systems API
|
|
453
|
+
fetch_size = 100 if client_filter else take
|
|
454
|
+
|
|
455
|
+
while True:
|
|
456
|
+
page_items = _fetch_page(
|
|
457
|
+
url,
|
|
458
|
+
filter_expr,
|
|
459
|
+
order_by,
|
|
460
|
+
fetch_size,
|
|
461
|
+
skip,
|
|
462
|
+
response_parser=response_parser,
|
|
463
|
+
projection=projection,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
if not page_items:
|
|
467
|
+
if shown_count == 0:
|
|
468
|
+
click.echo(empty_message)
|
|
469
|
+
break
|
|
470
|
+
|
|
471
|
+
page_was_full = len(page_items) >= fetch_size
|
|
472
|
+
|
|
473
|
+
# Client-side filtering (e.g. package name search)
|
|
474
|
+
if client_filter:
|
|
475
|
+
page_items = client_filter(page_items)
|
|
476
|
+
|
|
477
|
+
if not page_items:
|
|
478
|
+
skip += fetch_size
|
|
479
|
+
if not page_was_full:
|
|
480
|
+
# Last page from server and nothing matched
|
|
481
|
+
if shown_count == 0:
|
|
482
|
+
click.echo(empty_message)
|
|
483
|
+
break
|
|
484
|
+
continue
|
|
485
|
+
|
|
486
|
+
# Take only the page size worth of items for display
|
|
487
|
+
display_items = page_items[:take]
|
|
488
|
+
shown_count += len(display_items)
|
|
489
|
+
skip += fetch_size if client_filter else len(display_items)
|
|
490
|
+
|
|
491
|
+
output_formatted_list(
|
|
492
|
+
items=display_items,
|
|
493
|
+
output_format="table",
|
|
494
|
+
headers=headers,
|
|
495
|
+
row_formatter_func=formatter_func,
|
|
496
|
+
column_widths=column_widths,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
click.echo(f"\nShowing {shown_count} {item_label}")
|
|
500
|
+
|
|
501
|
+
# Flush stdout so the table is visible before prompting
|
|
502
|
+
try:
|
|
503
|
+
sys.stdout.flush()
|
|
504
|
+
except Exception:
|
|
505
|
+
# stdout may be closed or invalid (e.g., when piped); ignore flush errors
|
|
506
|
+
pass
|
|
507
|
+
|
|
508
|
+
# If the page was full, more may be available
|
|
509
|
+
if not page_was_full:
|
|
510
|
+
break
|
|
511
|
+
|
|
512
|
+
if not questionary.confirm(
|
|
513
|
+
"More results may be available. Show next set?", default=True
|
|
514
|
+
).ask():
|
|
515
|
+
break
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _filter_by_package(
|
|
519
|
+
systems: List[Dict[str, Any]],
|
|
520
|
+
package_search: str,
|
|
521
|
+
) -> List[Dict[str, Any]]:
|
|
522
|
+
"""Filter systems by installed package name (case-insensitive contains).
|
|
523
|
+
|
|
524
|
+
Args:
|
|
525
|
+
systems: List of system objects.
|
|
526
|
+
package_search: Package name to search for (case-insensitive).
|
|
527
|
+
|
|
528
|
+
Returns:
|
|
529
|
+
Filtered list of systems that have a matching package installed.
|
|
530
|
+
"""
|
|
531
|
+
search_lower = package_search.lower()
|
|
532
|
+
result: List[Dict[str, Any]] = []
|
|
533
|
+
for system in systems:
|
|
534
|
+
packages = system.get("packages")
|
|
535
|
+
if isinstance(packages, dict):
|
|
536
|
+
# Non-projected shape: packages -> {data: {...}}
|
|
537
|
+
pkg_data = packages.get("data")
|
|
538
|
+
if isinstance(pkg_data, dict):
|
|
539
|
+
pkg_names = pkg_data
|
|
540
|
+
else:
|
|
541
|
+
# Projected shape: packages is the data dict directly
|
|
542
|
+
pkg_names = packages
|
|
543
|
+
else:
|
|
544
|
+
continue
|
|
545
|
+
for pkg_name in pkg_names:
|
|
546
|
+
if search_lower in pkg_name.lower():
|
|
547
|
+
result.append(system)
|
|
548
|
+
break
|
|
549
|
+
return result
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _get_system_state(system: Dict[str, Any]) -> str:
|
|
553
|
+
"""Extract connection state string from a system object.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
system: System dictionary.
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
Connection state string.
|
|
560
|
+
"""
|
|
561
|
+
connected = system.get("connected")
|
|
562
|
+
if isinstance(connected, dict):
|
|
563
|
+
data = connected.get("data")
|
|
564
|
+
if isinstance(data, dict):
|
|
565
|
+
return data.get("state", "UNKNOWN")
|
|
566
|
+
return "UNKNOWN"
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _get_system_grains(system: Dict[str, Any]) -> Dict[str, Any]:
|
|
570
|
+
"""Extract grains data from a system object.
|
|
571
|
+
|
|
572
|
+
Args:
|
|
573
|
+
system: System dictionary.
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
Grains data dictionary.
|
|
577
|
+
"""
|
|
578
|
+
grains = system.get("grains")
|
|
579
|
+
if isinstance(grains, dict):
|
|
580
|
+
data = grains.get("data")
|
|
581
|
+
if isinstance(data, dict):
|
|
582
|
+
return data
|
|
583
|
+
return {}
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _format_system_detail(system: Dict[str, Any], workspace_map: Dict[str, str]) -> None:
|
|
587
|
+
"""Format and display detailed system information.
|
|
588
|
+
|
|
589
|
+
Args:
|
|
590
|
+
system: System dictionary.
|
|
591
|
+
workspace_map: Workspace ID to name mapping.
|
|
592
|
+
"""
|
|
593
|
+
alias = system.get("alias", "N/A")
|
|
594
|
+
sys_id = system.get("id", "")
|
|
595
|
+
state = _get_system_state(system)
|
|
596
|
+
grains = _get_system_grains(system)
|
|
597
|
+
|
|
598
|
+
click.echo(f"\nSystem Details")
|
|
599
|
+
click.echo("──────────────────────────────────────")
|
|
600
|
+
click.echo(f" ID: {sys_id}")
|
|
601
|
+
click.echo(f" Alias: {alias}")
|
|
602
|
+
click.echo(f" State: {state}")
|
|
603
|
+
|
|
604
|
+
# Workspace
|
|
605
|
+
ws_id = system.get("workspace", "")
|
|
606
|
+
ws_name = get_workspace_display_name(ws_id, workspace_map)
|
|
607
|
+
click.echo(f" Workspace: {ws_name} ({ws_id})")
|
|
608
|
+
|
|
609
|
+
# Grains / System Info
|
|
610
|
+
if grains:
|
|
611
|
+
click.echo("")
|
|
612
|
+
click.echo(" System Info:")
|
|
613
|
+
click.echo(f" Host: {grains.get('host', 'N/A')}")
|
|
614
|
+
kernel = grains.get("kernel", "N/A")
|
|
615
|
+
osversion = grains.get("osversion", "")
|
|
616
|
+
os_display = f"{kernel} ({osversion})" if osversion else kernel
|
|
617
|
+
click.echo(f" OS: {os_display}")
|
|
618
|
+
click.echo(f" Architecture: {grains.get('cpuarch', 'N/A')}")
|
|
619
|
+
click.echo(f" Device Class: {grains.get('deviceclass', 'N/A')}")
|
|
620
|
+
|
|
621
|
+
# Keywords
|
|
622
|
+
keywords = system.get("keywords")
|
|
623
|
+
if isinstance(keywords, dict):
|
|
624
|
+
kw_data = keywords.get("data")
|
|
625
|
+
if isinstance(kw_data, list) and kw_data:
|
|
626
|
+
click.echo(f"\n Keywords: {', '.join(str(k) for k in kw_data)}")
|
|
627
|
+
|
|
628
|
+
# Properties
|
|
629
|
+
properties = system.get("properties")
|
|
630
|
+
if isinstance(properties, dict):
|
|
631
|
+
prop_data = properties.get("data")
|
|
632
|
+
if isinstance(prop_data, dict) and prop_data:
|
|
633
|
+
click.echo("\n Properties:")
|
|
634
|
+
for key, value in prop_data.items():
|
|
635
|
+
click.echo(f" {key}: {value}")
|
|
636
|
+
|
|
637
|
+
# Timestamps
|
|
638
|
+
click.echo("")
|
|
639
|
+
click.echo(" Timestamps:")
|
|
640
|
+
click.echo(f" Created: {system.get('createdTimestamp', 'N/A')}")
|
|
641
|
+
click.echo(f" Last Updated: {system.get('lastUpdatedTimestamp', 'N/A')}")
|
|
642
|
+
connected = system.get("connected")
|
|
643
|
+
if isinstance(connected, dict):
|
|
644
|
+
click.echo(f" Last Present: {connected.get('lastPresentTimestamp', 'N/A')}")
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def _format_packages_table(system: Dict[str, Any]) -> None:
|
|
648
|
+
"""Display installed packages in a formatted table.
|
|
649
|
+
|
|
650
|
+
Args:
|
|
651
|
+
system: System dictionary.
|
|
652
|
+
"""
|
|
653
|
+
packages = system.get("packages")
|
|
654
|
+
if not isinstance(packages, dict):
|
|
655
|
+
click.echo("\n No package information available.")
|
|
656
|
+
return
|
|
657
|
+
|
|
658
|
+
pkg_data = packages.get("data")
|
|
659
|
+
if not isinstance(pkg_data, dict) or not pkg_data:
|
|
660
|
+
click.echo("\n No packages installed.")
|
|
661
|
+
return
|
|
662
|
+
|
|
663
|
+
pkg_list: List[Dict[str, str]] = []
|
|
664
|
+
for pkg_name, pkg_info in sorted(pkg_data.items()):
|
|
665
|
+
if isinstance(pkg_info, dict):
|
|
666
|
+
display_name = pkg_info.get("displayname") or pkg_name
|
|
667
|
+
display_ver = pkg_info.get("displayversion") or pkg_info.get("version") or ""
|
|
668
|
+
group = pkg_info.get("group") or ""
|
|
669
|
+
pkg_list.append(
|
|
670
|
+
{
|
|
671
|
+
"name": str(display_name),
|
|
672
|
+
"version": str(display_ver),
|
|
673
|
+
"group": str(group),
|
|
674
|
+
}
|
|
675
|
+
)
|
|
676
|
+
else:
|
|
677
|
+
pkg_list.append({"name": pkg_name, "version": str(pkg_info), "group": ""})
|
|
678
|
+
|
|
679
|
+
click.echo(f"\n Installed Packages ({len(pkg_list)}):")
|
|
680
|
+
|
|
681
|
+
def pkg_formatter(item: Dict[str, Any]) -> List[str]:
|
|
682
|
+
return [
|
|
683
|
+
item.get("name", ""),
|
|
684
|
+
item.get("version", ""),
|
|
685
|
+
item.get("group", ""),
|
|
686
|
+
]
|
|
687
|
+
|
|
688
|
+
mock_resp: Any = FilteredResponse({"packages": pkg_list})
|
|
689
|
+
UniversalResponseHandler.handle_list_response(
|
|
690
|
+
resp=mock_resp,
|
|
691
|
+
data_key="packages",
|
|
692
|
+
item_name="package",
|
|
693
|
+
format_output="table",
|
|
694
|
+
formatter_func=pkg_formatter,
|
|
695
|
+
headers=["Package", "Version", "Group"],
|
|
696
|
+
column_widths=[36, 16, 20],
|
|
697
|
+
empty_message=" No packages installed.",
|
|
698
|
+
enable_pagination=False,
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def _format_feeds_table(system: Dict[str, Any]) -> None:
|
|
703
|
+
"""Display configured feeds in a formatted table.
|
|
704
|
+
|
|
705
|
+
Args:
|
|
706
|
+
system: System dictionary.
|
|
707
|
+
"""
|
|
708
|
+
feeds = system.get("feeds")
|
|
709
|
+
if not isinstance(feeds, dict):
|
|
710
|
+
click.echo("\n No feed information available.")
|
|
711
|
+
return
|
|
712
|
+
|
|
713
|
+
feed_data = feeds.get("data")
|
|
714
|
+
if not isinstance(feed_data, dict) or not feed_data:
|
|
715
|
+
click.echo("\n No feeds configured.")
|
|
716
|
+
return
|
|
717
|
+
|
|
718
|
+
feed_list: List[Dict[str, str]] = []
|
|
719
|
+
for feed_url, feed_configs in feed_data.items():
|
|
720
|
+
if isinstance(feed_configs, list):
|
|
721
|
+
for cfg in feed_configs:
|
|
722
|
+
if isinstance(cfg, dict):
|
|
723
|
+
feed_list.append(
|
|
724
|
+
{
|
|
725
|
+
"name": str(cfg.get("name") or ""),
|
|
726
|
+
"enabled": str(cfg.get("enabled", "")),
|
|
727
|
+
"uri": str(cfg.get("uri") or feed_url),
|
|
728
|
+
}
|
|
729
|
+
)
|
|
730
|
+
else:
|
|
731
|
+
feed_list.append({"name": "", "enabled": "", "uri": feed_url})
|
|
732
|
+
|
|
733
|
+
click.echo(f"\n Configured Feeds ({len(feed_list)}):")
|
|
734
|
+
|
|
735
|
+
def feed_formatter(item: Dict[str, Any]) -> List[str]:
|
|
736
|
+
return [
|
|
737
|
+
item.get("name", ""),
|
|
738
|
+
item.get("enabled", ""),
|
|
739
|
+
item.get("uri", ""),
|
|
740
|
+
]
|
|
741
|
+
|
|
742
|
+
mock_resp: Any = FilteredResponse({"feeds": feed_list})
|
|
743
|
+
UniversalResponseHandler.handle_list_response(
|
|
744
|
+
resp=mock_resp,
|
|
745
|
+
data_key="feeds",
|
|
746
|
+
item_name="feed",
|
|
747
|
+
format_output="table",
|
|
748
|
+
formatter_func=feed_formatter,
|
|
749
|
+
headers=["Name", "Enabled", "URI"],
|
|
750
|
+
column_widths=[30, 8, 50],
|
|
751
|
+
empty_message=" No feeds configured.",
|
|
752
|
+
enable_pagination=False,
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
# ------------------------------------------------------------------
|
|
757
|
+
# Related-resource fetch helpers
|
|
758
|
+
# ------------------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _fetch_assets_for_system(system_id: str, take: int) -> Tuple[List[Dict[str, Any]], int]:
|
|
762
|
+
"""Fetch assets associated with a system.
|
|
763
|
+
|
|
764
|
+
Args:
|
|
765
|
+
system_id: System minion ID.
|
|
766
|
+
take: Maximum number of assets to return.
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
Tuple of (list of assets, total count).
|
|
770
|
+
"""
|
|
771
|
+
escaped = _escape_filter_value(system_id)
|
|
772
|
+
payload: Dict[str, Any] = {
|
|
773
|
+
"filter": f'location.minionId = "{escaped}"',
|
|
774
|
+
"take": take,
|
|
775
|
+
"returnCount": True,
|
|
776
|
+
"projection": (
|
|
777
|
+
"new(id,name,modelName,modelNumber,vendorName,vendorNumber,serialNumber,"
|
|
778
|
+
"workspace,properties,keywords,location.minionId,location.parent,"
|
|
779
|
+
"location.physicalLocation,location.state.assetPresence,"
|
|
780
|
+
"location.state.systemConnection,discoveryType,supportsSelfTest,"
|
|
781
|
+
"supportsSelfCalibration,supportsReset,supportsExternalCalibration,"
|
|
782
|
+
"scanCode,temperatureSensors.reading,externalCalibration.resolvedDueDate,"
|
|
783
|
+
"selfCalibration.date)"
|
|
784
|
+
),
|
|
785
|
+
}
|
|
786
|
+
resp = make_api_request("POST", f"{_get_apm_base_url()}/query-assets", payload=payload)
|
|
787
|
+
data = resp.json()
|
|
788
|
+
assets = data.get("assets", []) if isinstance(data, dict) else []
|
|
789
|
+
total = (data.get("totalCount") or len(assets)) if isinstance(data, dict) else len(assets)
|
|
790
|
+
return assets, total
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _fetch_alarms_for_system(system_id: str, take: int) -> Tuple[List[Dict[str, Any]], int]:
|
|
794
|
+
"""Fetch active alarm instances for a system.
|
|
795
|
+
|
|
796
|
+
Args:
|
|
797
|
+
system_id: System minion ID.
|
|
798
|
+
take: Maximum number of alarm instances to return.
|
|
799
|
+
|
|
800
|
+
Returns:
|
|
801
|
+
Tuple of (list of alarm instances, total count).
|
|
802
|
+
"""
|
|
803
|
+
escaped = _escape_filter_value(system_id)
|
|
804
|
+
payload: Dict[str, Any] = {
|
|
805
|
+
"filter": f'properties.minionId == "{escaped}"',
|
|
806
|
+
"take": take,
|
|
807
|
+
}
|
|
808
|
+
resp = make_api_request(
|
|
809
|
+
"POST",
|
|
810
|
+
f"{_get_alarm_base_url()}/query-instances-with-filter",
|
|
811
|
+
payload=payload,
|
|
812
|
+
)
|
|
813
|
+
data = resp.json()
|
|
814
|
+
alarms = data.get("alarmInstances", []) if isinstance(data, dict) else []
|
|
815
|
+
total = (data.get("totalCount") or len(alarms)) if isinstance(data, dict) else len(alarms)
|
|
816
|
+
return alarms, total
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _fetch_recent_jobs_for_system(system_id: str, take: int) -> Tuple[List[Dict[str, Any]], int]:
|
|
820
|
+
"""Fetch recent jobs for a system.
|
|
821
|
+
|
|
822
|
+
Args:
|
|
823
|
+
system_id: System minion ID.
|
|
824
|
+
take: Maximum number of jobs to return.
|
|
825
|
+
|
|
826
|
+
Returns:
|
|
827
|
+
Tuple of (list of jobs, total count).
|
|
828
|
+
"""
|
|
829
|
+
escaped = _escape_filter_value(system_id)
|
|
830
|
+
payload: Dict[str, Any] = {
|
|
831
|
+
"filter": f'id = "{escaped}"',
|
|
832
|
+
"orderBy": "state descending, lastUpdatedTimestamp descending",
|
|
833
|
+
"take": take,
|
|
834
|
+
}
|
|
835
|
+
resp = make_api_request(
|
|
836
|
+
"POST",
|
|
837
|
+
f"{_get_sysmgmt_base_url()}/query-jobs",
|
|
838
|
+
payload=payload,
|
|
839
|
+
)
|
|
840
|
+
data = resp.json()
|
|
841
|
+
jobs_list = data.get("jobs", []) if isinstance(data, dict) else []
|
|
842
|
+
total = (data.get("totalCount") or len(jobs_list)) if isinstance(data, dict) else len(jobs_list)
|
|
843
|
+
return jobs_list, total
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _fetch_results_for_system(system_id: str, take: int) -> Tuple[List[Dict[str, Any]], int]:
|
|
847
|
+
"""Fetch recent test results for a system.
|
|
848
|
+
|
|
849
|
+
Args:
|
|
850
|
+
system_id: System minion ID.
|
|
851
|
+
take: Maximum number of results to return.
|
|
852
|
+
|
|
853
|
+
Returns:
|
|
854
|
+
Tuple of (list of results, total count).
|
|
855
|
+
"""
|
|
856
|
+
escaped = _escape_filter_value(system_id)
|
|
857
|
+
payload: Dict[str, Any] = {
|
|
858
|
+
"productFilter": "",
|
|
859
|
+
"filter": f'(systemId == "{escaped}")',
|
|
860
|
+
"projection": [
|
|
861
|
+
"ID",
|
|
862
|
+
"PART_NUMBER",
|
|
863
|
+
"PROGRAM_NAME",
|
|
864
|
+
"PROPERTIES",
|
|
865
|
+
"SERIAL_NUMBER",
|
|
866
|
+
"STARTED_AT",
|
|
867
|
+
"STATUS",
|
|
868
|
+
"SYSTEM_ID",
|
|
869
|
+
"TOTAL_TIME_IN_SECONDS",
|
|
870
|
+
"WORKSPACE",
|
|
871
|
+
],
|
|
872
|
+
"orderBy": "STARTED_AT",
|
|
873
|
+
"descending": True,
|
|
874
|
+
"orderByComparisonType": "DEFAULT",
|
|
875
|
+
"take": take,
|
|
876
|
+
}
|
|
877
|
+
resp = make_api_request(
|
|
878
|
+
"POST",
|
|
879
|
+
f"{_get_testmonitor_base_url()}/query-results",
|
|
880
|
+
payload=payload,
|
|
881
|
+
)
|
|
882
|
+
data = resp.json()
|
|
883
|
+
results = data.get("results", []) if isinstance(data, dict) else []
|
|
884
|
+
total = (data.get("totalCount") or len(results)) if isinstance(data, dict) else len(results)
|
|
885
|
+
return results, total
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def _fetch_workitems_for_system(
|
|
889
|
+
system_id: str, take: int, days: int
|
|
890
|
+
) -> Tuple[List[Dict[str, Any]], int]:
|
|
891
|
+
"""Fetch upcoming/recent work items (test plan instances) scheduled for a system.
|
|
892
|
+
|
|
893
|
+
Queries work items where the system is a scheduled resource within a window
|
|
894
|
+
of ``days`` days before/after today (i.e. a centred ±days window).
|
|
895
|
+
|
|
896
|
+
Args:
|
|
897
|
+
system_id: System minion ID.
|
|
898
|
+
take: Maximum number of work items to return.
|
|
899
|
+
days: Half-width of the time window in days (centre = now).
|
|
900
|
+
|
|
901
|
+
Returns:
|
|
902
|
+
Tuple of (list of work items, total count).
|
|
903
|
+
"""
|
|
904
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
905
|
+
start = (now - datetime.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
906
|
+
end = (now + datetime.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
907
|
+
escaped = _escape_filter_value(system_id)
|
|
908
|
+
filter_expr = (
|
|
909
|
+
'((!(schedule.plannedStartDateTime = null || schedule.plannedStartDateTime = "") && '
|
|
910
|
+
'!(schedule.plannedEndDateTime = null || schedule.plannedEndDateTime = "") && '
|
|
911
|
+
f'DateTime(schedule.plannedStartDateTime) < DateTime.parse("{end}") && '
|
|
912
|
+
f'DateTime(schedule.plannedEndDateTime) > DateTime.parse("{start}")) && '
|
|
913
|
+
f'resources.systems.selections.Any(s => s.id == "{escaped}")) && type == "testplan"'
|
|
914
|
+
)
|
|
915
|
+
payload: Dict[str, Any] = {
|
|
916
|
+
"filter": filter_expr,
|
|
917
|
+
"orderBy": "UPDATED_AT",
|
|
918
|
+
"descending": True,
|
|
919
|
+
"take": take,
|
|
920
|
+
}
|
|
921
|
+
url = (
|
|
922
|
+
f"{_get_workitem_base_url()}/query-workitems"
|
|
923
|
+
"?ff-userdefinedworkflowsfortestplaninstances=true"
|
|
924
|
+
)
|
|
925
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
926
|
+
data = resp.json()
|
|
927
|
+
if isinstance(data, dict):
|
|
928
|
+
workitems: List[Dict[str, Any]] = list(data.get("workItems") or data.get("workitems") or [])
|
|
929
|
+
total: int = int(data.get("totalCount") or len(workitems))
|
|
930
|
+
else:
|
|
931
|
+
workitems = []
|
|
932
|
+
total = 0
|
|
933
|
+
return workitems, total
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
# ------------------------------------------------------------------
|
|
937
|
+
# Related-resource format section helpers
|
|
938
|
+
# ------------------------------------------------------------------
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _format_assets_section(assets: List[Dict[str, Any]], total: int, take: int) -> None:
|
|
942
|
+
"""Display an assets section in the system detail view.
|
|
943
|
+
|
|
944
|
+
Args:
|
|
945
|
+
assets: List of asset records.
|
|
946
|
+
total: Total count from the API (may exceed len(assets) if take < total).
|
|
947
|
+
take: Requested limit (used to build the "showing N of M" suffix).
|
|
948
|
+
"""
|
|
949
|
+
showing = len(assets)
|
|
950
|
+
suffix = f" (showing {showing} of {total})" if total > showing else ""
|
|
951
|
+
click.echo(f"\n Assets ({total}){suffix}:")
|
|
952
|
+
|
|
953
|
+
def fmt(item: Dict[str, Any]) -> List[str]:
|
|
954
|
+
return [
|
|
955
|
+
item.get("name", ""),
|
|
956
|
+
str(item.get("assetType", "")),
|
|
957
|
+
item.get("modelName", ""),
|
|
958
|
+
item.get("serialNumber", ""),
|
|
959
|
+
item.get("busType", ""),
|
|
960
|
+
]
|
|
961
|
+
|
|
962
|
+
mock: Any = FilteredResponse({"assets": assets})
|
|
963
|
+
UniversalResponseHandler.handle_list_response(
|
|
964
|
+
resp=mock,
|
|
965
|
+
data_key="assets",
|
|
966
|
+
item_name="asset",
|
|
967
|
+
format_output="table",
|
|
968
|
+
formatter_func=fmt,
|
|
969
|
+
headers=["Name", "Type", "Model", "Serial", "Bus"],
|
|
970
|
+
column_widths=[30, 16, 24, 16, 12],
|
|
971
|
+
empty_message=" No assets.",
|
|
972
|
+
enable_pagination=False,
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _format_alarms_section(alarms: List[Dict[str, Any]], total: int, take: int) -> None:
|
|
977
|
+
"""Display an active alarms section in the system detail view.
|
|
978
|
+
|
|
979
|
+
Args:
|
|
980
|
+
alarms: List of alarm instance records.
|
|
981
|
+
total: Total count from the API.
|
|
982
|
+
take: Requested limit.
|
|
983
|
+
"""
|
|
984
|
+
showing = len(alarms)
|
|
985
|
+
suffix = f" (showing {showing} of {total})" if total > showing else ""
|
|
986
|
+
click.echo(f"\n Active Alarms ({total}){suffix}:")
|
|
987
|
+
|
|
988
|
+
def fmt(item: Dict[str, Any]) -> List[str]:
|
|
989
|
+
rule = item.get("alarmRule") or {}
|
|
990
|
+
return [
|
|
991
|
+
rule.get("displayName", item.get("channel", "")),
|
|
992
|
+
str(item.get("severity", "")),
|
|
993
|
+
item.get("channel", ""),
|
|
994
|
+
item.get("setAt", item.get("createdAt", "")),
|
|
995
|
+
]
|
|
996
|
+
|
|
997
|
+
mock: Any = FilteredResponse({"alarms": alarms})
|
|
998
|
+
UniversalResponseHandler.handle_list_response(
|
|
999
|
+
resp=mock,
|
|
1000
|
+
data_key="alarms",
|
|
1001
|
+
item_name="alarm",
|
|
1002
|
+
format_output="table",
|
|
1003
|
+
formatter_func=fmt,
|
|
1004
|
+
headers=["Name", "Severity", "Channel", "Set At"],
|
|
1005
|
+
column_widths=[32, 10, 28, 28],
|
|
1006
|
+
empty_message=" No active alarms.",
|
|
1007
|
+
enable_pagination=False,
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _format_jobs_section(jobs: List[Dict[str, Any]], total: int, take: int) -> None:
|
|
1012
|
+
"""Display a recent jobs section in the system detail view.
|
|
1013
|
+
|
|
1014
|
+
Args:
|
|
1015
|
+
jobs: List of job records.
|
|
1016
|
+
total: Total count from the API.
|
|
1017
|
+
take: Requested limit.
|
|
1018
|
+
"""
|
|
1019
|
+
showing = len(jobs)
|
|
1020
|
+
suffix = f" (showing {showing} of {total})" if total > showing else ""
|
|
1021
|
+
click.echo(f"\n Recent Jobs ({total}){suffix}:")
|
|
1022
|
+
|
|
1023
|
+
def fmt(item: Dict[str, Any]) -> List[str]:
|
|
1024
|
+
fields = _get_job_display_fields(item)
|
|
1025
|
+
return [fields["jid"], fields["state"], fields["created"]]
|
|
1026
|
+
|
|
1027
|
+
mock: Any = FilteredResponse({"jobs": jobs})
|
|
1028
|
+
UniversalResponseHandler.handle_list_response(
|
|
1029
|
+
resp=mock,
|
|
1030
|
+
data_key="jobs",
|
|
1031
|
+
item_name="job",
|
|
1032
|
+
format_output="table",
|
|
1033
|
+
formatter_func=fmt,
|
|
1034
|
+
headers=["Job ID", "State", "Created"],
|
|
1035
|
+
column_widths=[36, 14, 28],
|
|
1036
|
+
empty_message=" No jobs found.",
|
|
1037
|
+
enable_pagination=False,
|
|
1038
|
+
)
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _format_results_section(results: List[Dict[str, Any]], total: int, take: int) -> None:
|
|
1042
|
+
"""Display a recent test results section in the system detail view.
|
|
1043
|
+
|
|
1044
|
+
Args:
|
|
1045
|
+
results: List of test result records.
|
|
1046
|
+
total: Total count from the API.
|
|
1047
|
+
take: Requested limit.
|
|
1048
|
+
"""
|
|
1049
|
+
showing = len(results)
|
|
1050
|
+
suffix = f" (showing {showing} of {total})" if total > showing else ""
|
|
1051
|
+
click.echo(f"\n Test Results ({total}){suffix}:")
|
|
1052
|
+
|
|
1053
|
+
def fmt(item: Dict[str, Any]) -> List[str]:
|
|
1054
|
+
status_obj = item.get("status") or {}
|
|
1055
|
+
if isinstance(status_obj, dict):
|
|
1056
|
+
status = status_obj.get("statusType", str(status_obj))
|
|
1057
|
+
else:
|
|
1058
|
+
status = str(status_obj)
|
|
1059
|
+
return [
|
|
1060
|
+
item.get("programName", ""),
|
|
1061
|
+
status,
|
|
1062
|
+
item.get("startedAt", item.get("startedWithApiAt", "")),
|
|
1063
|
+
]
|
|
1064
|
+
|
|
1065
|
+
mock: Any = FilteredResponse({"results": results})
|
|
1066
|
+
UniversalResponseHandler.handle_list_response(
|
|
1067
|
+
resp=mock,
|
|
1068
|
+
data_key="results",
|
|
1069
|
+
item_name="result",
|
|
1070
|
+
format_output="table",
|
|
1071
|
+
formatter_func=fmt,
|
|
1072
|
+
headers=["Program", "Status", "Started"],
|
|
1073
|
+
column_widths=[36, 12, 28],
|
|
1074
|
+
empty_message=" No test results found.",
|
|
1075
|
+
enable_pagination=False,
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _format_workitems_section(
|
|
1080
|
+
workitems: List[Dict[str, Any]], total: int, take: int, days: int
|
|
1081
|
+
) -> None:
|
|
1082
|
+
"""Display scheduled work items (test plans) for a system.
|
|
1083
|
+
|
|
1084
|
+
Args:
|
|
1085
|
+
workitems: List of work item records.
|
|
1086
|
+
total: Total count from the API.
|
|
1087
|
+
take: Requested limit.
|
|
1088
|
+
days: The time-window half-width used in the query (for display).
|
|
1089
|
+
"""
|
|
1090
|
+
showing = len(workitems)
|
|
1091
|
+
suffix = f" (showing {showing} of {total})" if total > showing else ""
|
|
1092
|
+
click.echo(f"\n Scheduled Work Items \u00b1{days}d ({total}){suffix}:")
|
|
1093
|
+
|
|
1094
|
+
def fmt(item: Dict[str, Any]) -> List[str]:
|
|
1095
|
+
schedule = item.get("schedule") or {}
|
|
1096
|
+
return [
|
|
1097
|
+
item.get("name", ""),
|
|
1098
|
+
item.get("state", ""),
|
|
1099
|
+
schedule.get("plannedStartDateTime", ""),
|
|
1100
|
+
schedule.get("plannedEndDateTime", ""),
|
|
1101
|
+
]
|
|
1102
|
+
|
|
1103
|
+
mock: Any = FilteredResponse({"workItems": workitems})
|
|
1104
|
+
UniversalResponseHandler.handle_list_response(
|
|
1105
|
+
resp=mock,
|
|
1106
|
+
data_key="workItems",
|
|
1107
|
+
item_name="work item",
|
|
1108
|
+
format_output="table",
|
|
1109
|
+
formatter_func=fmt,
|
|
1110
|
+
headers=["Name", "State", "Planned Start", "Planned End"],
|
|
1111
|
+
column_widths=[36, 14, 28, 28],
|
|
1112
|
+
empty_message=" No work items scheduled.",
|
|
1113
|
+
enable_pagination=False,
|
|
1114
|
+
)
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
# ------------------------------------------------------------------
|
|
1118
|
+
# Job helpers
|
|
1119
|
+
# ------------------------------------------------------------------
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _build_job_filter(
|
|
1123
|
+
system_id: Optional[str] = None,
|
|
1124
|
+
state: Optional[str] = None,
|
|
1125
|
+
function: Optional[str] = None,
|
|
1126
|
+
custom_filter: Optional[str] = None,
|
|
1127
|
+
) -> Optional[str]:
|
|
1128
|
+
"""Build API filter expression for job queries.
|
|
1129
|
+
|
|
1130
|
+
Args:
|
|
1131
|
+
system_id: Filter by target system ID.
|
|
1132
|
+
state: Filter by job state.
|
|
1133
|
+
function: Filter by salt function name.
|
|
1134
|
+
custom_filter: Advanced user-provided filter expression.
|
|
1135
|
+
|
|
1136
|
+
Returns:
|
|
1137
|
+
Combined filter expression string, or None if no filters.
|
|
1138
|
+
"""
|
|
1139
|
+
parts: List[str] = []
|
|
1140
|
+
|
|
1141
|
+
if system_id:
|
|
1142
|
+
escaped = _escape_filter_value(system_id)
|
|
1143
|
+
parts.append(f'id = "{escaped}"')
|
|
1144
|
+
if state:
|
|
1145
|
+
parts.append(f'state = "{state}"')
|
|
1146
|
+
if function:
|
|
1147
|
+
escaped = _escape_filter_value(function)
|
|
1148
|
+
parts.append(f'config.fun.Contains("{escaped}")')
|
|
1149
|
+
if custom_filter:
|
|
1150
|
+
parts.append(custom_filter)
|
|
1151
|
+
|
|
1152
|
+
return " and ".join(parts) if parts else None
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def _get_job_display_fields(job: Dict[str, Any]) -> Dict[str, str]:
|
|
1156
|
+
"""Extract display fields from a job object.
|
|
1157
|
+
|
|
1158
|
+
Args:
|
|
1159
|
+
job: Job dictionary.
|
|
1160
|
+
|
|
1161
|
+
Returns:
|
|
1162
|
+
Dictionary with formatted display fields.
|
|
1163
|
+
"""
|
|
1164
|
+
config = job.get("config") or {}
|
|
1165
|
+
result = job.get("result") or {}
|
|
1166
|
+
targets = config.get("tgt", [])
|
|
1167
|
+
functions = config.get("fun", [])
|
|
1168
|
+
|
|
1169
|
+
return {
|
|
1170
|
+
"jid": job.get("jid", ""),
|
|
1171
|
+
"state": job.get("state", ""),
|
|
1172
|
+
"created": job.get("createdTimestamp", ""),
|
|
1173
|
+
"target": targets[0] if targets else job.get("id", ""),
|
|
1174
|
+
"functions": ", ".join(functions) if functions else "",
|
|
1175
|
+
"success": str(result.get("success", "")),
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
# ==================================================================
|
|
1180
|
+
# Command registration
|
|
1181
|
+
# ==================================================================
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def register_system_commands(cli: Any) -> None:
|
|
1185
|
+
"""Register the 'system' command group and its subcommands.
|
|
1186
|
+
|
|
1187
|
+
Args:
|
|
1188
|
+
cli: Click CLI group to register commands on.
|
|
1189
|
+
"""
|
|
1190
|
+
|
|
1191
|
+
@cli.group()
|
|
1192
|
+
def system() -> None:
|
|
1193
|
+
"""Manage SystemLink systems.
|
|
1194
|
+
|
|
1195
|
+
Query, inspect, and manage systems registered with the Systems
|
|
1196
|
+
Management service. Supports filtering by alias, connection state,
|
|
1197
|
+
OS, hostname, keywords, and installed packages.
|
|
1198
|
+
|
|
1199
|
+
Filter syntax uses the Systems Management filter language:
|
|
1200
|
+
alias.Contains("PXI"), connected.data.state = "CONNECTED",
|
|
1201
|
+
grains.data.kernel = "Windows", and/or operators.
|
|
1202
|
+
"""
|
|
1203
|
+
|
|
1204
|
+
# ------------------------------------------------------------------
|
|
1205
|
+
# Phase 1: list, get, summary
|
|
1206
|
+
# ------------------------------------------------------------------
|
|
1207
|
+
|
|
1208
|
+
@system.command(name="list")
|
|
1209
|
+
@click.option(
|
|
1210
|
+
"--format",
|
|
1211
|
+
"-f",
|
|
1212
|
+
type=click.Choice(["table", "json"]),
|
|
1213
|
+
default="table",
|
|
1214
|
+
show_default=True,
|
|
1215
|
+
help="Output format",
|
|
1216
|
+
)
|
|
1217
|
+
@click.option(
|
|
1218
|
+
"--take",
|
|
1219
|
+
"-t",
|
|
1220
|
+
type=int,
|
|
1221
|
+
default=100,
|
|
1222
|
+
show_default=True,
|
|
1223
|
+
help=(
|
|
1224
|
+
"Number of items per page for table output; maximum number of items "
|
|
1225
|
+
"to return for JSON"
|
|
1226
|
+
),
|
|
1227
|
+
)
|
|
1228
|
+
@click.option("--alias", "-a", help="Filter by system alias (contains match)")
|
|
1229
|
+
@click.option(
|
|
1230
|
+
"--state",
|
|
1231
|
+
"-s",
|
|
1232
|
+
type=click.Choice(
|
|
1233
|
+
[
|
|
1234
|
+
"CONNECTED",
|
|
1235
|
+
"DISCONNECTED",
|
|
1236
|
+
"VIRTUAL",
|
|
1237
|
+
"APPROVED",
|
|
1238
|
+
"CONNECTED_REFRESH_PENDING",
|
|
1239
|
+
"CONNECTED_REFRESH_FAILED",
|
|
1240
|
+
"ACTIVATED_WITHOUT_CONNECTION",
|
|
1241
|
+
],
|
|
1242
|
+
case_sensitive=True,
|
|
1243
|
+
),
|
|
1244
|
+
help="Filter by connection state",
|
|
1245
|
+
)
|
|
1246
|
+
@click.option("--os", "os_filter", help="Filter by OS (kernel contains match)")
|
|
1247
|
+
@click.option("--host", help="Filter by hostname (contains match)")
|
|
1248
|
+
@click.option(
|
|
1249
|
+
"--has-package",
|
|
1250
|
+
help="Filter for systems with specified package installed (contains match)",
|
|
1251
|
+
)
|
|
1252
|
+
@click.option(
|
|
1253
|
+
"--has-keyword",
|
|
1254
|
+
multiple=True,
|
|
1255
|
+
help="Filter systems that have this keyword (repeatable)",
|
|
1256
|
+
)
|
|
1257
|
+
@click.option(
|
|
1258
|
+
"--property",
|
|
1259
|
+
"property_filters",
|
|
1260
|
+
multiple=True,
|
|
1261
|
+
help="Filter by property key=value (repeatable)",
|
|
1262
|
+
)
|
|
1263
|
+
@click.option("--workspace", "-w", help="Filter by workspace name or ID")
|
|
1264
|
+
@click.option(
|
|
1265
|
+
"--filter",
|
|
1266
|
+
"filter_query",
|
|
1267
|
+
help=("Advanced API filter expression " "(e.g., 'connected.data.state = \"CONNECTED\"')"),
|
|
1268
|
+
)
|
|
1269
|
+
@click.option(
|
|
1270
|
+
"--order-by",
|
|
1271
|
+
type=click.Choice(
|
|
1272
|
+
["ALIAS", "CREATED_AT", "UPDATED_AT"],
|
|
1273
|
+
case_sensitive=False,
|
|
1274
|
+
),
|
|
1275
|
+
help="Order by field",
|
|
1276
|
+
)
|
|
1277
|
+
def list_systems(
|
|
1278
|
+
format: str,
|
|
1279
|
+
take: int,
|
|
1280
|
+
alias: Optional[str],
|
|
1281
|
+
state: Optional[str],
|
|
1282
|
+
os_filter: Optional[str],
|
|
1283
|
+
host: Optional[str],
|
|
1284
|
+
has_package: Optional[str],
|
|
1285
|
+
has_keyword: Tuple[str, ...],
|
|
1286
|
+
property_filters: Tuple[str, ...],
|
|
1287
|
+
workspace: Optional[str],
|
|
1288
|
+
filter_query: Optional[str],
|
|
1289
|
+
order_by: Optional[str],
|
|
1290
|
+
) -> None:
|
|
1291
|
+
"""List and query systems with optional filtering.
|
|
1292
|
+
|
|
1293
|
+
Supports convenience filters (--alias, --state, --os, --host,
|
|
1294
|
+
--has-keyword, --property) that are translated to API filter
|
|
1295
|
+
expressions. Combine multiple options — they are joined with 'and'.
|
|
1296
|
+
|
|
1297
|
+
Use --has-package for client-side package filtering (contains match).
|
|
1298
|
+
|
|
1299
|
+
For advanced queries use --filter with the Systems Management filter
|
|
1300
|
+
syntax: connected.data.state = "CONNECTED" and grains.data.kernel = "Windows"
|
|
1301
|
+
"""
|
|
1302
|
+
format_output = validate_output_format(format)
|
|
1303
|
+
|
|
1304
|
+
try:
|
|
1305
|
+
# Resolve workspace if provided
|
|
1306
|
+
workspace_id: Optional[str] = None
|
|
1307
|
+
try:
|
|
1308
|
+
workspace_map = get_workspace_map()
|
|
1309
|
+
except Exception:
|
|
1310
|
+
workspace_map = {}
|
|
1311
|
+
|
|
1312
|
+
workspace = get_effective_workspace(workspace)
|
|
1313
|
+
if workspace:
|
|
1314
|
+
workspace_id = resolve_workspace_filter(workspace, workspace_map)
|
|
1315
|
+
|
|
1316
|
+
# Map order-by choices to API field names
|
|
1317
|
+
order_by_map: Dict[str, str] = {
|
|
1318
|
+
"ALIAS": "alias",
|
|
1319
|
+
"CREATED_AT": "createdTimestamp descending",
|
|
1320
|
+
"UPDATED_AT": "lastUpdatedTimestamp descending",
|
|
1321
|
+
}
|
|
1322
|
+
api_order_by = order_by_map.get(order_by.upper()) if order_by else None
|
|
1323
|
+
|
|
1324
|
+
filter_expr = _build_system_filter(
|
|
1325
|
+
alias=alias,
|
|
1326
|
+
state=state,
|
|
1327
|
+
os_filter=os_filter,
|
|
1328
|
+
host=host,
|
|
1329
|
+
has_keyword=has_keyword if has_keyword else None,
|
|
1330
|
+
property_filters=property_filters if property_filters else None,
|
|
1331
|
+
workspace_id=workspace_id,
|
|
1332
|
+
custom_filter=filter_query,
|
|
1333
|
+
)
|
|
1334
|
+
|
|
1335
|
+
def system_formatter(item: Dict[str, Any]) -> List[str]:
|
|
1336
|
+
ws_id = item.get("workspace", "")
|
|
1337
|
+
ws_name = get_workspace_display_name(ws_id, workspace_map)
|
|
1338
|
+
# Projected responses have flat top-level keys (e.g. "host")
|
|
1339
|
+
# while non-projected responses use nested structures.
|
|
1340
|
+
if "host" in item or "kernel" in item:
|
|
1341
|
+
# Flat projected shape
|
|
1342
|
+
host = item.get("host", "")
|
|
1343
|
+
state = item.get("connected", "UNKNOWN")
|
|
1344
|
+
kernel = item.get("kernel", "")
|
|
1345
|
+
else:
|
|
1346
|
+
# Nested shape (fallback)
|
|
1347
|
+
grains = _get_system_grains(item)
|
|
1348
|
+
host = grains.get("host", "")
|
|
1349
|
+
state = _get_system_state(item)
|
|
1350
|
+
kernel = grains.get("kernel", "")
|
|
1351
|
+
return [
|
|
1352
|
+
item.get("alias", ""),
|
|
1353
|
+
host,
|
|
1354
|
+
state,
|
|
1355
|
+
kernel,
|
|
1356
|
+
ws_name,
|
|
1357
|
+
item.get("id", ""),
|
|
1358
|
+
]
|
|
1359
|
+
|
|
1360
|
+
headers = ["Alias", "Host", "State", "OS", "Workspace", "ID"]
|
|
1361
|
+
column_widths = _calculate_column_widths()
|
|
1362
|
+
|
|
1363
|
+
query_url = f"{_get_sysmgmt_base_url()}/query-systems"
|
|
1364
|
+
|
|
1365
|
+
if format_output.lower() == "json":
|
|
1366
|
+
systems = _query_all_items(
|
|
1367
|
+
query_url,
|
|
1368
|
+
filter_expr,
|
|
1369
|
+
api_order_by,
|
|
1370
|
+
_parse_systems_response,
|
|
1371
|
+
projection=_LIST_PROJECTION,
|
|
1372
|
+
take=take,
|
|
1373
|
+
)
|
|
1374
|
+
if has_package:
|
|
1375
|
+
systems = _filter_by_package(systems, has_package)
|
|
1376
|
+
mock_resp: Any = FilteredResponse({"systems": systems})
|
|
1377
|
+
UniversalResponseHandler.handle_list_response(
|
|
1378
|
+
resp=mock_resp,
|
|
1379
|
+
data_key="systems",
|
|
1380
|
+
item_name="system",
|
|
1381
|
+
format_output=format_output,
|
|
1382
|
+
formatter_func=system_formatter,
|
|
1383
|
+
headers=headers,
|
|
1384
|
+
column_widths=column_widths,
|
|
1385
|
+
empty_message="No systems found.",
|
|
1386
|
+
enable_pagination=False,
|
|
1387
|
+
page_size=take,
|
|
1388
|
+
)
|
|
1389
|
+
else:
|
|
1390
|
+
pkg_filter = (
|
|
1391
|
+
(lambda items: _filter_by_package(items, has_package)) if has_package else None
|
|
1392
|
+
)
|
|
1393
|
+
_handle_interactive_pagination(
|
|
1394
|
+
url=query_url,
|
|
1395
|
+
filter_expr=filter_expr,
|
|
1396
|
+
order_by=api_order_by,
|
|
1397
|
+
take=take,
|
|
1398
|
+
formatter_func=system_formatter,
|
|
1399
|
+
headers=headers,
|
|
1400
|
+
column_widths=column_widths,
|
|
1401
|
+
empty_message="No systems found.",
|
|
1402
|
+
item_label="systems",
|
|
1403
|
+
response_parser=_parse_systems_response,
|
|
1404
|
+
client_filter=pkg_filter,
|
|
1405
|
+
projection=_LIST_PROJECTION,
|
|
1406
|
+
)
|
|
1407
|
+
except Exception as exc: # noqa: BLE001
|
|
1408
|
+
handle_api_error(exc)
|
|
1409
|
+
|
|
1410
|
+
@system.command(name="get")
|
|
1411
|
+
@click.argument("system_id")
|
|
1412
|
+
@click.option(
|
|
1413
|
+
"--format",
|
|
1414
|
+
"-f",
|
|
1415
|
+
type=click.Choice(["table", "json"]),
|
|
1416
|
+
default="table",
|
|
1417
|
+
show_default=True,
|
|
1418
|
+
help="Output format",
|
|
1419
|
+
)
|
|
1420
|
+
@click.option(
|
|
1421
|
+
"--include-packages",
|
|
1422
|
+
is_flag=True,
|
|
1423
|
+
help="Include installed packages in output",
|
|
1424
|
+
)
|
|
1425
|
+
@click.option(
|
|
1426
|
+
"--include-feeds",
|
|
1427
|
+
is_flag=True,
|
|
1428
|
+
help="Include configured feeds from the system record",
|
|
1429
|
+
)
|
|
1430
|
+
@click.option(
|
|
1431
|
+
"--include-assets",
|
|
1432
|
+
is_flag=True,
|
|
1433
|
+
help="Include assets associated with this system (niapm)",
|
|
1434
|
+
)
|
|
1435
|
+
@click.option(
|
|
1436
|
+
"--include-alarms",
|
|
1437
|
+
is_flag=True,
|
|
1438
|
+
help="Include active alarm instances for this system",
|
|
1439
|
+
)
|
|
1440
|
+
@click.option(
|
|
1441
|
+
"--include-jobs",
|
|
1442
|
+
is_flag=True,
|
|
1443
|
+
help="Include recent jobs dispatched to this system",
|
|
1444
|
+
)
|
|
1445
|
+
@click.option(
|
|
1446
|
+
"--include-results",
|
|
1447
|
+
is_flag=True,
|
|
1448
|
+
help="Include recent test results for this system",
|
|
1449
|
+
)
|
|
1450
|
+
@click.option(
|
|
1451
|
+
"--include-workitems",
|
|
1452
|
+
is_flag=True,
|
|
1453
|
+
help="Include scheduled work items (test plans) that reference this system",
|
|
1454
|
+
)
|
|
1455
|
+
@click.option(
|
|
1456
|
+
"--include-all",
|
|
1457
|
+
is_flag=True,
|
|
1458
|
+
help="Include all related resources (packages, feeds, assets, alarms, jobs, results, work items)",
|
|
1459
|
+
)
|
|
1460
|
+
@click.option(
|
|
1461
|
+
"--take",
|
|
1462
|
+
"-t",
|
|
1463
|
+
type=int,
|
|
1464
|
+
default=10,
|
|
1465
|
+
show_default=True,
|
|
1466
|
+
help="Maximum rows to show per related-resource section",
|
|
1467
|
+
)
|
|
1468
|
+
@click.option(
|
|
1469
|
+
"--workitem-days",
|
|
1470
|
+
type=int,
|
|
1471
|
+
default=30,
|
|
1472
|
+
show_default=True,
|
|
1473
|
+
help="Time-window half-width in days for --include-workitems (centre = today)",
|
|
1474
|
+
)
|
|
1475
|
+
def get_system(
|
|
1476
|
+
system_id: str,
|
|
1477
|
+
format: str,
|
|
1478
|
+
include_packages: bool,
|
|
1479
|
+
include_feeds: bool,
|
|
1480
|
+
include_assets: bool,
|
|
1481
|
+
include_alarms: bool,
|
|
1482
|
+
include_jobs: bool,
|
|
1483
|
+
include_results: bool,
|
|
1484
|
+
include_workitems: bool,
|
|
1485
|
+
include_all: bool,
|
|
1486
|
+
take: int,
|
|
1487
|
+
workitem_days: int,
|
|
1488
|
+
) -> None:
|
|
1489
|
+
"""Get detailed information about a specific system.
|
|
1490
|
+
|
|
1491
|
+
SYSTEM_ID is the unique identifier (minion ID) of the system.
|
|
1492
|
+
|
|
1493
|
+
Use --include-* flags to pull in related resources from other services
|
|
1494
|
+
in parallel. --include-all enables every section at once.
|
|
1495
|
+
"""
|
|
1496
|
+
format_output = validate_output_format(format)
|
|
1497
|
+
|
|
1498
|
+
# Resolve effective include flags
|
|
1499
|
+
eff_packages = include_all or include_packages
|
|
1500
|
+
eff_feeds = include_all or include_feeds
|
|
1501
|
+
eff_assets = include_all or include_assets
|
|
1502
|
+
eff_alarms = include_all or include_alarms
|
|
1503
|
+
eff_jobs = include_all or include_jobs
|
|
1504
|
+
eff_results = include_all or include_results
|
|
1505
|
+
eff_workitems = include_all or include_workitems
|
|
1506
|
+
|
|
1507
|
+
any_related = eff_assets or eff_alarms or eff_jobs or eff_results or eff_workitems
|
|
1508
|
+
|
|
1509
|
+
try:
|
|
1510
|
+
url = f"{_get_sysmgmt_base_url()}/systems?id={system_id}"
|
|
1511
|
+
resp = make_api_request("GET", url)
|
|
1512
|
+
data = resp.json()
|
|
1513
|
+
|
|
1514
|
+
# API returns an array — take the first element
|
|
1515
|
+
if isinstance(data, list) and data:
|
|
1516
|
+
system_data = data[0]
|
|
1517
|
+
elif isinstance(data, dict):
|
|
1518
|
+
system_data = data
|
|
1519
|
+
else:
|
|
1520
|
+
click.echo(f"✗ System not found: {system_id}", err=True)
|
|
1521
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
1522
|
+
|
|
1523
|
+
# ---------------------------------------------------------------
|
|
1524
|
+
# Fetch related resources in parallel
|
|
1525
|
+
# ---------------------------------------------------------------
|
|
1526
|
+
assets: List[Dict[str, Any]] = []
|
|
1527
|
+
assets_total = 0
|
|
1528
|
+
alarms: List[Dict[str, Any]] = []
|
|
1529
|
+
alarms_total = 0
|
|
1530
|
+
jobs_list: List[Dict[str, Any]] = []
|
|
1531
|
+
jobs_total = 0
|
|
1532
|
+
results: List[Dict[str, Any]] = []
|
|
1533
|
+
results_total = 0
|
|
1534
|
+
workitems: List[Dict[str, Any]] = []
|
|
1535
|
+
workitems_total = 0
|
|
1536
|
+
fetch_errors: Dict[str, str] = {}
|
|
1537
|
+
|
|
1538
|
+
if any_related:
|
|
1539
|
+
task_map: Dict[str, Any] = {}
|
|
1540
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
1541
|
+
if eff_assets:
|
|
1542
|
+
task_map["assets"] = executor.submit(
|
|
1543
|
+
_fetch_assets_for_system, system_id, take
|
|
1544
|
+
)
|
|
1545
|
+
if eff_alarms:
|
|
1546
|
+
task_map["alarms"] = executor.submit(
|
|
1547
|
+
_fetch_alarms_for_system, system_id, take
|
|
1548
|
+
)
|
|
1549
|
+
if eff_jobs:
|
|
1550
|
+
task_map["jobs"] = executor.submit(
|
|
1551
|
+
_fetch_recent_jobs_for_system, system_id, take
|
|
1552
|
+
)
|
|
1553
|
+
if eff_results:
|
|
1554
|
+
task_map["results"] = executor.submit(
|
|
1555
|
+
_fetch_results_for_system, system_id, take
|
|
1556
|
+
)
|
|
1557
|
+
if eff_workitems:
|
|
1558
|
+
task_map["workitems"] = executor.submit(
|
|
1559
|
+
_fetch_workitems_for_system, system_id, take, workitem_days
|
|
1560
|
+
)
|
|
1561
|
+
|
|
1562
|
+
for key, future in task_map.items():
|
|
1563
|
+
try:
|
|
1564
|
+
result_pair = future.result()
|
|
1565
|
+
if key == "assets":
|
|
1566
|
+
assets, assets_total = result_pair
|
|
1567
|
+
elif key == "alarms":
|
|
1568
|
+
alarms, alarms_total = result_pair
|
|
1569
|
+
elif key == "jobs":
|
|
1570
|
+
jobs_list, jobs_total = result_pair
|
|
1571
|
+
elif key == "results":
|
|
1572
|
+
results, results_total = result_pair
|
|
1573
|
+
elif key == "workitems":
|
|
1574
|
+
workitems, workitems_total = result_pair
|
|
1575
|
+
except Exception as exc: # noqa: BLE001
|
|
1576
|
+
fetch_errors[key] = str(exc)
|
|
1577
|
+
|
|
1578
|
+
# ---------------------------------------------------------------
|
|
1579
|
+
# Output
|
|
1580
|
+
# ---------------------------------------------------------------
|
|
1581
|
+
if format_output.lower() == "json":
|
|
1582
|
+
output_data = dict(system_data)
|
|
1583
|
+
if not eff_packages:
|
|
1584
|
+
output_data.pop("packages", None)
|
|
1585
|
+
if not eff_feeds:
|
|
1586
|
+
output_data.pop("feeds", None)
|
|
1587
|
+
if eff_assets:
|
|
1588
|
+
output_data["_assets"] = {
|
|
1589
|
+
"totalCount": assets_total,
|
|
1590
|
+
"items": assets,
|
|
1591
|
+
"error": fetch_errors.get("assets"),
|
|
1592
|
+
}
|
|
1593
|
+
if eff_alarms:
|
|
1594
|
+
output_data["_alarms"] = {
|
|
1595
|
+
"totalCount": alarms_total,
|
|
1596
|
+
"items": alarms,
|
|
1597
|
+
"error": fetch_errors.get("alarms"),
|
|
1598
|
+
}
|
|
1599
|
+
if eff_jobs:
|
|
1600
|
+
output_data["_jobs"] = {
|
|
1601
|
+
"totalCount": jobs_total,
|
|
1602
|
+
"items": jobs_list,
|
|
1603
|
+
"error": fetch_errors.get("jobs"),
|
|
1604
|
+
}
|
|
1605
|
+
if eff_results:
|
|
1606
|
+
output_data["_results"] = {
|
|
1607
|
+
"totalCount": results_total,
|
|
1608
|
+
"items": results,
|
|
1609
|
+
"error": fetch_errors.get("results"),
|
|
1610
|
+
}
|
|
1611
|
+
if eff_workitems:
|
|
1612
|
+
output_data["_workitems"] = {
|
|
1613
|
+
"totalCount": workitems_total,
|
|
1614
|
+
"items": workitems,
|
|
1615
|
+
"error": fetch_errors.get("workitems"),
|
|
1616
|
+
}
|
|
1617
|
+
click.echo(json.dumps(output_data, indent=2))
|
|
1618
|
+
else:
|
|
1619
|
+
try:
|
|
1620
|
+
workspace_map = get_workspace_map()
|
|
1621
|
+
except Exception:
|
|
1622
|
+
workspace_map = {}
|
|
1623
|
+
_format_system_detail(system_data, workspace_map)
|
|
1624
|
+
|
|
1625
|
+
if eff_packages:
|
|
1626
|
+
_format_packages_table(system_data)
|
|
1627
|
+
|
|
1628
|
+
if eff_feeds:
|
|
1629
|
+
_format_feeds_table(system_data)
|
|
1630
|
+
|
|
1631
|
+
if eff_assets:
|
|
1632
|
+
if "assets" in fetch_errors:
|
|
1633
|
+
click.echo(
|
|
1634
|
+
f"\n ✗ Failed to load assets: {fetch_errors['assets']}", err=True
|
|
1635
|
+
)
|
|
1636
|
+
else:
|
|
1637
|
+
_format_assets_section(assets, assets_total, take)
|
|
1638
|
+
|
|
1639
|
+
if eff_alarms:
|
|
1640
|
+
if "alarms" in fetch_errors:
|
|
1641
|
+
click.echo(
|
|
1642
|
+
f"\n ✗ Failed to load alarms: {fetch_errors['alarms']}", err=True
|
|
1643
|
+
)
|
|
1644
|
+
else:
|
|
1645
|
+
_format_alarms_section(alarms, alarms_total, take)
|
|
1646
|
+
|
|
1647
|
+
if eff_jobs:
|
|
1648
|
+
if "jobs" in fetch_errors:
|
|
1649
|
+
click.echo(f"\n ✗ Failed to load jobs: {fetch_errors['jobs']}", err=True)
|
|
1650
|
+
else:
|
|
1651
|
+
_format_jobs_section(jobs_list, jobs_total, take)
|
|
1652
|
+
|
|
1653
|
+
if eff_results:
|
|
1654
|
+
if "results" in fetch_errors:
|
|
1655
|
+
click.echo(
|
|
1656
|
+
f"\n ✗ Failed to load results: {fetch_errors['results']}", err=True
|
|
1657
|
+
)
|
|
1658
|
+
else:
|
|
1659
|
+
_format_results_section(results, results_total, take)
|
|
1660
|
+
|
|
1661
|
+
if eff_workitems:
|
|
1662
|
+
if "workitems" in fetch_errors:
|
|
1663
|
+
click.echo(
|
|
1664
|
+
f"\n ✗ Failed to load work items: {fetch_errors['workitems']}",
|
|
1665
|
+
err=True,
|
|
1666
|
+
)
|
|
1667
|
+
else:
|
|
1668
|
+
_format_workitems_section(workitems, workitems_total, take, workitem_days)
|
|
1669
|
+
|
|
1670
|
+
click.echo()
|
|
1671
|
+
|
|
1672
|
+
except Exception as exc: # noqa: BLE001
|
|
1673
|
+
handle_api_error(exc)
|
|
1674
|
+
|
|
1675
|
+
@system.command(name="summary")
|
|
1676
|
+
@click.option(
|
|
1677
|
+
"--format",
|
|
1678
|
+
"-f",
|
|
1679
|
+
type=click.Choice(["table", "json"]),
|
|
1680
|
+
default="table",
|
|
1681
|
+
show_default=True,
|
|
1682
|
+
help="Output format",
|
|
1683
|
+
)
|
|
1684
|
+
def system_summary(format: str) -> None:
|
|
1685
|
+
"""Show fleet-wide system summary.
|
|
1686
|
+
|
|
1687
|
+
Displays counts for connected, disconnected, virtual, and pending
|
|
1688
|
+
systems.
|
|
1689
|
+
"""
|
|
1690
|
+
format_output = validate_output_format(format)
|
|
1691
|
+
|
|
1692
|
+
try:
|
|
1693
|
+
# Fetch both summaries
|
|
1694
|
+
summary_url = f"{_get_sysmgmt_base_url()}/get-systems-summary"
|
|
1695
|
+
summary_resp = make_api_request("GET", summary_url)
|
|
1696
|
+
summary_data = summary_resp.json()
|
|
1697
|
+
|
|
1698
|
+
pending_url = f"{_get_sysmgmt_base_url()}/get-pending-systems-summary"
|
|
1699
|
+
pending_resp = make_api_request("GET", pending_url)
|
|
1700
|
+
pending_data = pending_resp.json()
|
|
1701
|
+
|
|
1702
|
+
connected = summary_data.get("connectedCount", 0)
|
|
1703
|
+
disconnected = summary_data.get("disconnectedCount", 0)
|
|
1704
|
+
virtual = summary_data.get("virtualCount", 0)
|
|
1705
|
+
pending = pending_data.get("pendingCount", 0)
|
|
1706
|
+
total = connected + disconnected + virtual + pending
|
|
1707
|
+
|
|
1708
|
+
if format_output.lower() == "json":
|
|
1709
|
+
result = {
|
|
1710
|
+
"connectedCount": connected,
|
|
1711
|
+
"disconnectedCount": disconnected,
|
|
1712
|
+
"virtualCount": virtual,
|
|
1713
|
+
"pendingCount": pending,
|
|
1714
|
+
"totalCount": total,
|
|
1715
|
+
}
|
|
1716
|
+
click.echo(json.dumps(result, indent=2))
|
|
1717
|
+
else:
|
|
1718
|
+
click.echo("\nSystem Fleet Summary")
|
|
1719
|
+
click.echo("──────────────────────────────────────")
|
|
1720
|
+
click.echo(f" Connected: {connected}")
|
|
1721
|
+
click.echo(f" Disconnected: {disconnected}")
|
|
1722
|
+
click.echo(f" Virtual: {virtual}")
|
|
1723
|
+
click.echo(f" Pending: {pending}")
|
|
1724
|
+
click.echo(" ─────────────────")
|
|
1725
|
+
click.echo(f" Total: {total}")
|
|
1726
|
+
click.echo()
|
|
1727
|
+
|
|
1728
|
+
except Exception as exc: # noqa: BLE001
|
|
1729
|
+
handle_api_error(exc)
|
|
1730
|
+
|
|
1731
|
+
# ------------------------------------------------------------------
|
|
1732
|
+
# Phase 2: update, remove, report, job subgroup
|
|
1733
|
+
# ------------------------------------------------------------------
|
|
1734
|
+
|
|
1735
|
+
@system.command(name="update")
|
|
1736
|
+
@click.argument("system_id")
|
|
1737
|
+
@click.option("--alias", help="New alias for the system")
|
|
1738
|
+
@click.option(
|
|
1739
|
+
"--keyword",
|
|
1740
|
+
"keywords",
|
|
1741
|
+
multiple=True,
|
|
1742
|
+
help="Keywords to set (replaces all keywords, repeatable)",
|
|
1743
|
+
)
|
|
1744
|
+
@click.option(
|
|
1745
|
+
"--property",
|
|
1746
|
+
"properties",
|
|
1747
|
+
multiple=True,
|
|
1748
|
+
help="Property in key=value format (replaces all properties, repeatable)",
|
|
1749
|
+
)
|
|
1750
|
+
@click.option("--workspace", "-w", help="Workspace ID or name to move system to")
|
|
1751
|
+
@click.option("--scan-code", help="New scan code")
|
|
1752
|
+
@click.option("--location-id", help="New location ID")
|
|
1753
|
+
@click.option(
|
|
1754
|
+
"--format",
|
|
1755
|
+
"-f",
|
|
1756
|
+
type=click.Choice(["table", "json"]),
|
|
1757
|
+
default="table",
|
|
1758
|
+
show_default=True,
|
|
1759
|
+
help="Output format",
|
|
1760
|
+
)
|
|
1761
|
+
def update_system(
|
|
1762
|
+
system_id: str,
|
|
1763
|
+
alias: Optional[str],
|
|
1764
|
+
keywords: Tuple[str, ...],
|
|
1765
|
+
properties: Tuple[str, ...],
|
|
1766
|
+
workspace: Optional[str],
|
|
1767
|
+
scan_code: Optional[str],
|
|
1768
|
+
location_id: Optional[str],
|
|
1769
|
+
format: str,
|
|
1770
|
+
) -> None:
|
|
1771
|
+
"""Update a system's metadata.
|
|
1772
|
+
|
|
1773
|
+
SYSTEM_ID is the unique identifier of the system to update.
|
|
1774
|
+
Only the specified fields are changed; others remain unchanged.
|
|
1775
|
+
"""
|
|
1776
|
+
check_readonly_mode("update a system")
|
|
1777
|
+
format_output = validate_output_format(format)
|
|
1778
|
+
|
|
1779
|
+
try:
|
|
1780
|
+
patch_data: Dict[str, Any] = {}
|
|
1781
|
+
|
|
1782
|
+
if alias is not None:
|
|
1783
|
+
patch_data["alias"] = alias
|
|
1784
|
+
if keywords:
|
|
1785
|
+
patch_data["keywords"] = list(keywords)
|
|
1786
|
+
if properties:
|
|
1787
|
+
patch_data["properties"] = _parse_properties(properties)
|
|
1788
|
+
if workspace is not None:
|
|
1789
|
+
try:
|
|
1790
|
+
ws_map = get_workspace_map()
|
|
1791
|
+
ws_id = resolve_workspace_filter(workspace, ws_map)
|
|
1792
|
+
patch_data["workspace"] = ws_id
|
|
1793
|
+
except Exception:
|
|
1794
|
+
patch_data["workspace"] = workspace
|
|
1795
|
+
if scan_code is not None:
|
|
1796
|
+
patch_data["scanCode"] = scan_code
|
|
1797
|
+
if location_id is not None:
|
|
1798
|
+
patch_data["locationId"] = location_id
|
|
1799
|
+
|
|
1800
|
+
if not patch_data:
|
|
1801
|
+
click.echo("✗ No fields specified to update.", err=True)
|
|
1802
|
+
sys.exit(ExitCodes.INVALID_INPUT)
|
|
1803
|
+
|
|
1804
|
+
url = f"{_get_sysmgmt_base_url()}/systems/managed/{system_id}"
|
|
1805
|
+
make_api_request("PATCH", url, payload=patch_data)
|
|
1806
|
+
|
|
1807
|
+
if format_output.lower() == "json":
|
|
1808
|
+
# PATCH returns 204 on success, so output the sent data
|
|
1809
|
+
result = {"id": system_id, **patch_data}
|
|
1810
|
+
click.echo(json.dumps(result, indent=2))
|
|
1811
|
+
else:
|
|
1812
|
+
format_success("System updated", {"ID": system_id})
|
|
1813
|
+
|
|
1814
|
+
except Exception as exc: # noqa: BLE001
|
|
1815
|
+
handle_api_error(exc)
|
|
1816
|
+
|
|
1817
|
+
@system.command(name="remove")
|
|
1818
|
+
@click.argument("system_id")
|
|
1819
|
+
@click.option(
|
|
1820
|
+
"--force",
|
|
1821
|
+
is_flag=True,
|
|
1822
|
+
help="Skip confirmation and remove immediately from database",
|
|
1823
|
+
)
|
|
1824
|
+
def remove_system(
|
|
1825
|
+
system_id: str,
|
|
1826
|
+
force: bool,
|
|
1827
|
+
) -> None:
|
|
1828
|
+
"""Remove/unregister a system from SystemLink.
|
|
1829
|
+
|
|
1830
|
+
SYSTEM_ID is the unique identifier of the system to remove.
|
|
1831
|
+
|
|
1832
|
+
Without --force, prompts for confirmation and waits for the
|
|
1833
|
+
unregister job to complete. With --force, removes the system
|
|
1834
|
+
from the database immediately.
|
|
1835
|
+
"""
|
|
1836
|
+
check_readonly_mode("remove a system")
|
|
1837
|
+
|
|
1838
|
+
try:
|
|
1839
|
+
# Fetch system info for confirmation display
|
|
1840
|
+
display_name = system_id
|
|
1841
|
+
try:
|
|
1842
|
+
info_url = f"{_get_sysmgmt_base_url()}/systems?id={system_id}"
|
|
1843
|
+
info_resp = make_api_request("GET", info_url)
|
|
1844
|
+
info_data = info_resp.json()
|
|
1845
|
+
if isinstance(info_data, list) and info_data:
|
|
1846
|
+
display_name = info_data[0].get("alias", system_id)
|
|
1847
|
+
except Exception: # noqa: BLE001
|
|
1848
|
+
# Best-effort only: if we cannot fetch system info, fall back to
|
|
1849
|
+
# using the ID as the display name for the confirmation prompt.
|
|
1850
|
+
display_name = system_id
|
|
1851
|
+
|
|
1852
|
+
if not force:
|
|
1853
|
+
if not questionary.confirm(
|
|
1854
|
+
f"Are you sure you want to remove system '{display_name}'?",
|
|
1855
|
+
default=False,
|
|
1856
|
+
).ask():
|
|
1857
|
+
click.echo("Remove cancelled.")
|
|
1858
|
+
sys.exit(ExitCodes.SUCCESS)
|
|
1859
|
+
|
|
1860
|
+
url = f"{_get_sysmgmt_base_url()}/remove-systems"
|
|
1861
|
+
payload: Dict[str, Any] = {
|
|
1862
|
+
"tgt": [system_id],
|
|
1863
|
+
"force": force,
|
|
1864
|
+
}
|
|
1865
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
1866
|
+
data = resp.json()
|
|
1867
|
+
|
|
1868
|
+
# Check for failed removals
|
|
1869
|
+
failed = data.get("failedIds", []) if isinstance(data, dict) else []
|
|
1870
|
+
if failed:
|
|
1871
|
+
for fail in failed:
|
|
1872
|
+
fail_id = fail.get("id", "") if isinstance(fail, dict) else str(fail)
|
|
1873
|
+
fail_err = (
|
|
1874
|
+
fail.get("error", {}).get("message", "Unknown error")
|
|
1875
|
+
if isinstance(fail, dict)
|
|
1876
|
+
else "Unknown error"
|
|
1877
|
+
)
|
|
1878
|
+
click.echo(f"✗ Failed to remove {fail_id}: {fail_err}", err=True)
|
|
1879
|
+
sys.exit(ExitCodes.GENERAL_ERROR)
|
|
1880
|
+
|
|
1881
|
+
format_success(
|
|
1882
|
+
"System removed",
|
|
1883
|
+
{"Name": display_name, "ID": system_id},
|
|
1884
|
+
)
|
|
1885
|
+
|
|
1886
|
+
except Exception as exc: # noqa: BLE001
|
|
1887
|
+
handle_api_error(exc)
|
|
1888
|
+
|
|
1889
|
+
@system.command(name="report")
|
|
1890
|
+
@click.option(
|
|
1891
|
+
"--type",
|
|
1892
|
+
"report_type",
|
|
1893
|
+
type=click.Choice(["SOFTWARE", "HARDWARE"], case_sensitive=True),
|
|
1894
|
+
required=True,
|
|
1895
|
+
help="Report type to generate",
|
|
1896
|
+
)
|
|
1897
|
+
@click.option(
|
|
1898
|
+
"--filter",
|
|
1899
|
+
"filter_query",
|
|
1900
|
+
help="Filter expression to scope which systems to include",
|
|
1901
|
+
)
|
|
1902
|
+
@click.option(
|
|
1903
|
+
"--output",
|
|
1904
|
+
"-o",
|
|
1905
|
+
"output_path",
|
|
1906
|
+
type=click.Path(),
|
|
1907
|
+
required=True,
|
|
1908
|
+
help="File path to save the report",
|
|
1909
|
+
)
|
|
1910
|
+
def system_report(
|
|
1911
|
+
report_type: str,
|
|
1912
|
+
filter_query: Optional[str],
|
|
1913
|
+
output_path: str,
|
|
1914
|
+
) -> None:
|
|
1915
|
+
"""Generate a software or hardware report for systems.
|
|
1916
|
+
|
|
1917
|
+
The report is saved to the specified output file.
|
|
1918
|
+
"""
|
|
1919
|
+
check_readonly_mode("generate a system report")
|
|
1920
|
+
|
|
1921
|
+
try:
|
|
1922
|
+
url = f"{_get_sysmgmt_base_url()}/generate-systems-report"
|
|
1923
|
+
payload: Dict[str, Any] = {"type": report_type}
|
|
1924
|
+
if filter_query:
|
|
1925
|
+
payload["filter"] = filter_query
|
|
1926
|
+
|
|
1927
|
+
resp = make_api_request("POST", url, payload=payload)
|
|
1928
|
+
|
|
1929
|
+
with open(output_path, "wb") as f:
|
|
1930
|
+
f.write(resp.content if hasattr(resp, "content") else resp.text.encode())
|
|
1931
|
+
|
|
1932
|
+
format_success(
|
|
1933
|
+
"Report generated",
|
|
1934
|
+
{"Type": report_type, "Output": output_path},
|
|
1935
|
+
)
|
|
1936
|
+
|
|
1937
|
+
except Exception as exc: # noqa: BLE001
|
|
1938
|
+
handle_api_error(exc)
|
|
1939
|
+
|
|
1940
|
+
# ------------------------------------------------------------------
|
|
1941
|
+
# Job subgroup
|
|
1942
|
+
# ------------------------------------------------------------------
|
|
1943
|
+
|
|
1944
|
+
@system.group()
|
|
1945
|
+
def job() -> None:
|
|
1946
|
+
"""Manage system jobs.
|
|
1947
|
+
|
|
1948
|
+
Query, inspect, and cancel jobs dispatched to managed systems.
|
|
1949
|
+
"""
|
|
1950
|
+
|
|
1951
|
+
@job.command(name="list")
|
|
1952
|
+
@click.option(
|
|
1953
|
+
"--format",
|
|
1954
|
+
"-f",
|
|
1955
|
+
type=click.Choice(["table", "json"]),
|
|
1956
|
+
default="table",
|
|
1957
|
+
show_default=True,
|
|
1958
|
+
help="Output format",
|
|
1959
|
+
)
|
|
1960
|
+
@click.option(
|
|
1961
|
+
"--take",
|
|
1962
|
+
"-t",
|
|
1963
|
+
type=int,
|
|
1964
|
+
default=25,
|
|
1965
|
+
show_default=True,
|
|
1966
|
+
help="Items per page (table output only)",
|
|
1967
|
+
)
|
|
1968
|
+
@click.option("--system-id", help="Filter jobs by target system ID")
|
|
1969
|
+
@click.option(
|
|
1970
|
+
"--state",
|
|
1971
|
+
type=click.Choice(
|
|
1972
|
+
["SUCCEEDED", "FAILED", "INPROGRESS", "INQUEUE", "OUTOFQUEUE", "CANCELED"],
|
|
1973
|
+
case_sensitive=True,
|
|
1974
|
+
),
|
|
1975
|
+
help="Filter by job state",
|
|
1976
|
+
)
|
|
1977
|
+
@click.option("--function", help="Filter by salt function name (contains match)")
|
|
1978
|
+
@click.option(
|
|
1979
|
+
"--filter",
|
|
1980
|
+
"filter_query",
|
|
1981
|
+
help="Advanced API filter expression for jobs",
|
|
1982
|
+
)
|
|
1983
|
+
@click.option(
|
|
1984
|
+
"--order-by",
|
|
1985
|
+
type=click.Choice(
|
|
1986
|
+
["CREATED_AT", "UPDATED_AT", "STATE"],
|
|
1987
|
+
case_sensitive=False,
|
|
1988
|
+
),
|
|
1989
|
+
help="Order by field (default: created descending)",
|
|
1990
|
+
)
|
|
1991
|
+
def list_jobs(
|
|
1992
|
+
format: str,
|
|
1993
|
+
take: int,
|
|
1994
|
+
system_id: Optional[str],
|
|
1995
|
+
state: Optional[str],
|
|
1996
|
+
function: Optional[str],
|
|
1997
|
+
filter_query: Optional[str],
|
|
1998
|
+
order_by: Optional[str],
|
|
1999
|
+
) -> None:
|
|
2000
|
+
"""List and query jobs with optional filtering.
|
|
2001
|
+
|
|
2002
|
+
Supports convenience filters (--system-id, --state, --function)
|
|
2003
|
+
that are translated to API filter expressions.
|
|
2004
|
+
"""
|
|
2005
|
+
format_output = validate_output_format(format)
|
|
2006
|
+
|
|
2007
|
+
try:
|
|
2008
|
+
job_order_map: Dict[str, str] = {
|
|
2009
|
+
"CREATED_AT": "createdTimestamp descending",
|
|
2010
|
+
"UPDATED_AT": "lastUpdatedTimestamp descending",
|
|
2011
|
+
"STATE": "state",
|
|
2012
|
+
}
|
|
2013
|
+
api_order_by = (
|
|
2014
|
+
job_order_map.get(order_by.upper()) if order_by else "createdTimestamp descending"
|
|
2015
|
+
)
|
|
2016
|
+
|
|
2017
|
+
filter_expr = _build_job_filter(
|
|
2018
|
+
system_id=system_id,
|
|
2019
|
+
state=state,
|
|
2020
|
+
function=function,
|
|
2021
|
+
custom_filter=filter_query,
|
|
2022
|
+
)
|
|
2023
|
+
|
|
2024
|
+
def job_formatter(item: Dict[str, Any]) -> List[str]:
|
|
2025
|
+
fields = _get_job_display_fields(item)
|
|
2026
|
+
return [
|
|
2027
|
+
fields["jid"],
|
|
2028
|
+
fields["state"],
|
|
2029
|
+
fields["created"],
|
|
2030
|
+
fields["target"],
|
|
2031
|
+
]
|
|
2032
|
+
|
|
2033
|
+
headers = ["Job ID", "State", "Created", "Target System"]
|
|
2034
|
+
column_widths = _calculate_job_column_widths()
|
|
2035
|
+
|
|
2036
|
+
query_url = f"{_get_sysmgmt_base_url()}/query-jobs"
|
|
2037
|
+
|
|
2038
|
+
if format_output.lower() == "json":
|
|
2039
|
+
jobs = _query_all_items(
|
|
2040
|
+
query_url,
|
|
2041
|
+
filter_expr,
|
|
2042
|
+
api_order_by,
|
|
2043
|
+
_parse_simple_response,
|
|
2044
|
+
)
|
|
2045
|
+
mock_resp: Any = FilteredResponse({"jobs": jobs})
|
|
2046
|
+
UniversalResponseHandler.handle_list_response(
|
|
2047
|
+
resp=mock_resp,
|
|
2048
|
+
data_key="jobs",
|
|
2049
|
+
item_name="job",
|
|
2050
|
+
format_output=format_output,
|
|
2051
|
+
formatter_func=job_formatter,
|
|
2052
|
+
headers=headers,
|
|
2053
|
+
column_widths=column_widths,
|
|
2054
|
+
empty_message="No jobs found.",
|
|
2055
|
+
enable_pagination=False,
|
|
2056
|
+
page_size=take,
|
|
2057
|
+
)
|
|
2058
|
+
else:
|
|
2059
|
+
_handle_interactive_pagination(
|
|
2060
|
+
url=query_url,
|
|
2061
|
+
filter_expr=filter_expr,
|
|
2062
|
+
order_by=api_order_by,
|
|
2063
|
+
take=take,
|
|
2064
|
+
formatter_func=job_formatter,
|
|
2065
|
+
headers=headers,
|
|
2066
|
+
column_widths=column_widths,
|
|
2067
|
+
empty_message="No jobs found.",
|
|
2068
|
+
item_label="jobs",
|
|
2069
|
+
response_parser=_parse_simple_response,
|
|
2070
|
+
)
|
|
2071
|
+
|
|
2072
|
+
except Exception as exc: # noqa: BLE001
|
|
2073
|
+
handle_api_error(exc)
|
|
2074
|
+
|
|
2075
|
+
@job.command(name="get")
|
|
2076
|
+
@click.argument("job_id")
|
|
2077
|
+
@click.option(
|
|
2078
|
+
"--format",
|
|
2079
|
+
"-f",
|
|
2080
|
+
type=click.Choice(["table", "json"]),
|
|
2081
|
+
default="table",
|
|
2082
|
+
show_default=True,
|
|
2083
|
+
help="Output format",
|
|
2084
|
+
)
|
|
2085
|
+
def get_job(
|
|
2086
|
+
job_id: str,
|
|
2087
|
+
format: str,
|
|
2088
|
+
) -> None:
|
|
2089
|
+
"""Get detailed information about a specific job.
|
|
2090
|
+
|
|
2091
|
+
JOB_ID is the unique identifier of the job.
|
|
2092
|
+
"""
|
|
2093
|
+
format_output = validate_output_format(format)
|
|
2094
|
+
|
|
2095
|
+
try:
|
|
2096
|
+
url = f"{_get_sysmgmt_base_url()}/jobs?jid={job_id}"
|
|
2097
|
+
resp = make_api_request("GET", url)
|
|
2098
|
+
data = resp.json()
|
|
2099
|
+
|
|
2100
|
+
# API returns an array — take the first element
|
|
2101
|
+
if isinstance(data, list) and data:
|
|
2102
|
+
job_data = data[0]
|
|
2103
|
+
elif isinstance(data, dict):
|
|
2104
|
+
job_data = data
|
|
2105
|
+
else:
|
|
2106
|
+
click.echo(f"✗ Job not found: {job_id}", err=True)
|
|
2107
|
+
sys.exit(ExitCodes.NOT_FOUND)
|
|
2108
|
+
|
|
2109
|
+
if format_output.lower() == "json":
|
|
2110
|
+
click.echo(json.dumps(job_data, indent=2))
|
|
2111
|
+
else:
|
|
2112
|
+
config = job_data.get("config") or {}
|
|
2113
|
+
result = job_data.get("result") or {}
|
|
2114
|
+
targets = config.get("tgt", [])
|
|
2115
|
+
functions = config.get("fun", [])
|
|
2116
|
+
|
|
2117
|
+
click.echo("\nJob Details")
|
|
2118
|
+
click.echo("──────────────────────────────────────")
|
|
2119
|
+
click.echo(f" Job ID: {job_data.get('jid', 'N/A')}")
|
|
2120
|
+
click.echo(f" State: {job_data.get('state', 'N/A')}")
|
|
2121
|
+
click.echo(
|
|
2122
|
+
f" Target: {targets[0] if targets else job_data.get('id', 'N/A')}"
|
|
2123
|
+
)
|
|
2124
|
+
click.echo(f" Functions: {', '.join(functions) if functions else 'N/A'}")
|
|
2125
|
+
|
|
2126
|
+
click.echo("")
|
|
2127
|
+
click.echo(" Timestamps:")
|
|
2128
|
+
click.echo(f" Created: {job_data.get('createdTimestamp', 'N/A')}")
|
|
2129
|
+
click.echo(f" Updated: {job_data.get('lastUpdatedTimestamp', 'N/A')}")
|
|
2130
|
+
click.echo(f" Dispatched: {job_data.get('dispatchedTimestamp', 'N/A')}")
|
|
2131
|
+
|
|
2132
|
+
if result:
|
|
2133
|
+
click.echo("")
|
|
2134
|
+
click.echo(" Result:")
|
|
2135
|
+
ret_codes = result.get("retcode", [])
|
|
2136
|
+
ret_values = result.get("return", [])
|
|
2137
|
+
successes = result.get("success", [])
|
|
2138
|
+
click.echo(f" Return Code: " f"{ret_codes[0] if ret_codes else 'N/A'}")
|
|
2139
|
+
click.echo(f" Return: " f"{ret_values[0] if ret_values else 'N/A'}")
|
|
2140
|
+
click.echo(f" Success: " f"{successes[0] if successes else 'N/A'}")
|
|
2141
|
+
|
|
2142
|
+
click.echo()
|
|
2143
|
+
|
|
2144
|
+
except Exception as exc: # noqa: BLE001
|
|
2145
|
+
handle_api_error(exc)
|
|
2146
|
+
|
|
2147
|
+
@job.command(name="summary")
|
|
2148
|
+
@click.option(
|
|
2149
|
+
"--format",
|
|
2150
|
+
"-f",
|
|
2151
|
+
type=click.Choice(["table", "json"]),
|
|
2152
|
+
default="table",
|
|
2153
|
+
show_default=True,
|
|
2154
|
+
help="Output format",
|
|
2155
|
+
)
|
|
2156
|
+
def job_summary(format: str) -> None:
|
|
2157
|
+
"""Show job summary — active, failed, and succeeded counts."""
|
|
2158
|
+
format_output = validate_output_format(format)
|
|
2159
|
+
|
|
2160
|
+
try:
|
|
2161
|
+
url = f"{_get_sysmgmt_base_url()}/get-jobs-summary"
|
|
2162
|
+
resp = make_api_request("GET", url)
|
|
2163
|
+
data = resp.json()
|
|
2164
|
+
|
|
2165
|
+
active = data.get("activeCount", 0)
|
|
2166
|
+
succeeded = data.get("succeededCount", 0)
|
|
2167
|
+
failed = data.get("failedCount", 0)
|
|
2168
|
+
total = active + succeeded + failed
|
|
2169
|
+
|
|
2170
|
+
if format_output.lower() == "json":
|
|
2171
|
+
result = {
|
|
2172
|
+
"activeCount": active,
|
|
2173
|
+
"succeededCount": succeeded,
|
|
2174
|
+
"failedCount": failed,
|
|
2175
|
+
"totalCount": total,
|
|
2176
|
+
}
|
|
2177
|
+
click.echo(json.dumps(result, indent=2))
|
|
2178
|
+
else:
|
|
2179
|
+
click.echo("\nJob Summary")
|
|
2180
|
+
click.echo("──────────────────────────────────────")
|
|
2181
|
+
click.echo(f" Active: {active}")
|
|
2182
|
+
click.echo(f" Succeeded: {succeeded}")
|
|
2183
|
+
click.echo(f" Failed: {failed}")
|
|
2184
|
+
click.echo(" ─────────────────")
|
|
2185
|
+
click.echo(f" Total: {total}")
|
|
2186
|
+
click.echo()
|
|
2187
|
+
|
|
2188
|
+
except Exception as exc: # noqa: BLE001
|
|
2189
|
+
handle_api_error(exc)
|
|
2190
|
+
|
|
2191
|
+
@job.command(name="cancel")
|
|
2192
|
+
@click.argument("job_id")
|
|
2193
|
+
@click.option("--system-id", help="Target system ID (for disambiguation)")
|
|
2194
|
+
def cancel_job(
|
|
2195
|
+
job_id: str,
|
|
2196
|
+
system_id: Optional[str],
|
|
2197
|
+
) -> None:
|
|
2198
|
+
"""Cancel a running job.
|
|
2199
|
+
|
|
2200
|
+
JOB_ID is the unique identifier of the job to cancel.
|
|
2201
|
+
"""
|
|
2202
|
+
check_readonly_mode("cancel a job")
|
|
2203
|
+
|
|
2204
|
+
try:
|
|
2205
|
+
url = f"{_get_sysmgmt_base_url()}/cancel-jobs"
|
|
2206
|
+
cancel_request: Dict[str, Any] = {"jid": job_id}
|
|
2207
|
+
if system_id:
|
|
2208
|
+
cancel_request["systemId"] = system_id
|
|
2209
|
+
|
|
2210
|
+
payload: Dict[str, Any] = {"jobs": [cancel_request]}
|
|
2211
|
+
make_api_request("POST", url, payload=payload)
|
|
2212
|
+
|
|
2213
|
+
format_success("Job cancelled", {"Job ID": job_id})
|
|
2214
|
+
|
|
2215
|
+
except Exception as exc: # noqa: BLE001
|
|
2216
|
+
handle_api_error(exc)
|