# elm-notes.yaml
# Hand-curated notes on elm command behaviour, response fields, and filter patterns.
# Supplements the swagger — focuses on what the swagger doesn't tell you:
# real response structure, what filters actually work, and gotchas.
#
# `elm COMMAND --info` shows the entry for COMMAND (built in by mkinfo.py at make
# time), so a change here needs `make` before it appears there. AI assistants
# read that output, so every line costs tokens each time the command is used:
# - Keep entries to what is tested or different from the swagger. --info already
#   lists every documented field, so key_fields only needs fields whose comment
#   adds meaning.
# - One fact once: fix or delete a wrong note rather than adding a correction.
# - Say how and when a claim was verified.
# - Duplicate keys fail the build; `make testdocs` fails if a command's --info
#   passes 16,000 characters. See "AI-facing docs" in CLAUDE.md.
#
# WARNING: This file is partially AI-assisted and may contain inaccuracies.
# Entries should be verified against the live API before being relied upon.
# Correct errors in place and remove this warning from individual entries once verified.
#
# HOW FLAGS ARE GENERATED (important for understanding missing/present options):
#
# The LM swagger incompletely documents query parameters. elm compensates in two ways:
#
# 1. Template injection (_jnja/command.py.j2): --sort/-S and --fields/-f are injected
#    on EVERY command that doesn't already have them declared in the swagger. This means
#    all commands have -f and -S regardless of what the swagger says.
#
# 2. swagger.undocumented.json: overrides _defs/ entries for specific endpoints where
#    the official swagger omits pagination params. Currently covers:
#    - 11 list endpoints missing size/offset/filter (see GitHub issue #47)
#    - IntegrationList (fields/size/offset/filter all missing from official swagger)
#    - PortalInfo (/setting/companySetting — not in official swagger at all)
#    Run `make init` to merge official swagger + undocumented into _defs/.
#
# "Standard list" in a note means -s/-o/-F/-f/-S/-c/-C all work.
# Remaining "no -s" notes are for genuinely non-paginated endpoints
# (single-object responses, sub-path resources, etc.).

# ---
# Output formats
# ---
# Global flag: elm -f <format> <command>
# Verified against: elm -f <fmt> DeviceGroupList -s2 -f id,name
_formats:
  csv:
    description: Comma-separated values with header row
    pipeable: true
    example: "id,name\n1,foo\n2,bar"

  tsv:
    description: True tab-separated values (tab delimiter, no alignment padding)
    pipeable: true
    note: "Distinct from 'tab' which is tabulate's human-readable aligned table. Supports -H and -I flags."
    example: "id\tname\n1\tfoo\n2\tbar"

  values:
    description: Bare values with no headers, no padding — for shell variable assignment and piping
    pipeable: true
    note: |
      Single field: one value per line. Multiple fields: TSV with no header row.
      Removes jq from simple scalar extraction:
        gid=$(elm -f values DeviceGroupList -f id -F name:"Linux Devices")
        elm DeviceList -s0 -F hostGroupIds~${gid}
      For multi-value array cases requiring --argjson, jq is still needed.
      Does not support -H or -I (headers are never shown regardless).
    example: "42"

  json:
    description: Compact JSON wrapped in command name key
    pipeable: true  # pipe to jq
    example: '{"DeviceGroupList": [{"id": 1, "name": "foo"}]}'

  jsonl:
    description: JSON Lines — one JSON object per line, no command-name wrapper
    pipeable: true
    note: "No {CommandName: [...]} wrapper unlike 'json'. Each line is a complete JSON object. Directly readable by DuckDB and jq without preprocessing."
    example: '{"id": 1, "name": "foo"}\n{"id": 2, "name": "bar"}'
    use_for: "Cache files for DuckDB queries; streaming pipelines; tools that expect newline-delimited JSON"

  prettyjson:
    description: Indented colour-highlighted JSON (uses pygments) — same structure as json
    pipeable: true  # colours may interfere; use json for clean piping
    example: "indented + syntax highlighted"

  tab:
    description: Tabulate 'simple' format — headers + separator line + aligned columns
    pipeable: false
    note: "Plain text, NOT Markdown. This is what md produced before issue #55"

  md:
    description: Markdown pipe table — alias for gfm (issue #55)
    pipeable: false
    note: "Was tabulate 'simple' (identical to tab) until issue #55, which is
      why older notes call it a historical alias. It now emits a real Markdown
      table; use tab if you want the plain-text one."

  txt:
    description: Plain text — headers + aligned columns, no separator line under headers
    pipeable: false

  gfm:
    description: GitHub Flavored Markdown table — pipe-delimited with header separator
    pipeable: false
    example: "| id | name |\n|---|---|\n| 1 | foo |"

  pipe:
    description: Markdown pipe table — like gfm but with right-aligned numbers and left-aligned strings
    pipeable: false

  jira:
    description: Jira wiki markup table
    pipeable: false
    example: "|| id || name ||\n| 1 | foo |"

  html:
    description: Minified HTML table
    pipeable: false

  prettyhtml:
    description: Syntax-highlighted HTML (uses pygments) — for terminal display only
    pipeable: false

  xml:
    description: Compact XML — includes a row index field
    pipeable: false
    note: "Adds an <index> element per row that is not in the source data"

  prettyxml:
    description: Indented XML — same structure as xml
    pipeable: false

  latex:
    description: LaTeX tabular environment
    pipeable: false

  rst:
    description: reStructuredText grid table
    pipeable: false

  raw:
    description: Python dict repr — NOT JSON, cannot pipe to jq
    pipeable: false
    note: "Output is a Python list of dicts e.g. [{'id': 1, 'name': 'foo'}] — not valid JSON"

  api:
    description: Makes the request, then prints the URL and Authorization header instead of the response data
    pipeable: false
    note: "Authorization header contains the HMAC signature — sensitive, do not share output
      or commit it to version control. The header is time-stamped (epoch ms in the third
      colon-separated field). The LMv1 signature eventually expires, but the expiry window is
      long and inconsistent across portals/environments — measured still valid after 3h22m on one
      sandbox, yet expired in under 2h elsewhere — so do not rely on it persisting; run curl
      promptly. The request IS made; response.url is used for the URL output."
    use_for: "Debug exactly what API call elm made, or replay it with curl"
    example: |
      curl -H "Authorization: LMv1 ..." "https://acme.logicmonitor.com/santaba/rest/..."

  sqlite:
    description: Appends query results to a local SQLite database file — a running database, not a new file per run
    pipeable: false
    requires: "-o/--filename is mandatory; errors if stdout is requested"
    note: |
      Table name is derived by converting the CamelCase command name to snake_case:
        DeviceList      -> device_list
        AlertList       -> alert_list
        MetricsUsage    -> metrics_usage
        CollectorById   -> collector_by_id
      Every row gets a 'fetched_at' column (first column, UTC ISO8601, e.g. '2026-05-29T01:44:25Z').
      Repeated runs append new rows — data accumulates over time. To get the latest snapshot only:
        SELECT * FROM device_list WHERE fetched_at = (SELECT MAX(fetched_at) FROM device_list)
      Nested dict/list fields (e.g. customProperties) are serialised to JSON strings for storage.
      Multiple endpoints can share one file — each lands in its own table.
      Uses stdlib sqlite3, no extra dependencies.
      The file is created on first run; subsequent runs append to the existing file/table.
    use_for: "Building a local queryable cache across multiple elm runs; replacing ad-hoc JSON file collections"
    example: |
      elm -f sqlite -o lm.sqlite DeviceList -s0
      elm -f sqlite -o lm.sqlite AlertList -s0
      sqlite3 lm.sqlite "SELECT displayName, hostStatus FROM device_list LIMIT 5"
      duckdb lm.sqlite "SELECT * FROM alert_list WHERE fetched_at > '2026-05-28'"

# ---
# Global notes (apply to all commands)
# ---
_global:
  flags:
    version: "-V/--version — show version and exit"
    list:    "-l/--list — list available credential profiles and exit; active marked with '* '; no credentials required; config.example.ini excluded"
    profile: "-p/--profile NAME — resolve ~/.config/logicmonitor/credentials/<NAME>.ini; strips .ini suffix if included; pre-scanned from sys.argv so --list reflects it correctly"
    config:  "--config PATH — full path to any .ini file; handled by click_config_file; use for credentials outside the default directory"
    format:  "-f/--format FORMAT — output format; must come BEFORE subcommand name"
    size:    "-s/--size INTEGER — results to return [0-1000]; 0 = all up to 1000 [default: 50]; -s0 and -s1000 are equivalent (confirmed by live test)"
    access_id:  "-i/--access_id TEXT — override access_id from config file; LM logs this value verbatim as the username field in AuditLogList"
    access_key: "-k/--access_key TEXT — override access_key from config file"
  performance:
    - "Startup (v1.8.0+): --version, --help, --list, and tab-completion load in ~0.2s.
      Command modules (_cmds/) are loaded lazily -- only the invoked command is imported.
      Heavy libraries (pandas, requests, tabulate, pygments) load inside engine()/output()
      so they are only paid when an API call is made."
    - "First run after compilation: macOS Gatekeeper verifies new binaries - expect 10-30s
      once per install. Not worth optimising; steady-state is what matters."
    - "Steady-state API call time is mostly network/LM latency. elm adds ~0.5-1s overhead
      (Python + pandas + JSON + output). Confirmed by timing curl directly against the API:
      all three machines (macOS personal, macOS corporate, Linux) hit the same portal in
      ~0.7s. Earlier slow times on the corporate Mac were against a different portal with
      a less favourable network path from that location -- not a proxy/SSL inspection issue.
      To isolate elm overhead: elm --profile NAME -f api COMMAND -s1 > /tmp/t.txt && curl
      with URL+auth from that file (auth eventually expires — window is long but inconsistent
      across environments; run the steps together to be safe)."
  gotchas:
    - "Never use 2>&1 when piping elm output to jq — elm warnings go to stderr and
      will corrupt the JSON stream causing jq parse errors"
    - "Multiple -F flags AND together (fixed in v1.7.10; previously only the last
      -F was sent). Comma-separated in a single -F also works and is equivalent:
        -F 'field1:val1' -F 'field2:val2'  ==  -F 'field1:val1,field2:val2'"
    - "API filter does not support OR across multiple values for the same field —
      use jq client-side for that"
    - "Array fields cannot be filtered for emptiness server-side (no 'is empty'
      operator) — use jq: select(.field | length == 0)"
    - "-s0 means return all records (up to 1000), not zero records"
    - "validate_filter() wraps values in double quotes automatically — do not
      double-quote values in shell, it causes doubling"
    - "Filter values are auto-escaped (v1.8.9+): validate_filter() escapes any
      backslash or double-quote in the value before wrapping it in quotes, so just
      type the literal value you want — elm handles it. Earlier versions wrapped the
      raw value, so a trailing backslash escaped the closing quote
      (-F 'hostname~foo\\' became hostname~\"foo\\\" -> 400 Bad Request) and the
      workaround was to double the backslash. That workaround is now wrong: with
      auto-escaping, -F 'hostname~foo\\\\' matches TWO literal backslashes."
    - "Comma is the filter-clause separator, so a literal comma in a search VALUE
      must be escaped as \\, — otherwise elm splits the value into two clauses and
      the second (which has no operator) errors with 'format must be FIELD[op]VALUE'.
      Free-text substring search is otherwise robust: -F 'name~ServiceNow' is
      case-insensitive (ServiceNow == servicenow) and spaces work
      (-F 'name~ServiceNow DEV'). Only the comma needs escaping:
      -F 'name~Smith\\, Inc' searches for the literal 'Smith, Inc'."
    - "--format TEXT on some commands (AuditLogList, ConfigSourceList, DatasourceList,
      EventSourceList, JobMonitorList, LogSourceList, PropertyRulesList) is an LM API-level
      parameter, NOT elm's output format flag. It instructs the LM API to return data in that
      format directly, bypassing elm's output pipeline. Use elm's global --format flag before
      the subcommand for output formatting instead. Known issue: AuditLogList --format csv
      causes a JSONDecodeError because the API returns raw CSV that elm cannot parse."

# ---
# Standard options shared by almost all List commands
# ---
_standard_list_options:
  note: "All *List commands support these unless noted otherwise"
  size:   "-s/--size INTEGER — results to return [0-1000]; 0 means all up to 1000 [default: 50]; -s0 and -s1000 are equivalent (confirmed by live test)"
  offset: "-o/--offset INTEGER — skip N records; combine with --size for pagination [default: 0]"
  fields: "-f/--fields FIELD,... — server-side field projection (comma-separated)"
  filter: "-F/--filter FIELD[op]VALUE,... — server-side filter; see operators in --help"
  sort:   "-S/--sort [+,-]FIELD,... — server-side sort; + ascending, - descending"
  count:  "-c/--count — returns min(-s, matching rows), i.e. how many records this request actually fetched (respects -F). Verified 2026-08-24: -c with the default -s50 returns 50, -c -s7 returns 7, -c -s0 returns 1000 — it is echoing the page size, not counting matches. So a -c result EQUAL to -s means 'at least this many', never 'exactly this many', and it is only a true count when it comes back smaller than -s. Use -C for counting."
  total:  "-C/--total — return LM's server-side total for the query. RESPECTS -F (verified 2026-08-24 against a live portal: DeviceList -C = 1576 unfiltered vs 1024 with a -F filter, and exact on narrow filters where the row count could be cross-checked). Ignores -s, since the total is independent of page size. Mechanically: engine.py's _output_only set means -C/-c are never sent as query params, so the filter still reaches LM and -C just prints obj['total'] for that filtered query. Most list endpoints return an exact total. AlertList and AuditLogList instead return LM's negative sentinel (elm shows '>N' with a warning); for those two only, and only when the true count is ≤ 1000, '-c -s0' is a workaround."

