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.
Files changed (74) hide show
  1. slcli/__init__.py +1 -0
  2. slcli/__main__.py +23 -0
  3. slcli/_version.py +4 -0
  4. slcli/asset_click.py +1289 -0
  5. slcli/cli_formatters.py +218 -0
  6. slcli/cli_utils.py +504 -0
  7. slcli/comment_click.py +602 -0
  8. slcli/completion_click.py +418 -0
  9. slcli/config.py +81 -0
  10. slcli/config_click.py +498 -0
  11. slcli/dff_click.py +979 -0
  12. slcli/dff_decorators.py +24 -0
  13. slcli/example_click.py +404 -0
  14. slcli/example_loader.py +274 -0
  15. slcli/example_provisioner.py +2777 -0
  16. slcli/examples/README.md +134 -0
  17. slcli/examples/_schema/schema-v1.0.json +169 -0
  18. slcli/examples/demo-complete-workflow/README.md +323 -0
  19. slcli/examples/demo-complete-workflow/config.yaml +638 -0
  20. slcli/examples/demo-test-plans/README.md +132 -0
  21. slcli/examples/demo-test-plans/config.yaml +154 -0
  22. slcli/examples/exercise-5-1-parametric-insights/README.md +101 -0
  23. slcli/examples/exercise-5-1-parametric-insights/config.yaml +1589 -0
  24. slcli/examples/exercise-7-1-test-plans/README.md +93 -0
  25. slcli/examples/exercise-7-1-test-plans/config.yaml +323 -0
  26. slcli/examples/spec-compliance-notebooks/README.md +140 -0
  27. slcli/examples/spec-compliance-notebooks/config.yaml +112 -0
  28. slcli/examples/spec-compliance-notebooks/notebooks/SpecAnalysis_ComplianceCalculation.ipynb +1553 -0
  29. slcli/examples/spec-compliance-notebooks/notebooks/SpecComplianceCalculation.ipynb +1577 -0
  30. slcli/examples/spec-compliance-notebooks/notebooks/SpecfileExtractionAndIngestion.ipynb +912 -0
  31. slcli/examples/spec-compliance-notebooks/spec_template.xlsx +0 -0
  32. slcli/feed_click.py +892 -0
  33. slcli/file_click.py +932 -0
  34. slcli/function_click.py +1400 -0
  35. slcli/function_templates.py +85 -0
  36. slcli/main.py +406 -0
  37. slcli/mcp_click.py +269 -0
  38. slcli/mcp_server.py +748 -0
  39. slcli/notebook_click.py +1770 -0
  40. slcli/platform.py +345 -0
  41. slcli/policy_click.py +679 -0
  42. slcli/policy_utils.py +411 -0
  43. slcli/profiles.py +411 -0
  44. slcli/response_handlers.py +359 -0
  45. slcli/routine_click.py +763 -0
  46. slcli/skill_click.py +253 -0
  47. slcli/skills/slcli/SKILL.md +713 -0
  48. slcli/skills/slcli/references/analysis-recipes.md +474 -0
  49. slcli/skills/slcli/references/filtering.md +236 -0
  50. slcli/skills/systemlink-webapp/SKILL.md +744 -0
  51. slcli/skills/systemlink-webapp/references/deployment.md +123 -0
  52. slcli/skills/systemlink-webapp/references/nimble-angular.md +380 -0
  53. slcli/skills/systemlink-webapp/references/systemlink-services.md +192 -0
  54. slcli/ssl_trust.py +93 -0
  55. slcli/system_click.py +2216 -0
  56. slcli/table_utils.py +124 -0
  57. slcli/tag_click.py +794 -0
  58. slcli/templates_click.py +599 -0
  59. slcli/testmonitor_click.py +1667 -0
  60. slcli/universal_handlers.py +305 -0
  61. slcli/user_click.py +1218 -0
  62. slcli/utils.py +832 -0
  63. slcli/web_editor.py +295 -0
  64. slcli/webapp_click.py +981 -0
  65. slcli/workflow_preview.py +287 -0
  66. slcli/workflows_click.py +988 -0
  67. slcli/workitem_click.py +2258 -0
  68. slcli/workspace_click.py +576 -0
  69. slcli/workspace_utils.py +206 -0
  70. systemlink_cli-1.3.1.dist-info/METADATA +20 -0
  71. systemlink_cli-1.3.1.dist-info/RECORD +74 -0
  72. systemlink_cli-1.3.1.dist-info/WHEEL +4 -0
  73. systemlink_cli-1.3.1.dist-info/entry_points.txt +7 -0
  74. 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)