# ---
# Websites
# ---
WebsiteList:
  path: /website/websites
  unique_options:
    collectorIds: "--collectorIds TEXT — filters websites by collector ID; comma-separated; synthetic collector IDs are negative (e.g. -7); tested --collectorIds -7 returned 0 (no websites on that collector); website response has a 'collectors' array not a single collectorId field (verified)"
  key_fields:
    id:       integer
    name:     string
    domain:   string # hostname or IP being monitored
    groupId:  integer # immediate parent group only — no subgroup traversal server-side
    type:     string # "webcheck" or "pingcheck"
    properties:
      type: array
      items: {name: string, value: string}
      note: "API filter works for specific name/value pairs; empty check must be client-side"
    status:   string
    sdtStatus: string
  filter_patterns:
    - "properties.name:foo,properties.value:bar  # sites with a specific property set"
    - "groupId:<id>  # sites in an exact group (not subgroups)"
  gotchas:
    - "groupId is exact match only — to query a hierarchy, collect all child group
      IDs first via WebsiteGroupList then filter client-side"
    - "properties being empty vs missing a specific key are different problems:
      empty uses jq select(.properties | length == 0);
      missing key uses jq array subtraction ($required - $present)"

WebsiteGroupList:
  path: /website/groups
  key_fields:
    id:                  integer
    name:                string
    fullPath:            string # full slash-separated path e.g. acme/customer/acme
    parentId:            integer
    numOfDirectSubGroups: integer
    numOfDirectWebsites:  integer
    numOfWebsites:        integer # includes all descendant websites
    properties:
      type: array
      items: {name: string, value: string}
  filter_patterns:
    - 'fullPath~"acme/"  # all groups whose path contains acme/ (catches all depths)'
  gotchas:
    - "fullPath~ with a trailing slash scopes to children of that group;
      without the slash it also matches the group itself"

# ---
# Devices
# ---
DeviceList:
  path: /device/devices
  unique_options:
    includeDeletedResources: "--includeDeletedResources / --dont-includeDeletedResources — include soft-deleted devices; deletedTimeInMs > 0 on deleted records; default excludes them"
    start:        "--start INTEGER (int64) — purpose unverified; co-occurs with netflowFilter on all netflow-capable endpoints; no visible effect on DeviceList in testing"
    end:          "--end INTEGER (int64) — same caveat as start"
    netflowFilter: "--netflowFilter TEXT — co-occurs with start/end on netflow endpoints; no visible effect on DeviceList in testing; LM swagger has no description"
  key_fields:
    id:           integer
    name:         string # hostname or IP
    displayName:  string # friendly name shown in UI
    hostGroupIds: string # comma-separated list of group IDs — use ~ not : to filter
    systemProperties:
      type: array
      items: {name: string, value: string}
    customProperties:
      type: array
      items: {name: string, value: string}
    autoProperties:
      type: array
      items: {name: string, value: string}
    preferredCollectorGroupName: string
    preferredCollectorGroupId:   integer
    preferredCollectorId:        integer  # collector currently assigned to this device
    autoBalancedCollectorGroupId: integer # 0 = pinned to preferredCollectorId, LM will not move it; non-zero = group ID, device is in the auto-balance pool and LM may reassign preferredCollectorId as load shifts
    sdtStatus:    string # 3-position SDT rollup "<group>-<device>-<instance>" — see sdtStatus_legend
  sdtStatus_legend:
    format: '"<group>-<device>-<instance>", each position "SDT" (active) or "none". e.g. none-SDT-none, SDT-none-none, SDT-SDT-SDT'
    positions:
      - "1 group:    SDT when a group-level SDT that APPLIES to this device is active"
      - "2 device:   SDT when a device-scoped SDT (ResourceSDT) is active"
      - "3 instance: SDT when an instance / datasource-instance SDT is active"
    rules:
      - "Positions are independent — an SDT does NOT cascade across them. A group SDT lights position 1 only, never 2 or 3."
      - "Applicability, not existence: a group SDT narrowed to a datasource/instance the device does not have will NOT light position 1 for that device (LM evaluates whether it actually applies)."
    verified: "Tested on a single device, 2026-06-26, by toggling SDTs scope-by-scope: none-SDT-none (device SDT) -> none-SDT-SDT (+ instance SDT) -> stayed none-SDT-SDT when a group SDT was limited to a datasource the device lacks -> SDT-SDT-SDT once the group SDT applied -> SDT-SDT-none (instance expired) -> SDT-none-none (device expired, group only)."
    system.sysinfo:
      note: "Full uname string e.g. 'Linux hostname 3.10.0-... x86_64' — reliable for OS type"
      linux_filter: "systemProperties.name:system.sysinfo,systemProperties.value~Linux"
    auto.endpoint.model:
      note: "Auto-discovered device model — also useful for OS/device type identification"
  filter_patterns:
    - "customProperties.name:foo,customProperties.value:bar"
    - "systemProperties.name:system.sysinfo,systemProperties.value~Linux  # Linux devices"
    - "systemProperties.name:system.groups,systemProperties.value~Root/Group/"
    - "hostGroupIds~<id>  # tilde because hostGroupIds is a comma-separated string"
    - "displayName~foo,preferredCollectorGroupName\\!~foo  # ! must be escaped in bash"
  gotchas:
    - "hostGroupIds is a comma-separated string, not an array — use ~ not : to match"
    - "-f/--fields may be ignored for some device types (e.g. deviceType:18 synthetic checks return full schema regardless of -f)"
    - "To list devices assigned to a collector group, filter on preferredCollectorGroupId (the assigned group). autoBalancedCollectorGroupId is only populated once LM has actually placed the device, so it can be empty for a group whose devices are assigned but not yet balanced — filtering on it may return 0 devices (e.g. CollectorGroupList -F 'autoBalancedCollectorGroupId:N' found nothing while preferredCollectorGroupId:N found the members)"
    - "There is no way to effectively filter by effective property (per LM community)"
    - "Neither OS signal is reliable alone. Device groups (e.g. 'Linux Servers') can contain other
      devices, and system.sysinfo can report the COLLECTOR's OS rather than the device's (seen for
      HTTP endpoints, network appliances and out-of-band hardware, on Linux and Windows collectors).
      Combine them, and sanity-check results against device and datasource names."
    - "alertDisableStatus is a string '<group>-<device>-<instance>', each part 'disable' or 'none' (verified 2026-09-17). The middle part matches the device's own disableAlerting exactly; the first part is alerting disabled by a group the device is in or inherits from, which is usually far more devices (one portal: 39 by their own setting, 744 in total). Not filterable: -F alertDisableStatus~disable returns 0 and !: returns everything; fetch and filter with jq (recipe: examples/health-checks.md)."
    - "hostStatus values: normal, dead, dead-collector."
    - "Device uptime is upTimeInSeconds on the device record (there is no 'uptime' field): elm DeviceList -F id:N -f displayName,upTimeInSeconds. That is the current run only; uptime over time comes from the device's uptime datasource via DeviceDatasourceInstanceData. MetricsSummary and MetricsUsage are licence and device counts, not uptime."
    - "DeviceList includes things that are not devices. Real devices are deviceType 0 (the swagger calls 1 an APPGROUP device; tools/elm-datasource-matrix.py also keeps 1, and no type-1 devices were on the test portal to check). To keep only real devices, fetch the non-device ids with -F 'deviceType!:0' (add -F 'deviceType!:1' to keep type 1 as well; !: works on DeviceList, verified: count is exactly total - type0 - type1) and drop them client-side. deviceType is not returned by AssociatedDeviceListByDataSourceId."
    - "A device that is itself a collector host: match its id against CollectorList collectorDeviceId. Names need not match."
  deviceType_values:
    "0": "standard device (the normal case), confirmed"
    "1": "APPGROUP device, per the swagger; not seen on the test portal"
    "2": "AWS account / cloud resource, confirmed by naming"
    "4": "Azure account / cloud resource, confirmed by naming"
    "3": "service device, per the swagger; not seen on the test portal"
    "6": "biz_service device (LM Service / Service Insight, incl. APM traces), swagger + naming"
    "7": "GCP, per LM; not seen on the test portal"
    "8": "Kubernetes resource (nodes, pods, services), confirmed by naming"
    "9": "Push Metrics / custom-metric device, observed (names often end ...pushmetric)"
    "11": "synthetic / web check (auth checks, Selenium tests), observed"
    "18": "synthetic check (returns full schema regardless of -f), observed"

DeviceGroupList:
  path: /device/groups
  key_fields:
    id:       integer
    name:     string
    fullPath: string
    parentId: integer
    customProperties:
      type: array
      items: {name: string, value: string}

# ---
# Datasources
# ---
DatasourceList:
  path: /setting/datasources
  options:
    format: "--format TEXT — LM API format param; do NOT use (see _global gotcha); use elm's global --format flag instead"
  key_fields:
    id:          integer
    name:        string
    displayName: string
    appliesTo:   string # AppliesTo expression — defines which devices get this datasource
    group:       string # datasource group/category
    collectMethod: string
    technology:  string
  filter_patterns:
    - "name:NTPv4  # exact name match — use : not ~ when you know the exact name;
      avoids retrieving all 1000+ datasources"
  patterns:
    appliesTo_filter: |
      # Find all datasources referencing a property in their AppliesTo expression
      elm DatasourceList -F 'appliesTo~purestorage.apitoken.pass' -s0 -f name,displayName,appliesTo
    appliesTo_active_check: |
      # Distinguish active from disabled AppliesTo expressions.
      #
      # Two common disable patterns in LM:
      #   1. /* purestorage.apitoken.pass */  — C-style comment wrapping
      #   2. false() && purestorage.apitoken.pass — short-circuit AND
      #
      # jq can strip /* */ comments with gsub but cannot reliably detect all
      # false() && (...) patterns. /* */ is widely used in this portal, so use
      # Python for reliable active-vs-inactive checking:
      elm DatasourceList -F 'appliesTo~purestorage.apitoken.pass' -s0 -f name,displayName,appliesTo | \
        python3 -c "
      import sys, re, json
      data = json.load(sys.stdin)
      for ds in data['DatasourceList']:
          expr = ds.get('appliesTo', '')
          stripped = re.sub(r'/\*.*?\*/', '', expr, flags=re.DOTALL)
          if re.search(r'purestorage\.apitoken\.pass', stripped):
              print(f\"{ds['name']}\t{ds['displayName']}\t{expr}\")
      " | column -t -s$'\t'
      #
      # Replace 'purestorage.apitoken.pass' with any property or expression fragment.
      # Note: false() && (...) still slips through — eyeball those rows.

AssociatedDeviceListByDataSourceId:
  path: /setting/datasources/{id}/devices
  required_params:
    id: integer # datasource ID
  note: "Returns all devices that have this datasource applied — useful for inverting
    (devices that do NOT have a datasource) by comparing against DeviceList"
  key_fields:
    id:          integer
    name:        string
    displayName: string
  pattern_devices_without_datasource: |
    # Step 1: find datasource ID
    elm DatasourceList -s0 -f id -F name:NTPv4

    # Step 2: get IDs of devices that DO have it
    ntp_ids=$(elm AssociatedDeviceListByDataSourceId --id <id> -f id | jq '[.AssociatedDeviceListByDataSourceId[].id]')

    # Step 3: get all target devices and exclude the ones that have it
    elm DeviceList -s0 -f id,displayName -F systemProperties.name:system.sysinfo,systemProperties.value~Linux | \
      jq -r --argjson ntp_ids "$ntp_ids" \
        '.DeviceList[] | select(.id as $id | $ntp_ids | contains([$id]) | not) | .displayName' | sort
  usage_matrix_tool: |
    tools/elm-datasource-matrix.py builds a device x datasource usage matrix as a
    GFM (GitHub Flavored Markdown) table: each row = a device (device ID + name),
    each datasource = a column, cell = tick (applied) or blank. For every
    datasource whose NAME matches a pattern. Pivots one
    AssociatedDeviceListByDataSourceId call per matching datasource, so cost is
    N_datasources calls, not N_devices.
      tools/elm-datasource-matrix.py NTP                  # sandbox, case-insensitive
      tools/elm-datasource-matrix.py -p prod NTP          # -p/--profile selects portal
      tools/elm-datasource-matrix.py -s NTP               # case-sensitive (ripgrep's flag)
      tools/elm-datasource-matrix.py -x '^NTP|NTP$'       # regex: NTP only at start/end
      tools/elm-datasource-matrix.py -x 'NTP|Ping'        # OR: one server call per branch, unioned
      tools/elm-datasource-matrix.py --csv NTP            # id,device,... 1/0 cells for spreadsheets
    Default match is case-INSENSITIVE: 'ntp'/'NTP'/'Ntp' all match NTPv4/Cisco_NTP.
    Pass -s/--case-sensitive (ripgrep's flag) to narrow it — then 'NTP' no longer
    matches substrings like AccessPoi[ntP]erformance or OverCurre[ntP]rotectors
    (these are real datasources that contain the letters n-t-p). Pass -x/--regex
    to treat the pattern as a Python regex (anchor with ^/$ for start/end-only);
    it derives a literal core (e.g. 'NTP' from '^NTP|NTP$') for the server-side
    name~ filter, then refines with the full regex client-side, so the full
    datasource list (can be thousands) is never downloaded. Top-level | ORs
    several patterns ('NTP|Ping') — LM ANDs repeated -F, so each branch becomes
    its own name~ call and the results are unioned. Size guard: aborts before
    the per-datasource calls if >--max-cols datasources match (default 20; each
    is one API call) and before rendering if >--max-rows devices would be rows
    (default 1000, LM's per-request -s0 row cap, beyond which the underlying
    device lists truncate anyway); 0 disables a limit. GFM cells are
    padded so the raw Markdown source lines up.
    REAL DEVICES ONLY (not configurable): rows are limited to deviceType 0/1.
    LM Services/Service Insight (deviceType 6), cloud (AWS/Azure), and k8s
    resources are excluded — implemented by fetching the non-device id set via
    `DeviceList -F deviceType!:0 -F deviceType!:1` (one call; the non-device set
    is smaller than the device set, so the 1000-row cap rarely bites) and
    dropping those ids. NOTE the filter operator for not-equals is `!:` (not `!`).
    "Used" = the live applied set (this endpoint), NOT the 24h-stale
    auto.activedatasources property — see DeviceDatasourceList.finding_active_datasources.
  gotchas:
    - "Caps at 1000 rows. If a datasource applies to more devices, check per device instead: elm DeviceDatasourceList --deviceId N -s0 -f id -F dataSourceName:NAME."
    - "Rows carry hasActiveInstance: devices APPLIED to (-C) and devices actually COLLECTING (hasActiveInstance true) can differ enormously (seen: 1205 vs 2). The instance array in each row is not a count of active instances."

# ---
# Users / Roles / Tokens
# ---
AdminList:
  path: /setting/admins
  unique_options:
    type:              "--type TEXT — silently ignored by LM API; field in response is userType with values 'Local', 'SAML', 'API only' (verified); use -F filter instead: -F userType:SAML"
    permission:        "--permission TEXT — no visible effect observed; values unverified"
    filterGroupString: "--filterGroupString TEXT — NOT a group filter: '11' returned users with 11 in the username, from groups 1 and 11 (verified 2026-10). Use -F adminGroupIds:N"
  key_fields:
    id:           integer
    username:     string
    userType:     string  # "Local", "SAML", "API only" (verified)
    twoFAEnabled: boolean
    adminGroupIds: array   # user group IDs; names via AdminGroupList. Filter: -F adminGroupIds:11 (verified 2026-10)
    roles:        array    # list of role objects with id, name, privileges
  gotchas:
    - "apiTokens.status: 2 = active, 1 = disabled, for both LMv1 and bearer tokens (apiTokens.type tells them apart). Filters natively on the nested field: -F apiTokens.status:2."

ApiTokenList:
  path: /setting/admins/apitokens
  unique_options:
    type:       "--type TEXT — values unverified; 'lmv1' and 'bearer' both return no results (verified); field may not exist in response"
    permission: "--permission TEXT — 'readonly' and 'readwrite' both return the same results (verified); does not appear to filter"
  key_fields:
    adminId:    integer # owning user ID
    adminName:  string  # owning username
    id:         integer # token ID
    accessId:   string
    status:     integer # 1=disabled, 2=active (verified via testing)
    note:       string
    roles:      array   # list of role names the token owner has
    createdOn:  integer # epoch seconds
    createdBy:  string
    lastUsedOn: integer # epoch seconds (0 = never used)
    lastAuthIp: string
  patterns:
    never_used:    "elm ApiTokenList -s0 -F lastUsedOn:0 -f id,adminName,note,createdOn,status   # tokens created but never used"
    active_tokens: "elm ApiTokenList -s0 -F status:2 -f id,adminName,note,lastUsedOn             # all active tokens"
  gotchas:
    - "status: 2 = active, 1 = disabled. lastUsedOn 0 = created but never used."

RoleList:
  path: /setting/roles
  unique_options:
    excludeAdmin: "--excludeAdmin / --dont-excludeAdmin — removes 1 built-in admin role from results (43 → 42 verified); default (no flag) includes it"
  key_fields:
    roleGroupId: integer  # role group ID; names via RoleGroupList. Filter: -F roleGroupId:18 (verified 2026-10)

AdminGroupList:
  path: /setting/admin/groups
  note: "Undocumented. User groups (the ID-to-name map for AdminList adminGroupIds). Singular 'admin'; /setting/admins/groups is 404 (verified 2026-10)."
  patterns:
    users_in_group: "elm AdminGroupList -F name:Admins -f id   # then: elm AdminList -F adminGroupIds:ID -s0 -f id,username"

RoleGroupList:
  path: /setting/role/groups
  note: "Undocumented. Role groups (the ID-to-name map for RoleList roleGroupId). Singular 'role'; /setting/roles/groups is 404 (verified 2026-10)."
  patterns:
    roles_in_group: "elm RoleGroupList -F 'name:Example Roles' -f id   # then: elm RoleList -F roleGroupId:ID -s0 -f id,name"
  gotchas:
    - "createdOn is epoch seconds except the root @default group, which returned 20190710233419 (a yyyymmddHHMMSS date) (verified 2026-10)."

AccessGroupList:
  path: /setting/accessgroup
  note: "Returns 400 Bad Request (verified). Endpoint may require additional params or may not be available on this account type."

AppliesToFunctionList:
  path: /setting/functions
  note: "Standard list; verified working. Returns AppliesTo function definitions."
  key_fields:
    id:         integer
    name:       string   # e.g. "isMisc", "isNetwork", "isStorage", "isLinux"
    appliesTo:  string   # the function body (AppliesTo expression)

AlertRuleList:
  path: /setting/alert/rules
  note: "Standard list; verified working."
  key_fields:
    id:                integer
    name:              string
    priority:          integer
    levelStr:          string   # e.g. "All"
    devices:           array
    deviceGroups:      array
    datasource:        string
    instance:          string
    datapoint:         string
    escalationInterval: integer
    escalatingChainId: integer
    escalatingChain:   object
    suppressAlertClear: boolean
    suppressAlertAckSdt: boolean
    resourceProperties: array
    sendAnomalySuppressedAlert: boolean

EscalationChainList:
  path: /setting/alert/chains
  note: "Standard list; verified working."
  key_fields:
    id:                integer
    name:              string
    description:       string
    enableThrottling:  boolean
    throttlingPeriod:  integer
    throttlingAlerts:  integer
    inAlerting:        boolean
    destinations:      array   # list of escalation stages
    ccDestinations:    array

ActionChainsList:
  path: /setting/action/chains
  note: "size/offset/filter are absent from the official LM swagger (same class
    as issue #47) and supplied via swagger.undocumented.json, so -s/-o/-F are
    exposed after make init. NOT yet live-verified that the API honours them —
    pending test; every sibling /setting/* endpoint in the same patch does."
  key_fields:
    id:     integer
    name:   string

ActionRulesList:
  path: /setting/action/rules
  note: "size/offset/filter absent from the official LM swagger (issue #47 class)
    and supplied via swagger.undocumented.json, same as ActionChainsList. -s/-o/-F
    are exposed after make init but not yet live-verified against the API."
  key_fields:
    id:     integer

RecipientGroupList:
  path: /setting/recipientgroups
  note: "Standard list; verified working. Name field is groupName not name."
  key_fields:
    id:          integer
    groupName:   string   # NOTE: field is groupName not name — -f name will return empty
    description: string
    recipients:  array
  gotchas:
    - "Name field is 'groupName', not 'name' — use -f id,groupName not -f id,name"

IntegrationList:
  path: /setting/integrations
  note: "Standard list. Very wide response. filter and size work; -f/--fields works
    reliably (elm applies client-side projection after the API call — see gotcha)."
  key_fields:
    id:     integer
    name:   string
    type:   string   # e.g. "http", "email", "servicenow", "msTeams", "slack-2", "connectwise-2"
  gotchas:
    - "The LM /setting/integrations endpoint only honours -f/--fields for GENERIC
      integration types (http, email) — for BRANDED types (servicenow, msTeams,
      slack-2, connectwise-2, ...) it ignores fields and returns the full ~100+
      field object. Projection is per-record, so one API response can mix trimmed
      and full records by type. elm works around this transparently: after receiving
      the response it trims every item to exactly the requested fields, so -f always
      produces consistent output regardless of integration type (upstream bug #52,
      fixed client-side in engine.py.j2). Confirmed type-correlated across three
      portals 2026-06-11."

NetscanList:
  path: /setting/netscans
  note: "Standard list; verified working."
  key_fields:
    id:          integer
    name:        string
    collectorId: integer

OpsNoteList:
  path: /setting/opsnotes
  note: "Standard list; verified working. id is a string not integer."
  key_fields:
    id:           string   # string ID (e.g. "JuyolHMaTxKxnrkNYl9o1Q"), not integer
    note:         string
    happenOnInSec: integer  # epoch seconds
    createdBy:    string
    scopes:       array    # list of {type, deviceId, deviceName, groupId, fullPath}
    tags:         array

UnmonitoredDeviceList:
  path: /device/unmonitoreddevices
  note: "Returns 403 Forbidden with read-only token (verified). May require admin token."

LogAlertGroupsList:
  path: /logpipelines
  note: "Standard list (-s/-o/-F/-f all work). Returns log alert pipeline definitions. id is a string."
  key_fields:
    id:          string   # string ID (e.g. "1", "2") — not integer
    name:        string
    query:       string   # LM log pipeline query expression
    description: string
  gotchas:
    - "id is a string, not integer — note when using with LogAlertGroupById (--pipelineId)"

LogQueryGroupList:
  path: /log/logquerygroups
  note: "Standard list (-s/-o/-F/-f all work). Returns log query group definitions."
  key_fields:
    id:           integer
    name:         string
    description:  string
    groupType:    string   # "PRIVATE" or "PUBLIC"
    ownerId:      integer  # 0 for system-owned
    parentId:     integer
    logQueries:   array    # list of log queries in this group (may be empty list in list view, null in ById)
    userPermission: string # "WRITE", "READ", etc.
  gotchas:
    - "logQueries is empty list in LogQueryGroupList but null in LogQueryGroupById (inconsistent)"

TrackedQueryGroupList:
  path: /trackedquerygroups
  note: "Standard list (-s/-o/-F/-f all work). Returns tracked query group definitions."
  key_fields:
    id:           integer
    name:         string   # e.g. "Ungrouped"
    description:  string
    ownerId:      integer
    parentId:     integer
    trackedQuery: object   # null if no tracked query associated

TopologySourceList:
  path: /setting/topologysources
  note: "Standard list (-s/-o/-F/-f all work). Returns topology source definitions."
  key_fields:
    id:               integer
    name:             string
    group:            string
    appliesTo:        string
    collectInterval:  integer
    collectionMethod: string
    checksum:         string

DiagnosticSourcesList:
  path: /setting/diagnosticsources
  note: "Standard list (-s/-o/-F/-f all work). Returns no data on this account (none configured)."

RemediationSourcesList:
  path: /setting/remediationsources
  note: "Standard list (-s/-o/-F/-f all work). Returns no data on this account (none configured)."

OIDList:
  path: /setting/oids
  note: "Standard list (-s/-o/-F/-f all work). Returns SNMP OID category definitions."
  key_fields:
    id:         integer
    oid:        string     # regex pattern e.g. "\\.1\\.3\\.6\\.1\\.4\\.1\\.8072"
    categories: string     # comma-separated category names e.g. "Netsnmp, snmp, snmpHR"
    checksum:   string
    lineageId:  string

# ---
# Audit / Logs
# ---
AuditLogList:
  path: /setting/accesslogs
  unique_options:
    format: "--format TEXT — LM API format param; DO NOT USE — causes JSONDecodeError when set to csv (API returns raw CSV that elm cannot parse); use elm's global --format flag instead"
  gotchas:
    - "username field contains the raw access_id string from the calling integration.
      If you see username: \"(update)\", that is literally the access_id someone configured
      — LM does not substitute or redact deleted tokens. Use the ip field and description
      to identify the source:
        elm AuditLogList -F 'username:(update)' -s0"
    - "NOT operators (!: and !~) are silently broken on this endpoint — elm constructs
      and sends the correct URL but the API returns results from an unexpected time window
      rather than applying the exclusion. Use positive operators (: and ~) and filter
      client-side with jq for exclusions:
        elm -f json AuditLogList -s0 | jq '.AuditLogList[] | select(.username != \"foo\")'"
    - "Newest first, at most 1000 per page. Without a filter you get the 1000 most recent;
      with a positive filter (~, :) up to 1000 matching records from the full history.
      -o pages back through older entries (verified: -o 5000 reached entries two weeks
      older than -o 0). To reach a date directly, filter on the epoch:
      -F happenedOn<:EPOCH or -F happenedOn>:EPOCH."
    - "-C gives no real total (prints a warning and >50). Count with -c -s0, which is
      only exact below 1000."
  key_fields:
    id:             integer
    username:       string
    ip:             string
    description:    string
    happenedOn:     integer # epoch seconds
    happenedOnLocal: string # human-readable local time string

# ---
# Device-scoped commands (require --deviceId or similar)
# ---
DeviceDatasourceList:
  path: /device/devices/{deviceId}/devicedatasources
  required_params:
    deviceId: integer # device ID
  key_fields:
    id:          integer # device-datasource ID (hdsId for sub-commands)
    dataSourceId: integer
    dataSourceName: string
    instanceNumber: integer
    alertStatus: string # "none", "warn", "error", "critical"
  finding_active_datasources: |
    Two ways to answer "which datasources are active on this device". They are
    NOT equivalent — pick based on what's available on the portal.

    1. STANDARD (always works, API-native, authoritative):
       Lists the device-datasources LM has actually matched/applied to the device.
         elm DeviceDatasourceList --deviceId <id> -s0 -f dataSourceName,instanceNumber
       - Always available on any portal; no setup required.
       - Authoritative — this is LM's own record of applied datasources.
       - Returns structured rows (one per applied datasource), so it's filterable
         (-F), sortable (-S), and countable (-C).

    2. NON-STANDARD (only if the 'DataSources_List' PropertySource is applied):
       Reads a single auto property that the PropertySource populates with a flat,
       comma-separated list of active datasource names.
         elm DeviceById --id <id> -f autoProperties \
           | jq -r '.DeviceById[0].autoProperties[]
                    | select(.name=="auto.activedatasources").value'
       - Property name is auto.activedatasources (NOTE: "active", not
         "activated" — easy to get wrong and get an empty result).
       - Only exists on devices where the 'DataSources_List' PropertySource is
         assigned and has run. Absent everywhere else — do not rely on it for
         cross-portal work without checking first.
       - Value is a single comma-separated string. Some names contain spaces
         (e.g. "CPU Cores-", "System Level IP Stats-") and some end in a trailing
         dash, so split on ',' ONLY, never on whitespace.
       - Convenience/reporting shortcut, not authoritative — it reflects the
         applied set as of the PropertySource's last run. PropertySources run on
         a 24-hour cycle, so this value can be up to ~24h stale: a datasource
         added/removed since the last run won't show until the next run. When you
         need the live applied set, use DeviceDatasourceList instead.

    Rule of thumb: use DeviceDatasourceList for anything programmatic or
    cross-portal; the auto.activedatasources property is a quick human-readable
    glance that only exists where the PropertySource is deployed (e.g. sandbox).

DeviceDatasourceInstanceList:
  path: /device/devices/{deviceId}/devicedatasources/{hdsId}/instances
  required_params:
    deviceId: integer
    hdsId:    integer # device-datasource ID (id from DeviceDatasourceList)
  key_fields:
    id:           integer
    name:         string
    displayName:  string
    alertStatus:  string
    stopMonitoring: boolean
    lastCollectedTime: integer # epoch seconds; unreliable for some collection types — do NOT use as proxy for "not collecting"
  gotchas:
    - "lastCollectedTime returns 0 for many datasources that ARE collecting and alerting (confirmed for SNMP batchscript and Groovy script datasources). Do not read lastCollectedTime == 0 as 'not collecting'; use alertStatus != 'none' instead."
    - "alertStatus format: [confirmationState]-[severity]-[anomalyState], e.g. none (no alert), unconfirmed-warn-none, warn-none (confirmed warning), error-none."

DeviceDatasourceInstanceGroupList:
  path: /device/devices/{deviceId}/devicedatasources/{deviceDsId}/instancegroups
  required_params:
    deviceId:   integer
  optional_params:
    deviceDsId: "--deviceDsId INTEGER — device-datasource ID; scopes to a specific datasource's instance groups"

DeviceEventsourceList:
  path: /device/devices/{deviceId}/deviceeventsources
  required_params:
    deviceId: integer
  note: "Standard list (-s/-o/-F/-f all work). Returns event sources applied to a device."

DevicePropertyList:
  path: /device/devices/{deviceId}/properties
  required_params:
    deviceId: integer
  key_fields:
    name:        string
    value:       string
    type:        string  # "system", "auto", "custom", "inherited"
    inheritList: array   # empty for non-inherited; list of sources for inherited properties
  note: "Returns all property types mixed together — filter by type if needed."

DevicePropertyByName:
  path: /device/devices/{deviceId}/properties/{name}
  required_params:
    deviceId: integer
    name:     string
  note: "Returns single property by exact name. 404 if property does not exist. Verified: --name system.hostname, --name system.ips."

DeviceInstanceList:
  path: /device/devices/{id}/instances
  required_params:
    id: integer # device ID — uses --id not --deviceId (verified)
  unique_options:
    start:        "--start INTEGER (int64) — netflow time range start; same caveat as DeviceList"
    end:          "--end INTEGER (int64) — netflow time range end"
    netflowFilter: "--netflowFilter TEXT — netflow filter; same caveat as DeviceList"
  key_fields:
    id:                  integer
    name:                string   # datasource-instance name e.g. "SNMP_Filesystem_Status-/"
    displayName:         string   # human label e.g. "/"
    dataSourceId:        integer
    deviceDataSourceId:  integer  # hdsId
    groupId:             integer
    deviceId:            integer
    alertStatus:         string
    stopMonitoring:      boolean
    lastCollectedTime:   integer  # epoch seconds; unreliable for some collection types
  gotchas:
    - "Uses --id not --deviceId (unlike most device-scoped commands)"
    - "dataSourceName not returned — join to DeviceDatasourceList on deviceDataSourceId if needed"
    - "-C gives instance count, NOT datapoint count — an instance (e.g. SNMP_Filesystem_Status-/var) has multiple datapoints (SpaceUsed, SpaceUsedPercent, ...)"
  patterns:
    count_instances: "elm DeviceInstanceList --id <deviceId> -C"
    count_datapoints: |
      # Total datapoints across all instances on a device (instances x datapoints per datasource).
      # Note: counts all configured datapoints — does not verify that all have active data.
      elm DeviceInstanceList --id <deviceId> -s0 \
        | jq -r '.DeviceInstanceList | group_by(.dataSourceId)[] | "\(.[0].dataSourceId) \(length)"' \
        | while read ds_id inst_count; do
            dp_count=$(elm DatasourceById --id $ds_id -f dataPoints \
              | jq '.DatasourceById[0].dataPoints | length')
            echo $((dp_count * inst_count))
          done \
        | awk '{sum += $1} END {print sum}'

DeviceConfigSourceConfigList:
  path: /device/devices/{deviceId}/devicedatasources/{hdsId}/instances/{instanceId}/config
  required_params:
    deviceId:   integer
    hdsId:      integer # device-datasource ID
    instanceId: integer # instance ID

# ---
# Device group-scoped commands
# ---
DeviceGroupPropertyList:
  path: /device/groups/{gid}/properties
  required_params:
    gid: integer # group ID — uses --gid not --deviceGrpId (verified)
  key_fields:
    name:   string
    value:  string  # masked as "********" for credential-style properties
    type:   string  # "custom", "system", etc.
  note: "Verified against gid=1. Credential-type properties have value masked."

DeviceGroupPropertyByName:
  path: /device/groups/{gid}/properties/{name}
  required_params:
    gid:  integer
    name: string
  note: "404 if property does not exist. Verified: --gid 1 --name topo.namespace returns value."

DeviceGroupSDTList:
  path: /device/groups/{id}/sdts
  required_params:
    id: integer # group ID
  note: "Returns no data for group 1 (root) and group 19 (Linux Servers) — groups may simply have no SDTs."

DeviceGroupClusterAlertConfList:
  path: /device/groups/{deviceGroupId}/clusterAlertConf
  required_params:
    deviceGroupId: integer
  note: "Returns no data for group 19 (Linux Servers) — may require a cluster-type group."

DeviceGroupDatasourceList:
  path: /device/groups/{deviceGroupId}/datasources
  required_params:
    deviceGroupId: integer
  unique_options:
    includeDisabledDataSourceWithoutInstance: "--includeDisabledDataSourceWithoutInstance / --dont-... — include datasources that are disabled and have no instances"

# ---
# Netflow commands (start/end/netflowFilter are meaningful here)
# ---
NetflowFlowList:
  path: /device/devices/{id}/flows
  required_params:
    id: integer # device ID
  unique_options:
    start:        "--start INTEGER (int64 epoch ms) — time range start for flow data"
    end:          "--end INTEGER (int64 epoch ms) — time range end for flow data"
    netflowFilter: "--netflowFilter TEXT — filter for flow data"
  note: "This is the endpoint where start/end/netflowFilter are actually meaningful. Returns 400 Bad Request if netflow not configured on the device."

NetflowEndpointList:
  path: /device/devices/{id}/endpoints
  required_params:
    id: integer # device ID
  unique_options:
    start:        "--start INTEGER (int64 epoch ms) — time range start"
    end:          "--end INTEGER (int64 epoch ms) — time range end"
    netflowFilter: "--netflowFilter TEXT — netflow filter"
    port:         "--port TEXT — filter by port; values unverified"
  note: "Returns 400 Bad Request if netflow not configured on the device."

NetflowPortList:
  path: /device/devices/{id}/ports
  required_params:
    id: integer # device ID
  unique_options:
    start:        "--start INTEGER (int64 epoch ms) — time range start"
    end:          "--end INTEGER (int64 epoch ms) — time range end"
    netflowFilter: "--netflowFilter TEXT — netflow filter"
    ip:           "--ip TEXT — filter by IP address; values unverified"

# ---
# Reports
# ---
WidgetList:
  path: /dashboard/widgets
  note: "Standard list; verified working. Returns all widgets across all dashboards."
  key_fields:
    id:          integer
    name:        string
    type:        string   # e.g. "pieChart", "html", "alert", "text", "gauge", "table", "graph"
    dashboardId: integer

RecommendationCategoriesList:
  path: /cost-optimization/recommendations/categories
  note: "Standard list; verified working. No id field — keyed by name."
  key_fields:
    name:        string   # e.g. "EC2_IDLE", "EC2_UNDERUTILIZED", "EBS_UNATTACHED"
    description: string   # human-readable description
  gotchas:
    - "No id field — cannot look up by id; use name as key"

RecommendationsList:
  path: /cost-optimization/recommendations
  note: "Standard list; returns no data on this account (no cost-optimization recommendations configured)."

# ---
# Dashboards
# ---
DashboardList:
  path: /dashboard/dashboards
  note: "Standard list; verified working."
  key_fields:
    id:      integer
    name:    string
    groupId: integer

DashboardGroupList:
  path: /dashboard/groups
  note: "Standard list; verified working."
  key_fields:
    id:       integer
    name:     string
    parentId: integer  # 0 for root group

# ---
# Reports
# ---
ReportGroupList:
  path: /report/groups
  note: "Standard list; verified working."
  key_fields:
    id:       integer
    name:     string

ReportList:
  path: /report/reports
  unique_options:
    showNextGenReports: "--showNextGenReports / --dont-showNextGenReports — no visible effect on count or results in testing (75 either way); may be account-specific or the account may not have Next Gen reports"

# ---
# Datasource types with LM API --format param (see _global gotcha)
# ---
ConfigSourceList:
  path: /setting/configsources
  unique_options:
    format: "--format TEXT — LM API format param; same gotcha as AuditLogList; use elm's global --format instead"

EventSourceList:
  path: /setting/eventsources
  unique_options:
    format: "--format TEXT — LM API format param; same gotcha as AuditLogList"

LogSourceList:
  path: /setting/logsources
  unique_options:
    format: "--format TEXT — LM API format param; same gotcha as AuditLogList"

JobMonitorList:
  path: /setting/batchjobs
  unique_options:
    format: "--format TEXT — LM API format param; same gotcha as AuditLogList"

PropertyRulesList:
  path: /setting/propertyrules
  unique_options:
    format: "--format TEXT — LM API format param; same gotcha as AuditLogList"

# ---
# Websites / Monitors
# ---
SiteMonitorCheckPointList:
  path: /website/smcheckpoints
  unique_options:
    type: "--type TEXT — tried 'public' and 'private', both returned same results as no flag; may require specific internal values; without flag returns legacy checkpoints (per LM help text); response has no 'type' field (verified: fields are id, name, geoInfo, description, displayPrio, isEnabledInRoot)"

# ---
# Datasource graphs
# ---
DataSourceOverviewGraphList:
  path: /setting/datasources/{dsId}/overviewgraphs
  required_params:
    dsId: integer # datasource ID
  note: "Returns no data for some datasources (e.g. dsId=514 NetSNMPCPUwithCores has none); verified data for dsId=28 (NetSNMPdiskIO-), id=4."

DataSourceOverviewGraphById:
  path: /setting/datasources/{dsId}/overviewgraphs/{id}
  required_params:
    dsId: integer  # datasource ID
    id:   integer  # graph ID from DataSourceOverviewGraphList
  note: "Verified working: --dsId 28 --id 4 returns graph definition with dataPoints, lines, etc."

# =============================================================
# Non-List commands
# =============================================================

# ---
# Commands requiring no arguments
# ---
_no_param_commands:
  note: "These work with no required params — just call them directly"
  commands:
    AllLogPartitions:      "/log/partitions — no -s option; returns log partitions with tenant/contract info (verified)"
    AwsAccountId:          "/aws/accountId — returns {accountId: string} (AWS account number) (verified)"
    AwsExternalId:         "/aws/externalId — 403 Forbidden with read-only token (verified)"
    ContractInfoByCompany: "/usage/contractInfo — returns a bare JSON array (no {total,items} envelope), like V4Metadata. The numeric-header CSV and -c/-C reporting 1 were an elm bug, fixed: -c now reports the real row count and CSV emits field names (verified 2026-09-11)"
    ExternalApiStats:      "/apiStats/externalApis — returns per-endpoint request stats: totalRequests, totNanoTime, maxNanoTime, api path, tags, summary (verified)"
    LogAlerts:             /logpipelines/processors
    MetricsSummary:        "/metrics/summary — richer than MetricsUsage: includes hostGroupsInfo (dynamic/static group counts + property counts), kubernetes breakdown, numOfSavedMaps; no -s option (verified)"
    MetricsUsage:          /metrics/usage
    PortalInfo:            "/setting/companySetting — same path as CompanySetting; returns portal/account info. Quick health snapshot: elm PortalInfo -f companyDisplayName,numberOfDevices,numberOfOpenAlerts,numberOfApiUsers,numberOfSessionUsers,numOfAWSDevices,numOfAzureDevices. Note: numberOfUsers and numberOfHosts do NOT exist — use numberOfApiUsers+numberOfSessionUsers and numberOfDevices (verified)"
    RetentionList:         "/setting/loganalytics/retentions — standard list (-s/-o/-F/-f all work); returns data retention options: {default, value (days), label, isDefault} (verified)"
    V4Metadata:            "/setting/logicmodules/metadata — LogicModule update status for the whole portal in one unpaginated call (~5k records): installationStatuses (IS_INSTALLED/CAN_UPGRADE/IS_CUSTOMIZED/CAN_INSTALL), originStatus, isInUse, originVersion, originPublishedAtMS. Returns a bare JSON array, not the {total,items} envelope — the [[...]] nesting and broken CSV headers were an elm bug, now fixed. No -s/-o/-F, and do NOT add them via swagger.undocumented.json: tested, the endpoint ignores all three (every variant returns all 5147 rows). -S is accepted but is a no-op. -f works (client-side projection). See tools/elm-module-updates.py (verified 2026-09-11)"
    IntegrationAuditLogs:  "/setting/integrations/auditlogs — unique: --format TEXT (LM API param — see gotcha)"

# ---
# Simple ById lookups (--id INTEGER only)
# Pattern: elm <Command> --id <ID>
# ---
_byid_pattern:
  note: "All take a single --id and return one record. IDs come from the corresponding *List command."
  commands:
    AccessGroupById:      /setting/accessgroup/{id}
    AdminById:            /setting/admins/{id}
    AlertRuleById:        /setting/alert/rules/{id}
    AppliesToFunctionById: /setting/functions/{id}       # verified working
    CollectorGroupById:   /setting/collector/groups/{id}
    CollectorById:        /setting/collector/collectors/{id}
    DashboardById:        /dashboard/dashboards/{id}         # unique: --template BOOL, --format TEXT (LM API)
    DashboardGroupById:   /dashboard/groups/{id}             # unique: --template BOOL, --format TEXT (LM API)
    DiagnosticSourcesById: /setting/diagnosticsources/{id}  # unique: --format TEXT (LM API)
    EscalationChainById:  /setting/alert/chains/{id}
    EventSourceById:      /setting/eventsources/{id}        # unique: --format TEXT (LM API)
    DeviceGroupById:      /device/groups/{id}
    ImmediateDeviceListByDeviceGroupId:  /device/groups/{id}/devices
    ImmediateWebsiteListByWebsiteGroupId: /website/groups/{id}/websites
    JobMonitorById:       /setting/batchjobs/{id}            # unique: --format TEXT (LM API)
    LogAlertGroupById:    /logpipelines/{pipelineId}         # param is --pipelineId; id is string (verified)
    LogAlertsById:        /logpipelines/processors/{processorId}  # param is --processorId
    LogQueryGroupById:    /log/logquerygroups/{id}
    NetscanById:          /setting/netscans/{id}
    OidById:              /setting/oids/{id}
    OpsNoteById:          /setting/opsnotes/{id}             # id is string, not integer
    PartitionById:        /log/partitions/{id}
    PropertyRulesById:    /setting/propertyrules/{id}        # unique: --format TEXT (LM API)
    RecipientGroupById:   /setting/recipientgroups/{id}
    RemediationSourcesById: /setting/remediationsources/{id} # unique: --format TEXT (LM API)
    ReportById:           /report/reports/{id}
    ReportGroupById:      /report/groups/{id}
    RoleById:             /setting/roles/{id}
    SdtById:              /sdt/sdts/{id}                     # id is string, not integer
    TopologySourceById:   /setting/topologysources/{id}      # unique: --format TEXT (LM API)
    TrackedQueryGroupById: /trackedquerygroups/{id}
    WebsiteById:          /website/websites/{id}             # unique: --format TEXT (LM API)
    WebsiteGroupById:     /website/groups/{id}
    WidgetById:           /dashboard/widgets/{id}
    WidgetListByDashboardId: /dashboard/dashboards/{id}/widgets

ImmediateDeviceListByDeviceGroupId:
  path: /device/groups/{id}/devices
  required_params:
    id: integer  # device group ID
  note: "Shallow, non-recursive fetch — returns only devices that are directly in the specified
    group. Devices in subgroups are NOT included. This is the key difference from DeviceList
    with a group filter: there is no server-side recursive traversal."
  key_fields:
    id:          integer
    name:        string   # hostname or IP
    displayName: string   # friendly name shown in UI
    customProperties:
      type: array
      items: {name: string, value: string}
      note: "All-or-nothing — cannot request a specific property by name; use -f displayName,customProperties to get all custom properties, then extract client-side"
  gotchas:
    - "Shallow only — calling this on a root group that has 0 direct devices returns 0 results,
      even if child subgroups contain thousands of devices. You need the group ID of the
      level where devices actually live, not the parent."
    - "customProperties is all-or-nothing — there is no server-side way to filter to a specific
      property by name; fetch all and extract with jq client-side"
  patterns:
    customer_group_audit: |
      # Example portal structure (implementation-specific — not universal across LM deployments):
      #   acme/customer/foo/          <- root group, 0 direct devices
      #       All/                    <- all canonical devices live here directly
      #       by Class/               <- same devices, organised by class (dynamic, duplicates)
      #       by Type/
      #       by Site/
      # The point: ImmediateDeviceListByDeviceGroupId on the group that holds devices directly
      # returns the canonical set. Calling it on a parent group that contains no direct devices
      # returns 0. Find the right level first (usually one with numOfDirectDevices > 0).

      # Step 1: find all */All subgroups under a customer path
      elm DeviceGroupList -s0 -F 'fullPath~acme/customer' -f id,fullPath | \
        jq -r '.DeviceGroupList[] | select(.fullPath | endswith("/All")) | "\(.id)\t\(.fullPath)"'

      # Step 2: for each All group, get devices with custom properties
      elm ImmediateDeviceListByDeviceGroupId --id <all_group_id> -s0 -f 'displayName,customProperties'

      # Step 3: extract displayName + a specific custom property client-side
      elm ImmediateDeviceListByDeviceGroupId --id <all_group_id> -s0 -f 'displayName,customProperties' | \
        jq -r '.ImmediateDeviceListByDeviceGroupId[] |
          [.displayName, (.customProperties[]? | select(.name == "customer.prop_name") | .value) // ""] |
          @tsv'

AlertList:
  path: /alert/alerts
  note: "Standard list; verified working. Alert IDs are strings (e.g. 'DS395142385')."
  key_fields:
    id:              string   # string ID, e.g. "DS395142385"
    type:            string   # "dataSourceAlert", "websiteAlert", etc.
    severity:        integer  # 2=warning, 3=error, 4=critical (higher = more severe; matches the swagger)
    startEpoch:      integer  # epoch seconds
    endEpoch:        integer  # epoch seconds; 0 if still active
    cleared:         boolean
    acked:           boolean
    ackedBy:         string
    ackedEpoch:      integer
    sdted:           boolean
    monitorObjectId: integer  # device/website ID
    monitorObjectName: string
    monitorObjectType: string
    instanceId:      integer
    instanceName:    string
    dataPointName:   string
    alertValue:      string
    threshold:       string
    rule:            string
    chainId:         integer
    ruleId:          integer
    resourceId:      integer
    suppressDesc:    string
    tenant:          string
  gotchas:
    - "severity is 2=warning, 3=error, 4=critical. An earlier version of this file had it reversed.
      Verified 2026-09-17 by comparing alertValue with the three-level threshold (warning error
      critical) on every open alert whose three thresholds differ: all matched, e.g. '>= 90 95 98'
      with value 93.6 is severity 2, '> 3600 7200 10800' with value 415947 is severity 4."
    - "Many fields are silently ignored as filters: the API returns every alert, as if there were no
      filter. Ignored: monitorObjectId, monitorObjectType, resourceId, instanceId, instanceDescription,
      resourceTemplateId, resourceTemplateType, dataPointId, chainId, ruleId, nextRecipient, sdtIds,
      alertValue, alertTriggerValue, threshold, thresholdMetadata. Applied: id, internalId, type,
      severity, startEpoch, lastUpdatedOnEpoch, monitorObjectName, resourceTemplateName, chain, tenant.
      Not elm's quoting: raw requests with the value quoted and unquoted, and with : and ~, all behave
      the same (2026-09-17). For one device use -F monitorObjectName:NAME or AlertListByDeviceId --id N;
      for the rest, fetch and filter client-side with jq."
    - "Without a cleared filter only active (uncleared) alerts are returned; -F cleared:true returns
      cleared ones instead (verified: same device, no filter 13, cleared:true 1000+)."
    - "-C does not return an exact total — LM API returns a negative sentinel; elm shows '>N' with a warning. Use '-c -s0' to count all active alerts (accurate when total ≤ 1000)"
    - "To find devices with alerting disabled, see DeviceList alertDisableStatus (a device's own setting is only part of it)."
  patterns:
    active_by_severity: "elm AlertList -s0 -F cleared:false -f id,severity | jq '[.AlertList[].severity] | group_by(.) | map({severity:.[0], count:length})'"
    count_all: "elm AlertList -c -s0 -F cleared:false   # real count when ≤ 1000"

# ---
# Alert commands
# ---
AlertById:
  path: /alert/alerts/{id}
  required_params:
    id: string  # alert ID is a string (e.g. "DS395389016"), not integer
  unique_options:
    needMessage:   "--needMessage — adds detailMessage and sessionId fields to response (verified)"
    customColumns: "--customColumns TEXT — comma-separated device property names; returned as a customColumns dict per alert e.g. {location: '...', description: '...'} (verified)"

AlertListByDeviceId:
  path: /device/devices/{id}/alerts
  required_params:
    id: integer  # device ID
  unique_options:
    needMessage:   "--needMessage — adds detailMessage and sessionId fields to response (verified)"
    customColumns: "--customColumns TEXT — comma-separated device property names; adds customColumns dict per alert (verified)"
    bound:         "--bound TEXT — purpose unverified; tested 'active' and 'cleared', both returned no results; may require specific API-level value"
    start:         "--start INTEGER (int64) — netflow time range; same caveat as DeviceList"
    end:           "--end INTEGER (int64) — same caveat as DeviceList"
    netflowFilter: "--netflowFilter TEXT — same caveat as DeviceList"

AlertListByDeviceGroupId:
  path: /device/groups/{id}/alerts
  required_params:
    id: integer  # device group ID
  unique_options:
    needMessage:   "--needMessage — adds detailMessage and sessionId fields (verified on DeviceId variant)"
    customColumns: "--customColumns TEXT — comma-separated device property names (verified on DeviceId variant)"

WebsiteAlertListByWebsiteId:
  path: /website/websites/{id}/alerts
  required_params:
    id: integer  # website ID
  unique_options:
    needMessage:   "--needMessage — adds detailMessage and sessionId fields"
    customColumns: "--customColumns TEXT — comma-separated property names"

# ---
# SDT / SDT History
# ---
AllSDTListByDeviceId:
  path: /device/devices/{id}/sdts
  required_params:
    id: integer
  unique_options:
    start:         "--start INTEGER (int64) — netflow time range; same caveat as DeviceList"
    end:           "--end INTEGER (int64)"
    netflowFilter: "--netflowFilter TEXT"

AllSDTListByWebsiteGroupId:
  path: /website/groups/{id}/sdts
  required_params:
    id: integer
  note: "Returns no data for group 1 (root) — no website group SDTs in this environment (verified)."

SDTHistoryByDeviceId:
  path: /device/devices/{id}/historysdts
  required_params:
    id: integer
  unique_options:
    start:         "--start INTEGER (int64) — netflow time range; same caveat as DeviceList"
    end:           "--end INTEGER (int64)"
    netflowFilter: "--netflowFilter TEXT"

SDTList:
  path: /sdt/sdts
  note: "Standard list; verified working. id is a string (e.g. 'H_161'), not integer."
  key_fields:
    id:          string   # format: H_<number> for historical, or other prefixes
    admin:       string   # username of SDT creator
    sdtType:     string   # "oneTime", "weekly", "monthly", "daily"
    type:        string   # what the SDT covers: ResourceSDT, ResourceGroupSDT,
                          # CollectorSDT, DeviceDataSourceInstanceSDT, WebsiteSDT (and more)
    startDateTime: integer  # epoch ms
    endDateTime:   integer  # epoch ms
    deviceDisplayName: string
    deviceId:    integer
    comment:     string
    isEffective: boolean  # true = SDT is currently active
  gotchas:
    - "id is a string (e.g. 'H_161'), not integer — cannot filter numerically"
    - "startDateTime and endDateTime are epoch milliseconds, not seconds"
    - "startDateTimeOnLocal and endDateTimeOnLocal are already formatted in the PORTAL timezone
       (the row's own `timezone` field names it): print them as they stand. Verified 2026-09-22
       against the epochs; on that date two models each moved a window 10 h by adding the reader's
       offset to the already-local string, and reported the wrong day"
    - "type is the SDT's scope, so rows are not devices: one ResourceGroupSDT silences every
       resource in that group, a CollectorSDT everything that collector monitors, and a
       DeviceDataSourceSDT or DeviceDataSourceInstanceSDT only part of one device. Asked how many
       devices are in downtime, group by type and say what each row covers; counting rows answers a
       different question (types from the swagger 2026-09-22; the test portal has only ResourceSDT)"
  patterns:
    currently_active: "elm SDTList -s0 -F isEffective:true -f type,deviceDisplayName,startDateTime,endDateTime,comment"
    long_sdts: "elm SDTList -s0 -f id,deviceDisplayName,endDateTime,comment | jq '.SDTList[] | select(.endDateTime > <far_future_epoch_ms>)'"

SDTHistoryByDeviceDataSourceId:
  path: /device/devices/{deviceId}/devicedatasources/{id}/historysdts
  required_params:
    deviceId: integer
    id:       integer  # device-datasource ID

SDTHistoryByDeviceGroupId:
  path: /device/groups/{id}/historysdts
  required_params:
    id: integer

SDTHistoryByWebsiteId:
  path: /website/websites/{id}/historysdts
  required_params:
    id: integer
  note: "Returns no data for website 2 (verified) — no SDT history for that website."

SDTHistoryByWebsiteGroupId:
  path: /website/groups/{id}/historysdts
  required_params:
    id: integer

# ---
# Audit / Logs
# ---
AuditLogById:
  path: /setting/accesslogs/{id}
  required_params:
    id: string  # audit log ID is a string (e.g. "B30tDTKcQkyREF5Rg2nTRg")
  note: "Verified working. Returns same fields as AuditLogList plus full description text."

# ---
# Collector commands
# ---
CollectorList:
  path: /setting/collector/collectors
  note: "Standard list command; no unique options. Key fields verified via live test."
  key_fields:
    id:                 integer
    description:        string   # human label; often mirrors hostname e.g. "DOMAIN\HOSTNAME"
    hostname:           string   # frequently "DOMAIN\HOSTNAME" or an FQDN, NOT a bare hostname
    platform:           string   # "linux" or "windows"
    arch:               string   # "Linux64", "Win64"
    build:              string   # e.g. "38001"; compare against CollectorVersionList for upgrade status
    status:             integer  # 1=registered/present (not a health indicator; isDown is authoritative)
    collectorSize:      string   # nano/small/medium/large/extra_large/double_extra_large/custom
    collectorGroupId:   integer
    collectorGroupName: string
    collectorDeviceId:  integer  # device ID of the collector host in LM; 0 = no device registered (no host-level metrics)
    numberOfHosts:      integer
    numberOfInstances:  integer  # only meaningful for auto-balance groups; 0 for non-auto-balance
    numberOfWebsites:   integer
    isDown:             boolean
    inSDT:              boolean
    upTime:             integer  # seconds since last agent start
    backupAgentId:      integer  # 0 = no failover; non-zero = ID of backup collector
    enableFailBack:     boolean  # true = devices auto-return to primary when it recovers
    calculatedThreshold: float   # current load vs autoBalanceInstanceCountThreshold; only non-zero for auto-balance groups
    acked:              boolean  # down alert has been acknowledged
    nextUpgradeInfo:    object   # {majorVersion, minorVersion, mandatory, stable, upgradeTime, upgradeTimeEpoch}
    userPermission:     string   # calling token's effective rights on this collector: "read" (read-only) or "write"/"write,debug" (manage). Only write/manage tokens can read the *Conf config-file fields
  gotchas:
    - "collectorDeviceId=0 means the collector has no device record in LM -- you cannot query its host metrics (CPU, memory) via DeviceDatasourceInstanceData etc."
    - "collectorDeviceId (when non-zero) is the device ID of the collector host in DeviceList -- use it to identify which devices in a group are themselves collector hosts so reachability/cross-collector checks can skip them (a collector is monitored from itself; collector name and device name need not match)"
    - "Collector Debug / remote debug operations (running a Groovy/!groovy command on a collector, e.g. via the Logic.Monitor PS module Invoke-LMCollectorDebugCommand) require a Manage-level token; a read-only token returns 'Access denied. Your API credentials do not have sufficient permissions.' (same class as CollectorStatusCheck returning 403 read-only)"
    - "hostname and description are usually the 'DOMAIN\\HOSTNAME' form (e.g. 'CORP\\NEWEDGE03') or an FQDN, not the bare host label -- matching a collector by an exact bare name fails. Match by numeric id, or substring/partial. The tools/lm-collector-reach.ps1 and lm-collector-debug.ps1 collector options resolve id -> exact hostname/description -> unambiguous substring for this reason."
    - "tools/lm-collector-reach.ps1 -Group <id|name> -WithCollector <id|name> tests a collector NOT yet in a group against that group's device list (pre-add vetting): submits the group's device list to the new collector via Collector Debug and reports device+protocol combos the new collector fails to reach but an in-group collector does. Requires the new collector to already be installed and connected to LM (just not in the group)."
    - "status is always 1 for all collectors including DOWN ones -- use isDown for real health status"
    - "build is a STRING holding the version as major*1000+minor, e.g. \"40001\" = 40.001 (matches CollectorVersionList majorVersion 40, minorVersion 1). There is no collectorVersion field. -F 'build<37000' and -S build work because builds are all 5 digits; use tonumber before comparing in jq."
    - "DOWN collectors (isDown: true) still report numberOfHosts. For \"in use\" or \"oldest version running\" questions filter -F isDown:false, and report down collectors and their host counts separately."
    - "Besides the *Conf fields, bearerToken, encodedConfigData, config, downloadUrl and copyUrl can carry secrets; a read-only token gets them empty (\"\"). Always pass -f so they are never fetched."
    - "numberOfInstances is 0 for non-auto-balance collectors -- it only tracks auto-balance load distribution"
    - "backupAgentId is a single value -- a collector can only have one backup; no chain failover"
    - "enableFailBack only takes effect if backupAgentId is set; harmless but meaningless when 0"
    - "The config-file fields (wrapperConf/collectorConf/sbproxyConf/watchdogConf/websiteConf) are only returned to a token whose userPermission includes 'write'; a read-only token gets the literal string '{}' for every one of them. Gate a collector-config backup on userPermission (per collector, so RBAC-scoped access still works) instead of fetching the blobs and inspecting for '{}'. userPermission is an LM-wide field (~22 object types) -- see elm-knowledge.md."
  patterns:
    all_collectors_health: |
      # Full health overview sorted by worst first (DOWN + most hosts = most urgent)
      elm CollectorList -s0 -f id,hostname,isDown,numberOfHosts,numberOfInstances,build,backupAgentId,enableFailBack,collectorGroupName,collectorSize,collectorDeviceId
    down_with_hosts: |
      # DOWN collectors that still have hosts attached (devices are unmonitored)
      elm CollectorList -s0 -F 'isDown:true' -f id,hostname,numberOfHosts,build,backupAgentId,collectorGroupName
    no_backup: |
      # Collectors with hosts but no backup (single point of failure)
      # No direct API filter for backupAgentId:0 -- pipe through jq
      elm CollectorList -s0 -f id,hostname,isDown,numberOfHosts,backupAgentId,collectorGroupName | \
        jq '.CollectorList[] | select(.backupAgentId == 0 and .numberOfHosts > 0)'
    old_builds: |
      # Collectors below a build threshold -- replace 38000 with your minimum acceptable build
      elm -f csv CollectorList -s0 -f id,hostname,isDown,numberOfHosts,build,collectorGroupName -F 'build<:38000' -S build

CollectorById:
  path: /setting/collector/collectors/{id}
  required_params:
    id: integer
  note: "Returns same fields as CollectorList plus predefinedConfig (size profiles) and full conf fields. predefinedConfig is large -- use -f to exclude if not needed."
  key_fields:
    id:                 integer
    description:        string
    build:              string
    collectorGroupId:   integer
    collectorGroupName: string
    collectorDeviceId:  integer
    hostname:           string
    platform:           string
    collectorSize:      string   # nano/small/medium/large/extra_large/double_extra_large/custom
    numberOfHosts:      integer
    numberOfInstances:  integer
    isDown:             boolean
    upTime:             integer
    backupAgentId:      integer
    enableFailBack:     boolean
    calculatedThreshold: float
    confVersion:        string   # Unix timestamp updated regularly by the collector (heartbeat/sync tick); does NOT indicate when config was last changed -- use userChangeOn for that
    userChangeOn:       integer  # Unix timestamp of last user-initiated change to this collector (portal config push, property edit, etc)
    collectorConf:      string   # JSON-encoded agent.conf overrides set via API; '{}' means no API-managed overrides
    wrapperConf:        string   # JSON-encoded wrapper.conf overrides set via API; '{}' means no API-managed overrides
    sbproxyConf:        string   # JSON-encoded sbproxy.conf overrides set via API
    watchdogConf:       string   # JSON-encoded watchdog.conf overrides set via API
    websiteConf:        string   # JSON-encoded website.conf overrides set via API
    agentConfFields:    object   # structured parsed version of agent.conf overrides; null if none set via API
    predefinedConfig:   object   # size profiles (nano..double_extra_large) with requiredMemory and agent.conf/wrapper.conf/sbproxy.conf keys; no 'custom' entry
  gotchas:
    - "All conf fields (collectorConf, wrapperConf, sbproxyConf, watchdogConf, websiteConf, agentConfFields, predefinedConfig, confVersion, config, encodedConfigData) are marked readOnly in the swagger schema. Collector configuration is READ-ONLY via the REST API -- you cannot push config changes through the API."
    - "The LM portal 'Collector Configuration' UI uses a separate internal channel to push config to the collector agent. This is NOT the public REST API."
    - "collectorConf and related conf fields return '{}' (string-encoded empty JSON) even after portal UI changes -- they do not reflect what was pushed."
    - "confVersion updates when the portal pushes a config, even though collectorConf stays '{}'. confVersion being non-zero does not mean the config is readable via API."
    - "collectorSize 'custom' means the collector was tuned outside the standard profiles. predefinedConfig has no 'custom' key."
    - "To read or change the actual running config, you need direct host access (SSH/RDP) or the portal UI. There is no API path for either operation."

CollectorGroupById:
  path: /setting/collector/groups/{id}
  required_params:
    id: integer
  note: "collector group for a specific host; get id from CollectorList.collectorGroupId"
  key_fields:
    id:                integer
    name:              string
    description:       string
    numOfCollectors:   integer
    numOfHosts:        integer
    numOfInstances:    integer
    autoBalance:       boolean
    platform:          string

CollectorGroupList:
  path: /setting/collector/groups
  note: "Standard list; no unique options. Verified via live test."
  auto_balance_explained: |
    When autoBalance=true, LM automatically redistributes devices between collectors in the
    group as instance counts change. Key mechanics:

    THE DEVICE FIELD THAT MATTERS:
    - DeviceList.autoBalancedCollectorGroupId controls whether a specific device participates:
        0         = device is pinned to its current preferredCollectorId; LM will not move it
        <groupId> = device is in the auto-balance pool; LM may reassign preferredCollectorId
    - autoBalance=true on the group sets the capability; each device opts in/out individually
      via autoBalancedCollectorGroupId. Within one group, some devices can be pinned (0) and
      others pooled (groupId) -- both coexist (verified).
    - Collector host devices should be pinned (autoBalancedCollectorGroupId=0). Monitoring a
      collector from a different collector is pointless: if the collector software is down,
      the device metrics are lost regardless of which collector nominally owns it. Pinning
      keeps the collector device on its own collector at all times.

    GROUP FIELDS:
    - autoBalanceInstanceCountThreshold: max instances per collector before rebalancing triggers
    - autoBalanceStrategy: "none" = LM default; other values undocumented
    - propertyForBalancing: "" = balance by instance count; non-empty = balance by device
      property value (pins devices with the same property value to the same collector)
    - calThreshold mirrors autoBalanceInstanceCountThreshold (same value, different field name)
    - numOfInstances (CollectorGroupById only, not list): total instances across all collectors
      in the group; if > numOfCollectors * threshold the group is over capacity and auto-balance
      cannot fully resolve load -- the group needs more collectors

    SINGLE-COLLECTOR AUTO-BALANCE GROUPS:
    - Valid and intentional. Setting autoBalance=true on a group with one collector means the
      group is pre-configured and ready to distribute load the moment a second collector is
      added -- no group reconfiguration needed at that point.

    LIMITS:
    - If total group load exceeds numOfCollectors * threshold, auto-balance cannot fix it (verified:
      a group with 26k instances across 2 collectors with 10k threshold stays overloaded)
    - In that case the group needs more collectors, not tuning
    - CollectorList.calculatedThreshold: per-collector load score; only non-zero for auto-balance groups
    - CollectorList.numberOfInstances: per-collector instance count; 0 for non-auto-balance collectors
  key_fields:
    id:                integer
    name:              string
    description:       string
    platform:          string   # "linux"/"windows" when autoBalance is on; "n/a" when off (verified)
    numOfCollectors:   integer
    numOfHosts:        integer  # always 0 in list -- CollectorGroupById returns correct count
    numOfInstances:    integer  # always 0 in list -- CollectorGroupById returns correct count (when autoBalance on); 0 in ById when autoBalance off
    autoBalance:       boolean
    autoBalanceStrategy: string  # "none" = LM default; other values undocumented
    autoBalanceInstanceCountThreshold: integer  # 0 when autoBalance is off; max instances before rebalancing triggers
    calThreshold:      integer  # mirrors autoBalanceInstanceCountThreshold (same value)
    propertyForBalancing: string  # "" = balance by instance count; device property name to balance by property value
    mismatchVersion:   boolean  # true if collectors in the group are on different builds
    customProperties:  array
    highestPriorityCollectorStatus: object  # {acked, inSDT, isDown, status} -- status value unreliable in list (verified: 1 in list vs 0 in ById for same group)
    userPermission:    string
  gotchas:
    - "numOfHosts and numOfInstances are always 0 in list response -- use CollectorGroupById for accurate counts (verified with autoBalance both on and off: list always 0, ById shows real values when autoBalance is on)"
    - "numOfInstances in ById is only populated when autoBalance is on -- 0 when off (it tracks load-balanced instance distribution)"
    - "platform shows 'n/a' when autoBalance is off, actual platform string when on"
    - "highestPriorityCollectorStatus.status is unreliable in list response -- consistently disagrees with ById for same group (1 vs 0 for id=64)"
    - "a group with autoBalance=true and numOfCollectors=1 cannot rebalance -- needs 2+ collectors to be useful"
  patterns:
    auto_balance_groups: |
      # List all auto-balance groups with threshold and collector count
      elm CollectorGroupList -s0 -F 'autoBalance:true' -f id,name,autoBalanceInstanceCountThreshold,numOfCollectors,platform,propertyForBalancing
    multi_collector_not_balanced: |
      # Groups with 2+ collectors but auto-balance off (missing rebalancing)
      elm -f txt CollectorGroupList -s0 -F 'autoBalance:false,numOfCollectors>:2' -f id,name,autoBalance,numOfCollectors
    version_mismatch_groups: |
      # Groups where collectors are on different builds
      elm CollectorGroupList -s0 -F 'mismatchVersion:true' -f id,name,mismatchVersion,numOfCollectors

CollectorEvents:
  path: /setting/collector/collectors/{collectorId}/events
  required_params:
    collectorId: integer
  note: "Returns recent collector lifecycle events."
  key_fields:
    id:                  string   # string ID not integer (e.g. "iMTtrKBDShiGEUeszXRfhg")
    collectorId:         integer
    collectorDescription: string
    collectorBuild:      string
    collectorPlatform:   string
    eCode:               integer  # 4=restart, 8=info/lifecycle event
    eventMsg:            string   # e.g. "Agent successfully restarted.", "Credential is rotated. Agent will restart."
    reportedOn:          integer  # epoch seconds
    reportDateOnLocal:   string
  gotchas:
    - "id is a string, not an integer — can't filter numerically"
    - "eCode 4 = agent restart; eCode 8 = sbproxy/health/lifecycle info"

CollectorStatusCheck:
  path: /setting/collector/collectors/{collectorId}/services/getStatusCheck
  required_params:
    collectorId: integer
  note: "Returns 403 Forbidden with read-only API token. May require admin token."

CollectorAgentLogLevels:
  path: /setting/collector/collectors/{id}/agentloglevels
  required_params:
    id: integer
  note: "Returns list of {component, logLevel} for all log components."
  key_fields:
    component: string  # e.g. "collector.snmp", "eventcollector.syslog"
    logLevel:  string  # "info" or "debug"
  gotchas:
    - "~50 components returned; all 'info' by default except eventcollector.syslog which may be 'debug'"
    - "Use this to discover valid component names for CollectorAgentLogLevelByComponent"

CollectorAgentLogLevelByComponent:
  path: /setting/collector/collectors/{id}/agentloglevels/{component}
  required_params:
    id:        integer
    component: string
  note: "Returns {component, logLevel} for a single component. collector.snmp is a valid example component name."
  valid_component_values: |
    default, alerter, autoprops, controller, discover, monitor, reporter, sbproxy,
    scanner, watchdog, collector.agent, collector.cim, collector.datapump, collector.dns,
    collector.esx, collector.externalwebpage, collector.ipmi, collector.jdbc, collector.jmx,
    collector.memcached, collector.netapp, collector.openmetrics, collector.perfmon,
    collector.ping, collector.script, collector.snmp, collector.s3, collector.tcp,
    collector.udp, collector.webpage, collector.wmi, collector.xen, collector.batchscript,
    collector.publisher, eventcollector.httpevent, eventcollector.logfile,
    eventcollector.snmptrap, eventcollector.syslog, eventcollector.wineventlog,
    eventcollector.scriptevent, logsource.kubernetes, logsource.collectoringestapi,
    webservice, scriptproperty, propsdetection, collector.syntheticsselenium,
    remotesession, netflowSources, netflowAcceptor, netflowRequestProcessor

CollectorVersionList:
  path: /setting/collector/collectors/versions
  note: "Lists available collector versions. No id field — versions are identified by majorVersion+minorVersion. Verified field list via live test."
  key_fields:
    majorVersion:   integer
    minorVersion:   integer
    has32bitLinux:  boolean
    has32bitWindows: boolean
    mandatory:      boolean
    releaseEpoch:   integer  # epoch seconds; 0 if not set
    stable:         boolean
    patch:          boolean  # null unless a patch release
    feature:        boolean  # null unless a feature/test track entry
  gotchas:
    - "No 'id' field — cannot filter by id. Filter by majorVersion or minorVersion."
    - "No isGD/isEA fields in actual response despite swagger suggestions"
    - "Lists versions offered for upgrade, not the ones installed (installed versions are CollectorList build)."
    - "Entries with feature: true (seen: majorVersion 10000) are not real releases; ignore them when finding the latest version. releaseEpoch is 0 for versions not yet generally released. The latest release is the highest stable: true entry."

CollectorInstaller:
  path: /setting/collector/collectors/{collectorId}/installers/{osAndArch}
  required_params:
    collectorId: integer
    osAndArch:   string  # e.g. "Linux64", "Win64" — values unverified
  unique_options:
    collectorVersion: "--collectorVersion INTEGER — specific version to download; omit for latest"
    token:            "--token TEXT — purpose unverified"
    monitorOthers:    "--monitorOthers BOOL — purpose unverified"
    collectorSize:    "--collectorSize TEXT — e.g. small, medium, large"
    useEA:            "--useEA BOOL — use Early Access build"
  note: "Downloads binary installer; untested (would write binary to stdout)."

DebugCommandResult:
  path: /debug/{id}
  required_params:
    id: string  # session ID from a debug command
  unique_options:
    collectorId: "--collectorId INTEGER — collector to query for the debug result"
  note: "Requires a debug session ID obtained from a prior debug command invocation; untested."

# ---
# Device ById and scoped commands
# ---
DeviceById:
  path: /device/devices/{id}
  required_params:
    id: integer
  unique_options:
    needStcGrpAndSortedCP: "--needStcGrpAndSortedCP — no visible field difference observed in testing; may only apply to certain device types"
    start:         "--start INTEGER (int64) — same caveat as DeviceList"
    end:           "--end INTEGER (int64)"
    netflowFilter: "--netflowFilter TEXT"

DeviceDatasourceById:
  path: /device/devices/{deviceId}/devicedatasources/{id}
  required_params:
    deviceId: integer
    id:       integer  # device-datasource ID

DeviceDatasourceInstanceById:
  path: /device/devices/{deviceId}/devicedatasources/{hdsId}/instances/{id}
  required_params:
    deviceId: integer
    hdsId:    integer  # device-datasource ID
    id:       integer  # instance ID

DeviceDatasourceInstanceGroupById:
  path: /device/devices/{deviceId}/devicedatasources/{deviceDsId}/groups/{id}
  required_params:
    deviceId:  integer
    deviceDsId: integer
    id:        integer

DeviceDatasourceInstanceAlertSettingById:
  path: /device/devices/{deviceId}/devicedatasources/{hdsId}/instances/{instanceId}/alertsettings/{id}
  required_params:
    deviceId:   integer
    hdsId:      integer
    instanceId: integer
    id:         integer

DeviceDatasourceInstanceAlertSettingListOfDSI:
  path: /device/devices/{deviceId}/devicedatasources/{hdsId}/instances/{instanceId}/alertsettings
  required_params:
    deviceId:   integer
    hdsId:      integer
    instanceId: integer

DeviceDatasourceInstanceAlertSettingListOfDevice:
  path: /device/devices/{deviceId}/alertsettings
  required_params:
    deviceId: integer
  unique_options:
    start:         "--start INTEGER (int64) — same caveat as DeviceList"
    end:           "--end INTEGER (int64)"
    netflowFilter: "--netflowFilter TEXT"
  note: "Returns all datapoint alert settings for all datasources on a device."

DeviceDatasourceInstanceSDTHistory:
  path: /device/devices/{deviceId}/devicedatasources/{hdsId}/instances/{id}/historysdts
  required_params:
    deviceId: integer
    hdsId:    integer
    id:       integer  # instance ID

DeviceGroupClusterAlertConfById:
  path: /device/groups/{deviceGroupId}/clusterAlertConf/{id}
  required_params:
    deviceGroupId: integer
    id:            integer

DeviceGroupDatasourceAlertSetting:
  path: /device/groups/{deviceGroupId}/datasources/{dsId}/alertsettings
  required_params:
    deviceGroupId: integer
    dsId:          integer  # dataSourceId (same as id in DeviceGroupDatasourceById)
  note: "No -s/--size option (verified). dsId = dataSourceId from DeviceGroupDatasourceList."

DeviceGroupDatasourceById:
  path: /device/groups/{deviceGroupId}/datasources/{id}
  required_params:
    deviceGroupId: integer
    id:            integer  # use dataSourceId from DeviceGroupDatasourceList (no separate id field there)
  note: "Verified: --deviceGroupId 19 --id 21883980 works where 21883980 is dataSourceId from DeviceGroupDatasourceList."

DeviceConfigSourceConfigById:
  path: /device/devices/{deviceId}/devicedatasources/{hdsId}/instances/{instanceId}/config/{id}
  required_params:
    deviceId:   integer
    hdsId:      integer
    instanceId: integer
    id:         string  # config ID is a string
  unique_options:
    format:     "--format TEXT — LM API format param; same gotcha as AuditLogList"
    startEpoch: "--startEpoch INTEGER (int64) — purpose unverified; possibly return config as of this timestamp"

# ---
# Time-series data endpoints
# Pattern: require device/datasource/instance IDs + optional time range
# ---
_data_endpoint_options:
  note: "Shared options across data/graph endpoints"
  start:      "--start INTEGER (int64 epoch seconds) — time range start; defaults to recent window"
  end:        "--end INTEGER (int64 epoch seconds) — time range end"
  period:     "--period FLOAT — time window in hours; e.g. 1.0 returns ~30 points, 24.0 returns ~500 points (verified on DeviceDatasourceInstanceData)"
  datapoints: "--datapoints TEXT — comma-separated datapoint names; filters response to only those datapoints (verified)"
  aggregate:  |
    --aggregate TEXT — verified valid values (on DeviceDatasourceDataById):
      none     = full time series (~30 points, same as omitting the flag)
      first    = single oldest point in the window
      last     = single most recent point
      average  = single mean of all points in the window
      sum      = single sum of all points in the window
    Invalid (API error or empty): avg, mean, median, count, min, max
  format:     "--format TEXT — LM API format param; same gotcha as AuditLogList"
  time_note:  "time values in responses are epoch MILLISECONDS, not seconds — divide by 1000 for epoch seconds (verified)"

DeviceDatasourceDataById:
  path: /device/devices/{deviceId}/devicedatasources/{id}/data
  required_params:
    deviceId: integer
    id:       integer  # device-datasource ID (hdsId from DeviceDatasourceList)
  unique_options: "start, end, period, datapoints, format, aggregate — see _data_endpoint_options"
  note: "Returns data for ALL instances of a datasource (unlike DeviceDatasourceInstanceData which is one instance). Instances are keyed by name under 'instances' dict."
  response_structure:
    dataSourceName: string
    dataPoints:     "list of datapoint name strings"
    instances:      "dict keyed by instance name (e.g. 'DS-enp3s0f0 [ID:2]') → {values: [[...]], time: [epoch_ms]}"
    nextPageParams: "pagination cursor"

DeviceDatasourceInstanceData:
  path: /device/devices/{deviceId}/devicedatasources/{hdsId}/instances/{id}/data
  required_params:
    deviceId: integer
    hdsId:    integer
    id:       integer  # instance ID
  unique_options: "start, end, period, datapoints, format — see _data_endpoint_options"
  response_structure:
    dataSourceName: string
    dataPoints:     "list of datapoint name strings (column headers)"
    values:         "list of arrays — each array is one time interval, values in same order as dataPoints; 'No Data' string used for missing values"
    time:           "list of epoch MILLISECONDS (not seconds) — newest first"
    nextPageParams: "pagination cursor; empty string when no more pages"
  notes:
    - "--datapoints InMbps,OutMbps filters to just those datapoints — values array shrinks to match (verified)"
    - "--period 1.0 returns ~30 points (1 hour window); --period 24.0 returns ~500 points (24 hours) — period is hours (verified)"
    - "time values are epoch ms — divide by 1000 for epoch seconds"

DeviceDatasourceInstanceGraphData:
  path: /device/devices/{deviceId}/devicedatasources/{hdsId}/instances/{id}/graphs/{graphId}/data
  required_params:
    deviceId: integer
    hdsId:    integer
    id:       integer  # instance ID
    graphId:  integer  # use 1 to get first graph; get graph IDs via DataSourceOverviewGraphList --dsId
  unique_options: "start, end, format — see _data_endpoint_options"
  note: "Returns rendered graph data (not raw values). graphId=1 returns the first graph for the instance."
  response_structure:
    type, id, name, title: "graph metadata"
    lines:       "array of line objects; each has legend, color, data (array of values or 'No Data'), min, max, avg, std"
    timestamps:  "array of epoch ms (24h window by default, ~700 points)"
    startTime, endTime: "epoch ms — window bounds"
    timeZone, timeZoneId: "e.g. AEDT / Australia/Sydney"
    exportFileName: "suggested filename if exporting"
    scopes:      "array of {type, id} — devices this graph covers"

DeviceDatasourceInstanceGroupOverviewGraphData:
  path: /device/devices/{deviceId}/devicedatasources/{deviceDsId}/groups/{dsigId}/graphs/{ographId}/data
  required_params:
    deviceId:  integer
    deviceDsId: integer
    dsigId:    integer
    ographId:  integer
  unique_options: "start, end, format — see _data_endpoint_options"

DeviceInstanceGraphDataOnlyByInstanceId:
  path: /device/devicedatasourceinstances/{instanceId}/graphs/{graphId}/data
  required_params:
    instanceId: integer
    graphId:    integer
  note: "Shortcut — doesn't require deviceId/hdsId, just the instance ID directly. Verified: --instanceId 423232578 --graphId 1 returns full graph data."
  unique_options: "start, end, format — see _data_endpoint_options"

WebsiteCheckpointDataById:
  path: /website/websites/{srvId}/checkpoints/{checkId}/data
  required_params:
    srvId:   integer  # website ID
    checkId: integer  # checkpoint ID; get from WebsiteById response (.checkpoints[].id)
  unique_options: "start, end, period, datapoints, format, aggregate — see _data_endpoint_options"
  note: "Verified working: --srvId 2 --checkId 354175036. Checkpoint IDs are in WebsiteById response (.checkpoints[].id), not from SiteMonitorCheckPointList. dataPoints includes overallStatus, totalSmCount, failSmCount, sslDaysUntilExpiration, etc. time is epoch ms."

WebsiteDataByGraphName:
  path: /website/websites/{id}/graphs/{graphName}/data
  required_params:
    id:        integer
    graphName: string  # graph name; tested 'response' → 400; valid values unknown
  unique_options: "start, end, format — see _data_endpoint_options"
  note: "Returns 400 Bad Request for graphName='response' on website 2 — valid graphName values are not documented and may be website-type-specific."

WebsiteGraphData:
  path: /website/websites/{websiteId}/checkpoints/{checkpointId}/graphs/{graphName}/data
  required_params:
    websiteId:    integer
    checkpointId: integer
    graphName:    string  # tested 'response', 'availability' → both 400; valid values unknown
  unique_options: "start, end, format — see _data_endpoint_options"
  note: "Returns 400 Bad Request for all graphName values tested — valid values unknown."

WidgetDataById:
  path: /dashboard/widgets/{id}/data
  required_params:
    id: integer
  unique_options: "start, end, format — see _data_endpoint_options"
  note: "Verified working — returns widget-specific data structure (varies by widget type)."

TopTalkersGraph:
  path: /device/devices/{id}/topTalkersGraph
  required_params:
    id: integer  # device ID
  unique_options:
    start:         "--start INTEGER (int64) — time range start (netflow)"
    end:           "--end INTEGER (int64) — time range end (netflow)"
    netflowFilter: "--netflowFilter TEXT — netflow filter"
    format:        "--format TEXT — LM API format param; same gotcha as AuditLogList"
    keyword:       "--keyword TEXT — purpose unverified; possibly filter for top talker search"
  note: "Netflow-specific — requires netflow to be enabled and configured on the device"

# ---
# Delta devices (incremental sync)
# ---
DeltaIdWithDevices:
  path: /device/devices/delta
  note: "No -s/--size option (verified). Returns full device snapshot. The deltaId is in the response root (not per-item) — elm surfaces only the items array, so deltaId is not accessible via elm. Use direct API call to retrieve it."
  unique_options:
    deltaId: "--deltaId TEXT — if provided, returns changes since that deltaId; if omitted, returns full snapshot with new deltaId"
  gotchas:
    - "No -s option — returns all devices; can be large"
    - "deltaId is in response root, not per-item — not accessible via elm; need direct API call"
    - "API returns 400 'number of active delta requests have already reached the max allowed limit' if concurrent delta sessions are open — one session at a time"

DeltaDevices:
  path: /device/devices/delta/{deltaId}
  required_params:
    deltaId: string  # from a previous DeltaIdWithDevices call
  note: "Untested — requires a valid deltaId from DeltaIdWithDevices, which elm doesn't surface. Use direct API access to get the deltaId first."

# ---
# Config source / update history
# ---
ConfigSourceById:
  path: /setting/configsources/{id}
  required_params:
    id: integer
  unique_options:
    format: "--format TEXT — LM API format param; same gotcha as AuditLogList"

UpdateReasonListByDataSourceId:
  path: /setting/datasources/{id}/updatereasons
  required_params:
    id: integer  # datasource ID

UpdateReasonListByConfigSourceId:
  path: /setting/configsources/{id}/updatereasons
  required_params:
    id: integer  # configsource ID
  note: "No -s/--size option (verified). Verified working with id=50968587."

DatasourceById:
  path: /setting/datasources/{id}
  required_params:
    id: integer
  unique_options:
    format: "--format TEXT — LM API format param; same gotcha as AuditLogList"
  gotchas:
    - "Returns an array even for one result: jq '.DatasourceById[0].collectMethod'."

# ---
# Log / log pipeline
# ---
LogQueriesByGroupId:
  path: /log/logquerygroups/{id}/logqueries
  required_params:
    id: integer  # log query group ID
  note: "No -s/--size option (verified). Returns no data for group 1 (Ungrouped) — no queries in that group."

LogQueryGroupListByGroupType:
  path: /log/logquerygroups/grouptype/{groupType}
  required_params:
    groupType: string  # case-insensitive; verified values: PRIVATE (returns results), PUBLIC (returns 0 in test env); likely mirrors LM log query group types
  unique_options:
    allGroups: "--allGroups BOOL — purpose unverified; possibly include all groups regardless of type"

# ---
# Reports
# ---
ReportUsingTaskId:
  path: /report/reports/{id}/tasks/{taskId}
  required_params:
    id:     integer  # report ID
    taskId: string   # task ID is a string

# ---
# API tokens scoped to a user
# ---
ApiTokenListByAdminId:
  path: /setting/admins/{adminId}/apitokens
  required_params:
    adminId: integer
  unique_options:
    type:       "--type TEXT — token type filter; values unverified"
    permission: "--permission TEXT — permission level filter; values unverified"

# ---
# Website properties / SDTs (already covered above for SDTs)
# ---
WebsitePropertyListByWebsiteId:
  path: /website/websites/{id}/properties
  required_params:
    id: integer

WebsiteSDTListByWebsiteId:
  path: /website/websites/{id}/sdts
  required_params:
    id: integer

# ---
# Misc
# ---
LogSourceById:
  path: /setting/logsources/{id}
  required_params:
    id: string  # log source ID is a string
  unique_options:
    format: "--format TEXT — LM API format param; same gotcha as AuditLogList"

RecommendationById:
  path: /cost-optimization/recommendations/{id}
  required_params:
    id: string  # recommendation ID is a string

PortalInfo:
  path: /setting/companySetting
  note: "Snapshot of the account; verified field names (numberOfUsers and numberOfHosts do NOT exist)."
  gotchas:
    - "The contacts array holds names, email addresses and phone numbers of portal contacts. Do not share PortalInfo output or commit it. MetricsSummary gives cloud and Kubernetes device breakdowns without the contacts."
  key_fields:
    companyDisplayName:          string   # the portal's display name
    numberOfDevices:             integer  # total monitored devices
    numberOfOpenAlerts:          integer  # active alerts
    numberOfApiUsers:            integer  # users with API tokens
    numberOfSessionUsers:        integer  # interactive (web/SSO) users
    numOfAWSDevices:             integer
    numOfAzureDevices:           integer
    numOfGcpDevices:             integer
    numberOfDatasourceInstances: integer  # instances being collected
    numberOfInstancesPerDS:      object   # {datasource name: instances}, every datasource with instances; ranks datasources by use in one call (verified 2026-10-11, 595 entries)
    numberOfDashboards:          integer
    numberOfWidgets:             integer
    hostGroupsInfo:              object   # dynamic vs static groups and their property counts
    alertTotalIncludeInAck:      boolean  # whether acknowledged alerts count toward totals
    alertTotalIncludeInSdt:      boolean  # whether SDT'd alerts count toward totals
  patterns:
    overview: "elm PortalInfo -f companyDisplayName,numberOfDevices,numberOfOpenAlerts,numberOfApiUsers,numberOfSessionUsers,numOfAWSDevices,numOfAzureDevices"

V4Metadata:
  path: /setting/logicmodules/metadata
  note: "Every installed and installable LogicModule with update status; ~5000 records / ~13 MB in one unpaginated call. Background and recipes: examples/logicmodules.md."
  gotchas:
    - "Returns a bare JSON array, not {total, items}; elm normalises it, but raw consumers must not expect .items."
    - "-s, -o, -F and -S are all ignored (verified: every variant returned all 5147 records), so select and sort client-side. -f works because elm projects fields itself. Do not add size/offset/filter for this path to swagger.undocumented.json: elm would then look like it filters when it does not."
    - "installationStatuses: IS_INSTALLED, CAN_UPGRADE (newer version published), IS_CUSTOMIZED (upgrading overwrites local edits), CAN_INSTALL (Exchange only), CAN_SKIP. originStatus CORE = LM official; also DEPRECATED, COMMUNITY, SECURITY_REVIEW."
    - "No endpoint shows the version you would upgrade TO; age of the installed version (originPublishedAtMS, epoch ms) is the only 'how out of date' signal."
    - "DEPRECATED modules never carry CAN_UPGRADE (they are replaced, not updated), so an upgrade query cannot find them; query originStatus DEPRECATED separately."
    - "associatedHostsCount is always 0 for DATASOURCE and CONFIGSOURCE; use associatedInstancesCount there, and AssociatedDeviceListByDataSourceId for devices."
    - "Module ids are unique only within a type (one id was shared by six types); always carry type with id."
    - "originPublishedAtMS comes out as a float in table formats because the column has gaps."
