@intflow/sentinelctl 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,39 +46,45 @@ Mistyped commands and options get a suggestion (`Did you mean --range?`), and ev
46
46
  ## Fleet overview and metrics
47
47
 
48
48
  ```bash
49
- sentinelctl fleet summary # status, attention, busiest devices, log levels, top/rising/new error patterns
50
- sentinelctl fleet summary -r 1h -o json # about 5KB
49
+ sentinelctl fleet summary # status, offline split, attention, busiest devices, trends, log levels, error patterns
50
+ sentinelctl fleet summary -r 1h -o json # about 10KB
51
51
  sentinelctl metrics top memory -r 7d # devices ranked by 7-day average memory + fleet distribution
52
52
  sentinelctl metrics top disk --agg last --above 85 --top 50
53
+ sentinelctl metrics top diskDaysLeft --below 14 # disks that fill up within two weeks at the current growth
53
54
  sentinelctl metrics top cpu --agg p95 -r 24h --compare --sort change
54
- sentinelctl metrics top reboots -r 7d --above 0
55
+ sentinelctl metrics top reboots -r 7d --above 0 # many devices reboot daily on schedule; compare with p50
55
56
  sentinelctl metrics top networkTx -r 24h --label fleet=edge -o json
57
+ sentinelctl devices correlate <agent-id> --peak cpu -r 24h # metrics and logs around the CPU peak
58
+ sentinelctl devices correlate <agent-id> --at 2026-09-30T14:58:00+09:00 --window 30m
56
59
  ```
57
60
 
58
- `metrics top` runs one query over every device: `--agg avg|max|min|p95|last` per device over `-r 1h|6h|24h|7d|30d` (or `--from/--to`, up to 30 days), ranked, with min/p50/p90/p95/max across devices. Metrics: cpu, memory, disk, swap, load, temperature, processes, tcpConnections, networkRx, networkTx, reboots. `--compare` adds the previous window of the same length. Device filters work like `devices list`; retired devices are excluded by default.
61
+ `metrics top` runs one query over every device: `--agg avg|max|min|p95|last` per device over `-r 1h|6h|24h|7d|30d` (or `--from/--to`, up to 30 days), ranked, with min/p50/p90/p95/max across devices. Metrics: cpu, memory, disk, swap, load, temperature, processes, tcpConnections, networkRx, networkTx, reboots, diskDaysLeft. `--compare` adds the previous window of the same length. Device filters work like `devices list`; retired devices are excluded by default.
59
62
 
60
- ## Logs
63
+ ## Logs: count first, read last
61
64
 
62
65
  ```bash
63
- sentinelctl logs search timeout -r 1h
64
- sentinelctl logs search -a <agent-id> -l error -r 24h --all
65
- sentinelctl logs search -s sshd.service --from 2026-09-30T00:00:00Z --to 2026-09-30T06:00:00Z
66
- sentinelctl logs search -l error -f # follow new errors (Ctrl+C to stop)
67
- sentinelctl logs search -q boom -o ndjson | jq .message
66
+ sentinelctl logs stats -l error -r 24h --by message --compare # which patterns, what changed
67
+ sentinelctl logs groups -l error -r 24h # every device x source x pattern, with count, first/last and latest line
68
+ sentinelctl logs stats -p "<pattern>" --by agent --timeline -r 7d # where and since when
69
+ sentinelctl logs search -p "<pattern>" -a <agent-id> -r 24h -n 50 # a few raw lines
68
70
  sentinelctl logs context -a <agent-id> -s app.service -t 2026-09-30T01:02:03.456Z
69
- sentinelctl logs histogram -l error -r 24h
70
- sentinelctl logs stats -l error -r 1h --by source # exact counts, one server-side query
71
- sentinelctl logs stats -l error -r 24h --by agent --top 10
72
- sentinelctl logs stats -a <agent-id> -r 7d --by message # message patterns, numbers collapsed to <N>
73
- sentinelctl logs stats -l error -r 24h --by message --compare --sort change # what is new or growing
71
+ sentinelctl logs facets -l error -r 24h # top devices, sources, severities at a glance
72
+ sentinelctl logs histogram -a <agent-id> -r 30d
74
73
  sentinelctl logs sources -r 24h
74
+ sentinelctl logs stats --service payment-api --env prod -l error -r 7d --by message # one application across devices (needs service/env labels)
75
+ sentinelctl logs stats -l error -r 24h --by service
76
+ sentinelctl logs search -l error --since <nextSince> -o ndjson # poll only new logs
75
77
  ```
76
78
 
77
- `logs stats --compare` also counts the previous window of the same length and adds `previousCount`, `change` and `isNew` per group. Message groups carry `searchText`, ready for `logs search -q`.
79
+ - `logs search` returns at most 10,000 lines (250 per request, `--pages N` up to 40 or `--all`). On a busy fleet that covers minutes, so never download logs to count or summarize them: `logs stats` and `logs groups` count every log in the window on the server.
80
+ - `-p/--pattern` takes a pattern exactly as `logs stats --by message` or `logs groups` print it (numbers as `<N>`) and matches only that pattern. `-q` matches words case-insensitively; add `--case-sensitive` for a much faster exact-case match.
81
+ - `--dedupe` folds lines repeated within the same second; `--collapse` on `logs search` is the same as `logs groups`.
82
+ - Windows: `15m`, `1h`, `6h`, `24h`, `7d`, `30d` or `--from/--to` up to the retention period (31 days by default). Fleet-wide aggregations over 7 days need `-a`, `-s`, `-p` or `--case-sensitive`; the server answers `log_query_too_broad` otherwise.
83
+ - Aggregations over a day are computed per day and finished days are cached, so repeating a long query is cheap; the CLI continues automatically when the server needs several requests.
84
+ - Levels: `emergency`, `critical` and `error` include more severe levels; `warning`, `info` (with notice) and `debug` match that level only.
85
+ - `--fields` (raw fields) is admin-only and audited. `--follow` polls at most every 5 seconds and is meant for humans.
78
86
 
79
- Windows are `15m`, `1h`, `6h`, `24h`, `7d`, or `--from`/`--to` up to 30 days. One page holds 50, 100 or 250 logs; when you ask for several pages (`--pages N`, at most 40, or `--all`) the CLI fetches 250 per request and stops at 10,000 logs. To answer "how many" or "which devices the most", use `logs stats`: it aggregates on the server and returns exact counts instead of downloading logs. `--fields` (raw fields) is admin-only and audited. `--follow` polls at most every 5 seconds.
80
-
81
- Each user can run 2 log queries and 2 metric aggregations at a time, web and CLI combined; a third concurrent query gets 429 and GET requests retry automatically with backoff.
87
+ Each user can run 2 log queries and 2 metric aggregations at a time (web and CLI combined) and spend 60 seconds of log query time per minute; GET requests that get 429 retry automatically with backoff.
82
88
 
83
89
  ## Devices and metadata
84
90
 
@@ -117,6 +123,10 @@ sentinelctl admin operations -r 7d
117
123
 
118
124
  Any other endpoint: `sentinelctl api "/api/devices?status=offline"`.
119
125
 
126
+ ## Updates
127
+
128
+ The server tells the CLI the latest version; when a newer one exists the CLI prints one notice a day on stderr (`{"notice":{"type":"update_available"}}` with -o json). Update with `npm install -g sentinelctl@latest`. A CLI older than the server minimum gets exit code 3 with the same command. `SENTINELCTL_NO_UPDATE_NOTICE=1` turns the notice off.
129
+
120
130
  ## Output and exit codes
121
131
 
122
132
  - `--select agentId,displayName,status,metadata.role` keeps only those fields of each item in json/ndjson output (plus top-level scalars), e.g. `devices list --all -o json` drops from ~700KB to ~20KB. `devices show --series none` (or `--series cpu,memory`) drops time series.
@@ -126,9 +136,10 @@ Any other endpoint: `sentinelctl api "/api/devices?status=offline"`.
126
136
 
127
137
  ## Publishing
128
138
 
129
- Publish the scoped package first, then the alias with the same version (`cli/unscoped/package.json` depends on it). Accounts with two-factor authentication need an interactive terminal for the browser approval.
139
+ GitHub Actions publishes both packages (`.github/workflows/publish-cli.yml`) with npm trusted publishing, so no npm token is stored.
130
140
 
131
- ```bash
132
- cd cli && npm test && npm publish --access public
133
- cd unscoped && npm publish --access public
134
- ```
141
+ 1. Bump `cli/package.json`, `cli/unscoped/package.json` (version and its `@intflow/sentinelctl` dependency) and `cliLatestVersion` in `config/dashboard.json` to the same version; `node scripts/check-cli-release.mjs` checks this.
142
+ 2. Commit, then push a tag: `git tag cli-v0.6.0 && git push origin cli-v0.6.0`.
143
+ 3. The workflow runs the CLI checks and tests, publishes `@intflow/sentinelctl`, waits for the registry, then publishes the `sentinelctl` alias. Versions already on npm are skipped. Run it manually (Actions → publish-cli) to verify and pack without publishing.
144
+
145
+ One-time setup on npmjs.com, for each of the two packages: Settings → Trusted Publisher → GitHub Actions, organization or user `yeon3724`, repository `sentinel-fleet-console`, workflow `publish-cli.yml`, environment `npm`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intflow/sentinelctl",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Sentinel Fleet Console CLI: search device logs, inspect status and metrics, edit metadata and manage roles",
5
5
  "type": "module",
6
6
  "bin": {
package/src/client.mjs CHANGED
@@ -9,9 +9,7 @@ export class ApiError extends Error {
9
9
  }
10
10
  }
11
11
 
12
- const userAgent = 'sentinelctl';
13
-
14
- export function createClient({ server, token = null, fetch: fetchImplementation = globalThis.fetch, timeoutMs = 60_000, maxRetries = 5, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
12
+ export function createClient({ server, token = null, userAgent = 'sentinelctl', onVersions = null, fetch: fetchImplementation = globalThis.fetch, timeoutMs = 60_000, maxRetries = 5, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) }) {
15
13
  async function request(method, path, { query, body, auth = true, allowStatuses = [] } = {}) {
16
14
  const url = new URL(path, server);
17
15
  for (const [key, value] of Object.entries(query ?? {})) {
@@ -36,6 +34,8 @@ export function createClient({ server, token = null, fetch: fetchImplementation
36
34
  const reason = error?.name === 'TimeoutError' ? 'timed out' : 'could not connect';
37
35
  throw new ApiError(`Request to ${url.origin} ${reason}.`, { code: 'network_error' });
38
36
  }
37
+ const latest = response.headers.get('x-sentinelctl-latest');
38
+ if (onVersions && latest) onVersions({ latest, minimum: response.headers.get('x-sentinelctl-minimum') });
39
39
  const text = await response.text();
40
40
  let payload = {};
41
41
  try {
package/src/commands.mjs CHANGED
@@ -4,7 +4,7 @@ import { ageText, localTime, percentText, renderKeyValues, renderTable, singleLi
4
4
 
5
5
  export const roleLabels = { viewer: 'viewer (read-only)', editor: 'editor', admin: 'admin' };
6
6
  export const outputChoices = ['table', 'json', 'ndjson'];
7
- const logRanges = ['15m', '1h', '6h', '24h', '7d'];
7
+ const logRanges = ['15m', '1h', '6h', '24h', '7d', '30d'];
8
8
  const deviceRanges = ['30m', '1h', '6h', '24h', '7d', '30d'];
9
9
  const logLevels = ['emergency', 'critical', 'error', 'warning', 'info', 'debug'];
10
10
  const deviceStatuses = ['all', 'online', 'delayed', 'offline', 'unknown', 'maintenance', 'retired'];
@@ -13,15 +13,29 @@ const maxPages = 40;
13
13
  const deviceSorts = ['attention', 'status', 'displayName', 'cpu', 'memory', 'disk', 'load', 'temperature', 'ageSeconds', 'role', 'site', 'group', 'incidentCount'];
14
14
 
15
15
  const logFilterOptions = {
16
- query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Case-insensitive text in the message. Positional words are appended.' },
17
- agent: { alias: 'a', type: 'string', placeholder: 'id', description: 'Exact agent ID (find it with `devices lookup`).' },
18
- level: { alias: 'l', type: 'string', choices: logLevels, description: 'This severity and above (error includes emergency/alert/critical).' },
19
- source: { alias: 's', type: 'string', placeholder: 'source', description: 'Exact source: systemd unit, container name, syslog identifier (see `logs sources`).' },
20
- range: { alias: 'r', type: 'string', choices: logRanges, description: 'Relative window. Default 15m when --from/--to are absent.' },
21
- from: { type: 'string', placeholder: 'ISO', description: 'Absolute window start, e.g. 2026-09-30T00:00:00+09:00. Requires --to; at most 30 days.' },
22
- to: { type: 'string', placeholder: 'ISO', description: 'Absolute window end.' },
16
+ query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Words in the message, case-insensitive. Positional words are appended.' },
17
+ pattern: { alias: 'p', type: 'string', placeholder: 'pattern', description: 'Exact pattern as printed by logs stats --by message or logs groups (<N> = number).' },
18
+ 'case-sensitive': { type: 'boolean', description: 'Case-sensitive -q: much faster, misses other casings.' },
19
+ agent: { alias: 'a', type: 'string', placeholder: 'id', description: 'Agent ID (see devices lookup).' },
20
+ level: { alias: 'l', type: 'string', choices: logLevels, description: 'emergency/critical/error include more severe levels; warning, info, debug are exact.' },
21
+ source: { alias: 's', type: 'string', placeholder: 'source', description: 'systemd unit, container or syslog identifier (see logs sources).' },
22
+ service: { type: 'string', placeholder: 'name', description: '`service` label, across devices.' },
23
+ env: { type: 'string', placeholder: 'name', description: '`env` label.' },
24
+ range: { alias: 'r', type: 'string', choices: logRanges, description: 'Relative window (default 15m).' },
25
+ from: { type: 'string', placeholder: 'ISO', description: 'Start, e.g. 2026-09-30T09:00:00+09:00 (with --to; up to 31 days).' },
26
+ to: { type: 'string', placeholder: 'ISO', description: 'End.' },
23
27
  };
24
28
 
29
+ async function getComplete(context, path, query) {
30
+ let body = await context.client.get(path, query);
31
+ for (let round = 0; body.complete === false && round < 20; round += 1) {
32
+ context.note(`Aggregating step ${body.coverage?.done ?? 0}/${body.coverage?.chunks ?? '?'}, continuing…`);
33
+ body = await context.client.get(path, query);
34
+ }
35
+ if (body.complete === false) context.hint(`Partial result: ${body.coverage?.done}/${body.coverage?.chunks} steps aggregated. Run the same command again to continue (finished days are cached).`);
36
+ return body;
37
+ }
38
+
25
39
  function logCriteria(options, positionals) {
26
40
  const text = [options.query, ...positionals].filter(Boolean).join(' ');
27
41
  if ((options.from && !options.to) || (!options.from && options.to)) {
@@ -33,8 +47,10 @@ function logCriteria(options, positionals) {
33
47
  throw new UsageError(`Cannot parse --${name}: ${value}`, { hint: 'Use ISO 8601, e.g. 2026-09-30T09:00:00+09:00.' });
34
48
  }
35
49
  }
50
+ if (options['case-sensitive'] && !text) throw new UsageError('--case-sensitive needs search text (-q).');
36
51
  return {
37
- q: text || undefined, agentId: options.agent, level: options.level, source: options.source,
52
+ q: text || undefined, agentId: options.agent, level: options.level, source: options.source, service: options.service, env: options.env,
53
+ pattern: options.pattern, match: options['case-sensitive'] ? 'exact' : undefined,
38
54
  range: options.from ? undefined : (options.range ?? '15m'),
39
55
  from: options.from ? new Date(options.from).toISOString() : undefined,
40
56
  to: options.to ? new Date(options.to).toISOString() : undefined,
@@ -94,14 +110,43 @@ function logKey(log) {
94
110
  function emptyLogHint(criteria) {
95
111
  const tips = [];
96
112
  if (criteria.range && criteria.range !== '7d') tips.push('widen the window (-r 24h or -r 7d)');
97
- if (criteria.q) tips.push('shorten or drop the search text');
113
+ if (criteria.q) tips.push(criteria.match === 'exact' ? 'drop --case-sensitive' : 'shorten or drop the search text');
114
+ if (criteria.pattern) tips.push('check the pattern with `sentinelctl logs stats --by message`');
98
115
  if (criteria.source) tips.push('check source names with `sentinelctl logs sources -r 24h`');
99
116
  if (criteria.agentId) tips.push('check the agent ID with `sentinelctl devices lookup <name>`');
100
117
  if (criteria.level) tips.push('drop -l');
101
118
  return tips.length ? `No logs matched. Try: ${tips.join('; ')}.` : 'No logs matched.';
102
119
  }
103
120
 
121
+ function timestampNanos(value) {
122
+ const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/.exec(String(value));
123
+ if (!match) return BigInt(Date.parse(value)) * 1_000_000n;
124
+ return BigInt(Date.parse(`${match[1]}${match[3]}`)) * 1_000_000n + BigInt((match[2] ?? '').padEnd(9, '0'));
125
+ }
126
+
127
+ function dedupeLogs(logs) {
128
+ const result = [];
129
+ for (const log of logs) {
130
+ const previous = result.at(-1);
131
+ const sameSecond = previous && String(previous.timestamp).slice(0, 19) === String(log.timestamp).slice(0, 19);
132
+ const strip = (message) => String(message).replace(/^\[[^\]]+\]\s*/, '');
133
+ if (sameSecond && previous.agentId === log.agentId && previous.source === log.source && strip(previous.message) === strip(log.message)) {
134
+ previous.repeat = (previous.repeat ?? 1) + 1;
135
+ continue;
136
+ }
137
+ result.push({ ...log });
138
+ }
139
+ return result;
140
+ }
141
+
104
142
  async function logsSearch(context, { options, positionals }) {
143
+ if (options.collapse) return logsGroups(context, { options, positionals });
144
+ if (options.since) {
145
+ if (options.from || options.to || options.range) throw new UsageError('--since cannot be combined with --range/--from/--to.');
146
+ if (!Number.isFinite(Date.parse(options.since))) throw new UsageError(`Cannot parse --since: ${options.since}`, { hint: 'Pass the nextSince value printed by the previous run.' });
147
+ options.from = options.since;
148
+ options.to = new Date().toISOString();
149
+ }
105
150
  const criteria = logCriteria(options, positionals);
106
151
  const paging = options.all || options.pages > 1;
107
152
  const limit = options.fields ? 50 : (options.limit ?? (paging ? 250 : 100));
@@ -120,8 +165,17 @@ async function logsSearch(context, { options, positionals }) {
120
165
  if (!last.hasMore || !last.nextCursor) break;
121
166
  cursor = last.nextCursor;
122
167
  }
123
- emit(context, context.global.output === 'json' ? { criteria: last.criteria, window: last.window, count: logs.length, hasMore: last.hasMore, logs } : logs,
124
- () => renderTable(logs, logColumns, { width: context.width, wide: options.wide }));
168
+ if (options.since) {
169
+ const since = timestampNanos(options.since);
170
+ for (let index = logs.length - 1; index >= 0; index -= 1) if (timestampNanos(logs[index].timestamp) <= since) logs.splice(index, 1);
171
+ }
172
+ const shown = options.dedupe ? dedupeLogs(logs) : logs;
173
+ const nextSince = logs[0]?.timestamp ?? options.since ?? null;
174
+ emit(context, context.global.output === 'json'
175
+ ? { criteria: last.criteria, window: last.window, count: logs.length, ...(options.dedupe ? { rows: shown.length } : {}), hasMore: last.hasMore, ...(options.since ? { nextSince } : {}), logs: shown }
176
+ : shown,
177
+ () => renderTable(shown, options.dedupe ? [...logColumns.slice(0, 4), { header: 'x', value: (log) => (log.repeat ? `×${log.repeat}` : ''), max: 5 }, logColumns[4]] : logColumns, { width: context.width, wide: options.wide }));
178
+ if (options.since) context.hint(`Next time: --since ${nextSince}`);
125
179
  if (!logs.length) context.hint(emptyLogHint(criteria));
126
180
  else context.note(`${logs.length} logs · ${localTime(last.window.start)} → ${localTime(last.window.end)}`);
127
181
  if (last.hasMore && options.all) context.hint(`Stopped at the ${maxPages * 250}-log cap. Narrow the filters, or use \`sentinelctl logs stats\` for counts.`);
@@ -157,17 +211,39 @@ async function followLogs(context, query, options) {
157
211
  }
158
212
 
159
213
 
160
- const statsDimensions = ['source', 'agent', 'level', 'message'];
214
+ const statsDimensions = ['source', 'agent', 'level', 'message', 'service'];
215
+
216
+ const sparkBlocks = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
217
+
218
+ function sparkline(points, window, step) {
219
+ if (!points?.length || !step) return '';
220
+ const start = Math.floor(Date.parse(window.start) / 1000 / step) * step;
221
+ const end = Date.parse(window.end) / 1000;
222
+ const counts = new Map(points);
223
+ const values = [];
224
+ for (let at = start; at < end; at += step) values.push(counts.get(at) ?? 0);
225
+ const peak = Math.max(1, ...values);
226
+ return values.map((value) => (value ? sparkBlocks[Math.min(7, Math.floor((value / peak) * 7.999))] : ' ')).join('');
227
+ }
228
+
229
+ function quoteArgument(text) {
230
+ return `"${String(text).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
231
+ }
232
+
233
+ function narrowingHint(criteria) {
234
+ const window = criteria.range ? `-r ${criteria.range}` : `--from ${criteria.from} --to ${criteria.to}`;
235
+ return { window, level: criteria.level ? ` -l ${criteria.level}` : '' };
236
+ }
161
237
 
162
238
  async function logsStats(context, { options, positionals }) {
163
239
  const criteria = logCriteria(options, positionals);
164
240
  if (options.sort === 'change' && !options.compare) {
165
241
  throw new UsageError('--sort change needs --compare.', { hint: 'Example: sentinelctl logs stats -l error -r 24h --by message --compare --sort change' });
166
242
  }
167
- const body = await context.client.get('/api/logs/stats', {
168
- ...criteria, by: options.by, top: options.top, compare: options.compare ? 1 : undefined, sort: options.sort,
243
+ const body = await getComplete(context, '/api/logs/stats', {
244
+ ...criteria, by: options.by, top: options.top, compare: options.compare ? 1 : undefined, sort: options.sort, timeline: options.timeline ? 1 : undefined,
169
245
  });
170
- const label = { source: 'SOURCE', agent: 'DEVICE', level: 'LEVEL', message: 'MESSAGE PATTERN' }[body.criteria.by];
246
+ const label = { source: 'SOURCE', agent: 'DEVICE', level: 'LEVEL', message: 'MESSAGE PATTERN', service: 'SERVICE' }[body.criteria.by];
171
247
  const signed = (value) => (value === null || value === undefined ? '?' : value > 0 ? `+${value}` : String(value));
172
248
  emit(context, context.global.output === 'ndjson' ? body.groups : body, () => renderTable(body.groups, [
173
249
  { header: 'COUNT', value: (group) => String(group.count) },
@@ -176,10 +252,13 @@ async function logsStats(context, { options, positionals }) {
176
252
  { header: 'CHANGE', value: (group) => (group.isNew ? 'new' : signed(group.change)) },
177
253
  ] : [{ header: 'SHARE', value: (group) => (body.total ? `${((group.count / body.total) * 100).toFixed(1)}%` : '-') }]),
178
254
  { header: 'DEVICES', value: (group) => String(group.devices) },
255
+ ...(body.timelineStepSeconds ? [{ header: 'TREND', value: (group) => sparkline(group.timeline, body.window, body.timelineStepSeconds), max: 48 }] : []),
256
+ ...(body.timelineStepSeconds ? [{ header: 'LAST', value: (group) => (group.last ? localTime(group.last).slice(5, 16) : '-'), max: 11 }] : []),
179
257
  ...(body.criteria.by === 'agent' ? [{ header: 'AGENT ID', value: (group) => group.key, max: 36 }] : []),
180
258
  { header: label, value: (group) => (body.criteria.by === 'agent' ? group.displayName ?? group.host ?? group.key : group.key) },
181
259
  ], { width: context.width, wide: options.wide }));
182
260
  context.note(`${body.total} logs from ${body.devices} devices · ${localTime(body.window.start)} → ${localTime(body.window.end)}`);
261
+ if (body.timelineStepSeconds) context.note(`TREND: one column per ${Math.round(body.timelineStepSeconds / 60)} min; first/last (bucket precision) are in -o json.`);
183
262
  if (body.previous) {
184
263
  context.note(`Previous window ${localTime(body.previous.window.start)} → ${localTime(body.previous.window.end)}: ${body.previous.total} logs (${signed(body.total - body.previous.total)}) · ${body.newGroupCount} new groups`);
185
264
  if (body.falling?.length && body.criteria.sort !== 'change') {
@@ -190,13 +269,45 @@ async function logsStats(context, { options, positionals }) {
190
269
  if (body.truncated) context.hint(`Showing the top ${body.groups.length} of ${body.groupCount} groups. Raise --top (max 100) or add filters.`);
191
270
  if (body.approximate) context.hint('Message patterns are approximate on this server (collapse_nums is unavailable); narrow the window or filters for exact counts.');
192
271
  if (!body.total) context.hint(emptyLogHint(criteria));
193
- const sample = body.groups.find((group) => group.searchText);
272
+ const sample = body.groups.find((group) => group.count > 0);
194
273
  if (body.criteria.by === 'message' && sample) {
195
- context.hint(`See matching logs: sentinelctl logs search -q "${sample.searchText.replaceAll('"', '\\"')}" -r ${criteria.range ?? '24h'}${criteria.level ? ` -l ${criteria.level}` : ''} (searchText in -o json)`);
274
+ const { window, level } = narrowingHint(criteria);
275
+ context.hint(`Next: who and where → sentinelctl logs groups -p ${quoteArgument(sample.key)} ${window}${level}; raw lines → sentinelctl logs search -p ${quoteArgument(sample.key)} ${window} -n 50`);
196
276
  }
197
277
  }
198
278
 
199
- const metricNames = ['cpu', 'memory', 'disk', 'swap', 'load', 'temperature', 'processes', 'tcpConnections', 'networkRx', 'networkTx', 'reboots'];
279
+ const groupColumns = [
280
+ { header: 'COUNT', value: (group) => String(group.count) },
281
+ { header: 'LAST', value: (group) => (group.last ? localTime(group.last).slice(5, 16) : '-'), max: 11 },
282
+ { header: 'DEVICE', value: (group) => group.displayName ?? group.agentId, max: 26 },
283
+ { header: 'SOURCE', value: (group) => group.source, max: 22 },
284
+ { header: 'PATTERN', value: (group) => group.pattern },
285
+ ];
286
+
287
+ async function logsGroups(context, { options, positionals }) {
288
+ const criteria = logCriteria(options, positionals);
289
+ const body = await getComplete(context, '/api/logs/groups', { ...criteria, top: options.top ?? options.limit });
290
+ emit(context, context.global.output === 'ndjson' ? body.groups : body, () => renderTable(body.groups, groupColumns, { width: context.width, wide: options.wide }));
291
+ context.note(`${body.total} logs in ${body.groupCount} groups (${body.patternCount} patterns, ${body.devices} devices) · ${localTime(body.window.start)} → ${localTime(body.window.end)}`);
292
+ if (body.truncated) context.hint(`Showing ${body.groups.length} of ${body.groupCount} groups, largest first. Raise --top (max 500) or add filters.`);
293
+ if (body.approximate) context.hint('Patterns are approximate on this server (collapse_nums is unavailable).');
294
+ if (!body.total) context.hint(emptyLogHint(criteria));
295
+ const first = body.groups[0];
296
+ if (first) {
297
+ const { window } = narrowingHint(criteria);
298
+ context.hint(`Each group keeps its latest line in -o json (latest.message). Raw lines of one group: sentinelctl logs search -a ${first.agentId} -p ${quoteArgument(first.pattern)} ${window} -n 50`);
299
+ }
300
+ }
301
+
302
+ async function logsFacets(context, { options, positionals }) {
303
+ const criteria = logCriteria({ ...options, range: options.range ?? (options.from ? undefined : '1h') }, positionals);
304
+ const body = await context.client.get('/api/logs/facets', { ...criteria, top: options.top });
305
+ emit(context, body, () => Object.entries(body.facets).map(([field, values]) => `${field}:\n${values
306
+ .map((item) => ` ${String(item.count).padStart(9)} ${item.displayName && item.displayName !== item.value ? `${item.displayName} (${item.value})` : item.value}`).join('\n')}`).join('\n'));
307
+ context.note(`${localTime(body.window.start)} → ${localTime(body.window.end)}`);
308
+ }
309
+
310
+ const metricNames = ['cpu', 'memory', 'disk', 'swap', 'load', 'temperature', 'processes', 'tcpConnections', 'networkRx', 'networkTx', 'reboots', 'diskDaysLeft'];
200
311
  const metricWindows = ['1h', '6h', '24h', '7d', '30d'];
201
312
  const deviceSeries = ['cpu', 'memory', 'disk', 'load', 'temperature', 'processes', 'tcpConnections', 'networkRx', 'networkTx', 'swap', 'uptime'];
202
313
 
@@ -247,7 +358,7 @@ async function metricsTop(context, { options, positionals }) {
247
358
  }
248
359
 
249
360
  async function fleetSummary(context, { options }) {
250
- const body = await context.client.get('/api/fleet/summary', { range: options.range, logs: options.logs === false ? 0 : undefined });
361
+ const body = await context.client.get('/api/fleet/summary', { range: options.range, logs: options.logs === false ? 0 : undefined, trends: options.trends === false ? 0 : undefined });
251
362
  emit(context, body, () => {
252
363
  const counts = (record) => Object.entries(record ?? {}).sort((left, right) => right[1] - left[1]).map(([key, value]) => `${key} ${value}`).join(', ');
253
364
  const names = (devices) => devices.map((device) => device.displayName).join(', ');
@@ -264,9 +375,23 @@ async function fleetSummary(context, { options }) {
264
375
  { header: 'EXAMPLES', value: (entry) => names(entry.devices) },
265
376
  ], { width: context.width })
266
377
  : ' none');
378
+ const offline = body.devices.offline;
379
+ if (offline) {
380
+ sections.push('', `Offline: ${offline.within24h.length} within 24h${offline.within24h.length ? ` (${offline.within24h.map((device) => `${device.displayName} ${device.offlineHours}h`).join(', ')})` : ''} · ${offline.otherCount} for 1-7 days · ${offline.over7dCount} over 7 days`);
381
+ }
267
382
  sections.push('', 'Highest now (online, active):', renderKeyValues(Object.entries(body.resources).map(([field, devices]) => [
268
383
  field, devices.map((device) => `${device.displayName} ${field === 'temperature' ? `${device.value}°C` : `${device.value}%`}`).join(', ') || '-',
269
384
  ])));
385
+ if (body.trends?.error) sections.push('', `Trends: ${body.trends.error}`);
386
+ else if (body.trends) {
387
+ const list = (devices, format) => devices.map((device) => `${device.displayName} ${format(device)}`).join(', ') || 'none';
388
+ sections.push('', 'Trends:', renderKeyValues([
389
+ ['disk full <14d', list(body.trends.diskFullWithin14Days, (device) => `${device.value}d`)],
390
+ ['temperature +15°C', list(body.trends.temperatureRise, (device) => `${device.previous}→${device.value}°C`)],
391
+ [`reboots >${body.trends.rebootOutliers.threshold}/7d`, `${list(body.trends.rebootOutliers.devices, (device) => `${device.value}`)} (typical ${body.trends.rebootOutliers.typicalPerWeek}/7d)`],
392
+ ]));
393
+ }
394
+ if (body.logs?.complete === false) sections.push('', 'Logs: partial (some days still aggregating); run again to complete.');
270
395
  if (body.logs?.error) sections.push('', `Logs: ${body.logs.error}`);
271
396
  else if (body.logs) {
272
397
  const errors = body.logs.errors;
@@ -287,7 +412,7 @@ async function fleetSummary(context, { options }) {
287
412
  }
288
413
  return sections.join('\n');
289
414
  });
290
- context.hint('Drill down: devices list --focus attention · logs stats -l error --by message --compare · metrics top memory -r 24h');
415
+ context.hint('Drill down: logs groups -l error -r 24h · logs stats -l error --by message --compare --timeline · metrics top diskDaysLeft · devices overview <id>');
291
416
  }
292
417
 
293
418
  async function logsContext(context, { options }) {
@@ -305,7 +430,7 @@ async function logsContext(context, { options }) {
305
430
  }
306
431
 
307
432
  async function logsHistogram(context, { options, positionals }) {
308
- const body = await context.client.get('/api/logs/histogram', logCriteria(options, positionals));
433
+ const body = await getComplete(context, '/api/logs/histogram', logCriteria(options, positionals));
309
434
  emit(context, context.global.output === 'ndjson' ? body.buckets : body, () => {
310
435
  const peak = Math.max(1, ...body.buckets.map((bucket) => bucket.total));
311
436
  return renderTable(body.buckets, [
@@ -324,7 +449,7 @@ async function logsSources(context, { options }) {
324
449
  { header: 'SOURCE', value: (source) => source.value },
325
450
  { header: 'COUNT', value: (source) => String(source.count) },
326
451
  ], { width: context.width }));
327
- context.note(`Top sources in a sample of the latest ${body.sampleSize} logs.`);
452
+ context.note(`Top ${body.sources.length} sources by exact count over ${body.range} (${body.total} logs).`);
328
453
  }
329
454
 
330
455
  const deviceColumns = [
@@ -427,6 +552,52 @@ async function devicesOverview(context, { options, positionals }) {
427
552
  ]));
428
553
  }
429
554
 
555
+ function durationSeconds(value, name) {
556
+ const match = /^(\d+)([smh])$/.exec(String(value));
557
+ if (!match) throw new UsageError(`--${name} must look like 90s, 15m or 2h: ${value}`);
558
+ return Number(match[1]) * { s: 1, m: 60, h: 3600 }[match[2]];
559
+ }
560
+
561
+ const correlateMetricNames = ['cpu', 'memory', 'disk', 'load', 'temperature', 'swap', 'processes', 'tcpConnections', 'networkRx', 'networkTx'];
562
+ const correlateUnits = { cpu: '%', memory: '%', disk: '%', swap: '%', temperature: '°C', networkRx: 'B/s', networkTx: 'B/s' };
563
+
564
+ async function devicesCorrelate(context, { options, positionals }) {
565
+ const agentId = requireAgentId(positionals);
566
+ if (!options.at === !options.peak) throw new UsageError('Give exactly one of --at <time> or --peak <metric>.', { hint: 'Example: sentinelctl devices correlate <agent-id> --peak cpu -r 24h' });
567
+ if (options.at && !Number.isFinite(Date.parse(options.at))) throw new UsageError(`Cannot parse --at: ${options.at}`);
568
+ const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}/correlate`, {
569
+ at: options.at ? new Date(options.at).toISOString() : undefined, peak: options.peak, range: options.peak ? options.range : undefined,
570
+ window: durationSeconds(options.window, 'window'), level: options.level, top: options.top, series: options.series ? 1 : undefined,
571
+ });
572
+ const value = (name, number) => metricValueText(number, correlateUnits[name]);
573
+ emit(context, body, () => [
574
+ renderKeyValues([
575
+ ['Device', `${body.displayName} (${body.agentId})`],
576
+ ['At', `${localTime(body.at)}${body.peak ? ` · peak ${body.peak.metric} ${value(body.peak.metric, body.peak.value)} in ${body.peak.range}` : ''}`],
577
+ ['Window', `${localTime(body.window.start)} → ${localTime(body.window.end)} (${body.stepSeconds}s step)`],
578
+ ['Reboots', body.reboots.map(localTime).join(', ') || 'none'],
579
+ ['Logs', `${body.logs.total} (${Object.entries(body.logs.levels).map(([level, count]) => `${level} ${count}`).join(', ') || '-'})`],
580
+ ]),
581
+ '',
582
+ renderTable(Object.entries(body.metrics), [
583
+ { header: 'METRIC', value: ([name]) => name },
584
+ { header: 'AT', value: ([name, item]) => value(name, item.atValue) },
585
+ { header: 'MIN', value: ([name, item]) => value(name, item.min) },
586
+ { header: 'MAX', value: ([name, item]) => value(name, item.max) },
587
+ { header: 'BIGGEST STEP', value: ([name, item]) => (item.biggestStep ? `${item.biggestStep.delta > 0 ? '+' : ''}${value(name, item.biggestStep.delta)} at ${localTime(item.biggestStep.at).slice(11)}` : '-') },
588
+ ], { width: context.width }),
589
+ '',
590
+ body.logs.groups.length ? renderTable(body.logs.groups, [
591
+ { header: 'COUNT', value: (group) => String(group.count) },
592
+ { header: 'FIRST', value: (group) => localTime(group.first).slice(11) },
593
+ { header: 'SOURCE', value: (group) => group.source, max: 22 },
594
+ { header: 'PATTERN', value: (group) => group.pattern },
595
+ ], { width: context.width, wide: options.wide }) : 'No logs in the window.',
596
+ ].join('\n'));
597
+ if (!body.logs.complete) context.hint('Log counts are partial; run again to complete.');
598
+ if (body.logs.truncated) context.hint(`Showing ${body.logs.groups.length} of ${body.logs.groupCount} log groups. Raise --top or add -l.`);
599
+ }
600
+
430
601
  async function health(context) {
431
602
  const body = await context.client.get('/api/health');
432
603
  emit(context, body, () => [
@@ -696,50 +867,88 @@ async function rawApi(context, { options, positionals }) {
696
867
  const commands = [
697
868
  {
698
869
  group: 'logs', name: 'search', run: logsSearch, role: 'viewer', mutates: false,
699
- summary: 'Search logs by window, device, severity, source and text; or follow live',
700
- description: 'Newest first. Default 15m window and 100 logs. When fetching several pages, 250 logs are fetched per request, up to 10,000 in total. To count or rank logs, use `logs stats` instead.',
870
+ summary: 'Raw log lines, newest first',
871
+ description: 'Stops at 10,000 lines, which covers minutes of this fleet: read examples here after logs stats/groups, never count with it.',
701
872
  args: [{ name: 'text', optional: true, description: 'Same as -q (words are joined with spaces).' }],
702
873
  options: {
703
874
  ...logFilterOptions,
704
- limit: { alias: 'n', type: 'integer', choices: [50, 100, 250], description: 'Logs per page. Default 100 (250 when paging).' },
705
- pages: { type: 'integer', placeholder: 'N', description: `Follow the cursor for up to N pages (1-${maxPages}).` },
706
- all: { type: 'boolean', description: 'Fetch every page of the window, stopping at 10,000 logs.' },
707
- fields: { type: 'boolean', description: 'Include raw fields (admin only, audited, 50 logs per page).' },
708
- follow: { alias: 'f', type: 'boolean', description: 'Keep printing new logs (for humans; never ends, agents should not use it).' },
709
- interval: { type: 'integer', default: 5, placeholder: 'seconds', description: 'Polling interval for --follow (min 5).' },
710
- wide: { alias: 'w', type: 'boolean', description: 'Do not truncate messages in table output.' },
875
+ limit: { alias: 'n', type: 'integer', choices: [50, 100, 250], description: 'Lines per page (default 100; 250 when paging).' },
876
+ pages: { type: 'integer', placeholder: 'N', description: `Pages to follow (1-${maxPages}).` },
877
+ all: { type: 'boolean', description: 'All pages, up to 10,000 lines.' },
878
+ fields: { type: 'boolean', description: 'Raw fields (admin, audited, 50 per page).' },
879
+ collapse: { type: 'boolean', description: 'Same as logs groups.' },
880
+ top: { type: 'integer', placeholder: 'N', description: 'Groups with --collapse (1-500, default 50).' },
881
+ dedupe: { type: 'boolean', description: 'Fold same-second repeats into one line with a count.' },
882
+ since: { type: 'string', placeholder: 'ISO', description: 'Only logs after this time; poll with the printed nextSince.' },
883
+ follow: { alias: 'f', type: 'boolean', description: 'Stream new logs (humans only; never ends).' },
884
+ interval: { type: 'integer', default: 5, placeholder: 'seconds', description: 'Seconds between --follow polls (min 5).' },
885
+ wide: { alias: 'w', type: 'boolean', description: 'Do not truncate.' },
711
886
  },
712
887
  roleFor: (options) => (options.fields ? 'admin' : 'viewer'),
713
888
  roleNote: '--fields requires admin',
714
889
  examples: [
715
890
  'sentinelctl logs search -l error -r 1h',
891
+ 'sentinelctl logs search -p "tegra-xusb <N>.usb: not all ports suspended: -<N>" -r 24h -n 50 -o json',
892
+ 'sentinelctl logs search -l error -r 24h --collapse -o json',
893
+ 'sentinelctl logs search -l error --since 2026-09-30T01:02:03.456789Z -o ndjson',
716
894
  'sentinelctl logs search "connection reset" -r 24h -o ndjson',
717
895
  'sentinelctl logs search -a <agent-id> -s ssh.service -r 6h --all -o json',
718
896
  'sentinelctl logs search --from 2026-09-30T09:00:00+09:00 --to 2026-09-30T10:00:00+09:00 -l warning',
719
897
  ],
720
- output: 'logs[]: timestamp (UTC ISO), severity, priority, agentId, host, displayName, source, message (+fields). -o json wraps them as {criteria, window, count, hasMore, logs}.',
898
+ output: 'logs[]: {timestamp, severity, agentId, displayName, source, message, repeat?, fields?}; json adds {window, count, hasMore, nextSince?}.',
721
899
  },
722
900
  {
723
901
  group: 'logs', name: 'stats', run: logsStats, role: 'viewer', mutates: false,
724
- summary: 'Count and rank logs by source, device, severity or message pattern (one server-side query)',
725
- description: 'Aggregates in VictoriaLogs instead of downloading logs, so counts are exact over the whole window. message groups by pattern with numbers collapsed to <N>. Cached for 60s on the server.',
902
+ summary: 'Exact counts by source, device, level, message pattern or service',
903
+ description: 'Counts every log in the window on the server. --compare shows what changed, --timeline when.',
726
904
  args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
727
905
  options: {
728
906
  ...logFilterOptions,
729
- by: { alias: 'b', type: 'string', choices: statsDimensions, default: 'source', description: 'Group by source, agent (device), level or message pattern.' },
730
- top: { type: 'integer', default: 20, placeholder: 'N', description: 'Groups to return (1-100).' },
731
- compare: { type: 'boolean', description: 'Also count the previous window of the same length: previousCount, change and isNew per group.' },
732
- sort: { type: 'string', choices: ['count', 'change'], description: 'Rank by count (default) or by increase over the previous window (needs --compare).' },
733
- wide: { alias: 'w', type: 'boolean', description: 'Do not truncate long keys in table output.' },
907
+ by: { alias: 'b', type: 'string', choices: statsDimensions, default: 'source', description: 'Grouping (agent = device).' },
908
+ top: { type: 'integer', default: 20, placeholder: 'N', description: 'Groups (1-100).' },
909
+ compare: { type: 'boolean', description: 'Add the previous window: previousCount, change, isNew.' },
910
+ timeline: { type: 'boolean', description: 'Add counts over time per group (~48 buckets).' },
911
+ sort: { type: 'string', choices: ['count', 'change'], description: 'count or change (needs --compare).' },
912
+ wide: { alias: 'w', type: 'boolean', description: 'Do not truncate.' },
734
913
  },
735
914
  examples: [
736
915
  'sentinelctl logs stats -l error -r 1h --by source',
737
916
  'sentinelctl logs stats -l error -r 24h --by agent --top 10 -o json',
738
917
  'sentinelctl logs stats -l error -r 24h --by message --compare --sort change',
918
+ 'sentinelctl logs stats -l error -r 7d --by message --timeline --top 10',
739
919
  'sentinelctl logs stats -a <agent-id> -r 7d --by message',
740
920
  'sentinelctl logs stats "connection refused" -r 24h --by agent',
741
921
  ],
742
- output: '{criteria, window, total, devices, groupCount, truncated, approximate, groups[]: {key, count, devices (distinct agents), host?, displayName?, searchText? (message: text to pass to logs search -q), previousCount?, change?, isNew?}, previous?: {window, total, devices, exhaustive}, newGroupCount?, rising?[≤5], falling?[≤5] (largest increases and decreases across all groups, not only the returned top)}.',
922
+ output: '{total, devices, groupCount, truncated, complete, groups[]: {key, count, devices, displayName?, previousCount?, change?, isNew?, timeline?[[epoch, count]], first?, last?}, previous?, newGroupCount?, rising?, falling?}.',
923
+ },
924
+ {
925
+ group: 'logs', name: 'groups', run: logsGroups, role: 'viewer', mutates: false,
926
+ summary: 'Every device × source × pattern with count, first/last time and latest line',
927
+ description: 'Lossless summary of the window, typically 50-100x smaller than the raw lines.',
928
+ args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
929
+ options: {
930
+ ...logFilterOptions,
931
+ top: { type: 'integer', default: 50, placeholder: 'N', description: 'Groups, largest first (1-500).' },
932
+ wide: { alias: 'w', type: 'boolean', description: 'Do not truncate.' },
933
+ },
934
+ examples: [
935
+ 'sentinelctl logs groups -l error -r 1h',
936
+ 'sentinelctl logs groups -a <agent-id> -r 7d -o json',
937
+ 'sentinelctl logs groups -p "Failed to start <N>.service" -r 24h',
938
+ ],
939
+ output: '{total, devices, groupCount, patternCount, truncated, complete, groups[]: {agentId, displayName, source, pattern, count, first, last, latest{timestamp, severity, message}}}.',
940
+ },
941
+ {
942
+ group: 'logs', name: 'facets', run: logsFacets, role: 'viewer', mutates: false,
943
+ summary: 'Top devices, severities, sources and labels in one query',
944
+ description: 'Fleet-wide up to 24h; up to 7 days with -a, -s, --service or -p.',
945
+ options: {
946
+ ...logFilterOptions,
947
+ range: { alias: 'r', type: 'string', choices: logRanges, description: 'Window (default 1h).' },
948
+ top: { type: 'integer', default: 10, placeholder: 'N', description: 'Values per field (1-50).' },
949
+ },
950
+ examples: ['sentinelctl logs facets -l error -r 24h', 'sentinelctl logs facets -a <agent-id> -r 7d -o json'],
951
+ output: '{facets{agent_id[{value, count, displayName}], severity[], _SYSTEMD_UNIT[], service[], …}}.',
743
952
  },
744
953
  {
745
954
  group: 'logs', name: 'context', run: logsContext, role: 'viewer', mutates: false,
@@ -761,15 +970,15 @@ const commands = [
761
970
  summary: 'Log volume and error counts over time',
762
971
  args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
763
972
  options: logFilterOptions,
764
- examples: ['sentinelctl logs histogram -r 24h', 'sentinelctl logs histogram -a <agent-id> -r 7d -o json'],
765
- output: '{start, end, stepSeconds, total, buckets[]: {at (epoch seconds), total, errors}}. Cached for 60s on the server.',
973
+ examples: ['sentinelctl logs histogram -r 24h', 'sentinelctl logs histogram -a <agent-id> -r 30d -o json'],
974
+ output: '{stepSeconds, total, complete, buckets[]: {at (epoch), total, errors}}.',
766
975
  },
767
976
  {
768
977
  group: 'logs', name: 'sources', run: logsSources, role: 'viewer', mutates: false,
769
- summary: 'Log sources (systemd units, containers) seen recently',
978
+ summary: 'Log sources (systemd units, containers) ranked by exact count',
770
979
  options: { range: { alias: 'r', type: 'string', choices: logRanges, default: '24h', description: 'Sample window.' } },
771
980
  examples: ['sentinelctl logs sources -r 1h'],
772
- output: '{range, sampleSize, sources[]: {value, count}}: top 50 in a sample of the latest 250 logs.',
981
+ output: '{total, sources[]: {value, count}} (top 50).',
773
982
  },
774
983
  {
775
984
  group: 'devices', name: 'list', run: devicesList, role: 'viewer', mutates: false,
@@ -827,21 +1036,21 @@ const commands = [
827
1036
  },
828
1037
  {
829
1038
  group: 'metrics', name: 'top', run: metricsTop, role: 'viewer', mutates: false,
830
- summary: 'Rank devices by a metric aggregated over a window, with the fleet-wide distribution',
831
- description: 'One server-side query over every device: avg/max/min/p95/last of the metric per device, ranked, plus min/p50/p90/p95/max across devices. --compare adds the previous window of the same length. reboots counts uptime resets. Device filters work like devices list (retired devices are excluded by default). Cached for 60s.',
1039
+ summary: 'Rank all devices by a metric over a window, with the fleet distribution',
1040
+ description: 'reboots = uptime resets (daily scheduled reboots are normal; compare with p50). diskDaysLeft = days until the root disk is full at the current growth. Device filters match devices list.',
832
1041
  args: [{ name: 'metric', description: metricNames.join(', ') }],
833
1042
  options: {
834
- agg: { type: 'string', choices: ['avg', 'max', 'min', 'p95', 'last'], default: 'avg', description: 'Per-device aggregation over the window (ignored for reboots).' },
1043
+ agg: { type: 'string', choices: ['avg', 'max', 'min', 'p95', 'last'], default: 'avg', description: 'Per-device aggregation.' },
835
1044
  range: { alias: 'r', type: 'string', choices: metricWindows, description: 'Window (default 24h).' },
836
- from: { type: 'string', placeholder: 'ISO', description: 'Absolute window start (with --to, at most 30 days).' },
837
- to: { type: 'string', placeholder: 'ISO', description: 'Absolute window end.' },
838
- top: { type: 'integer', default: 10, placeholder: 'N', description: 'Devices to return (1-100).' },
839
- order: { type: 'string', choices: ['desc', 'asc'], default: 'desc', description: 'desc = highest first, asc = lowest first.' },
840
- compare: { type: 'boolean', description: 'Also aggregate the previous window: previous and change per device.' },
841
- sort: { type: 'string', choices: ['value', 'change'], description: 'Rank by value (default) or by change (needs --compare).' },
842
- above: { type: 'number', placeholder: 'N', description: 'Only devices whose value is above N (e.g. --above 90).' },
843
- below: { type: 'number', placeholder: 'N', description: 'Only devices whose value is below N.' },
844
- query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Device text filter (same as devices list).' },
1045
+ from: { type: 'string', placeholder: 'ISO', description: 'Start (with --to; up to 30 days).' },
1046
+ to: { type: 'string', placeholder: 'ISO', description: 'End.' },
1047
+ top: { type: 'integer', default: 10, placeholder: 'N', description: 'Devices (1-100).' },
1048
+ order: { type: 'string', choices: ['desc', 'asc'], description: 'Default desc (asc for diskDaysLeft).' },
1049
+ compare: { type: 'boolean', description: 'Add the previous window: previous, change.' },
1050
+ sort: { type: 'string', choices: ['value', 'change'], description: 'value or change (needs --compare).' },
1051
+ above: { type: 'number', placeholder: 'N', description: 'Only values above N.' },
1052
+ below: { type: 'number', placeholder: 'N', description: 'Only values below N.' },
1053
+ query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Device text.' },
845
1054
  status: { type: 'string', choices: deviceStatuses, description: 'Reception status.' },
846
1055
  lifecycle: { type: 'string', choices: lifecycles, description: 'Lifecycle scope (default operational).' },
847
1056
  role: { type: 'string', placeholder: 'role', description: 'Device role.' },
@@ -856,20 +1065,40 @@ const commands = [
856
1065
  'sentinelctl metrics top disk --agg last --above 85 --top 50',
857
1066
  'sentinelctl metrics top cpu --agg p95 -r 24h --compare --sort change',
858
1067
  'sentinelctl metrics top reboots -r 7d --above 0',
1068
+ 'sentinelctl metrics top diskDaysLeft -r 24h --below 14',
859
1069
  'sentinelctl metrics top networkTx -r 24h --label fleet=edge -o json',
860
1070
  ],
861
- output: '{metric, aggregation, unit, window{start, end, seconds, range}, criteria, scope{devices, withData, withoutData, matched}, distribution{count, min, avg, p50, p90, p95, max}, previousDistribution?, devices[]: {agentId, displayName, host, status, value, previous?, change?}}.',
1071
+ output: '{metric, unit, scope{devices, withData, matched}, distribution{min, avg, p50, p90, p95, max}, devices[]: {agentId, displayName, status, value, previous?, change?}}.',
862
1072
  },
863
1073
  {
864
1074
  group: 'fleet', name: 'summary', run: fleetSummary, role: 'viewer', mutates: false,
865
- summary: 'One-call fleet briefing: status counts, attention reasons, busiest devices, log levels and error patterns',
866
- description: 'Start here to answer "how is the infrastructure?". Combines the device inventory, attention reasons with example devices, the highest current CPU/memory/disk/temperature, and log statistics for the window: totals by level, top and rising error patterns (compared with the previous window) and the devices with the most errors. About 5KB as JSON.',
1075
+ summary: 'Status, attention, trends and error patterns in one call',
1076
+ description: 'Start here. Trends flag disks full within 14 days, temperature rises of 15°C+ and unusual reboot counts; error patterns are compared with the previous window. ~10KB JSON.',
867
1077
  options: {
868
1078
  range: { alias: 'r', type: 'string', choices: logRanges, default: '24h', description: 'Log window.' },
869
- logs: { type: 'boolean', description: 'Include log statistics (default on; --no-logs skips them).' },
1079
+ logs: { type: 'boolean', description: 'Log statistics (--no-logs skips).' },
1080
+ trends: { type: 'boolean', description: 'Metric trends (--no-trends skips).' },
870
1081
  },
871
1082
  examples: ['sentinelctl fleet summary', 'sentinelctl fleet summary -r 1h -o json', 'sentinelctl fleet summary --no-logs'],
872
- output: '{range, stale, devices{total, status, lifecycle, metrics, logs}, attention[]: {reason, count, devices[≤5]}, resources{cpu, memory, disk, temperature: [{agentId, displayName, value}]}, logs{window, total, devices, levels, errors{total, previousTotal, devices, newPatterns}, topErrorPatterns[], risingErrorPatterns[], fallingErrorPatterns[] (shrunk or gone since the previous window), topErrorDevices[]}}.',
1083
+ output: '{devices{status, metrics, logs, offline}, attention[]{reason, count, devices}, resources{cpu, memory, disk, temperature}, trends{diskFullWithin14Days, temperatureRise, rebootOutliers}, logs{levels, errors, topErrorPatterns, risingErrorPatterns, fallingErrorPatterns, topErrorDevices}}.',
1084
+ },
1085
+ {
1086
+ group: 'devices', name: 'correlate', run: devicesCorrelate, role: 'viewer', mutates: false,
1087
+ summary: 'Metrics and logs of one device around a time or metric peak',
1088
+ description: 'For "what happened when X spiked": metric values and biggest change, reboots and log groups around --at or the --peak of a metric.',
1089
+ args: [{ name: 'agent-id' }],
1090
+ options: {
1091
+ at: { type: 'string', placeholder: 'ISO', description: 'Center time.' },
1092
+ peak: { type: 'string', choices: correlateMetricNames, description: 'Center on the highest point of this metric within --range instead.' },
1093
+ range: { alias: 'r', type: 'string', choices: ['1h', '6h', '24h', '7d'], default: '24h', description: 'Where to look for --peak.' },
1094
+ window: { type: 'string', default: '15m', placeholder: 'duration', description: 'Look this far before and after (1m-6h).' },
1095
+ level: { alias: 'l', type: 'string', choices: logLevels, description: 'Only these log levels in the groups.' },
1096
+ top: { type: 'integer', default: 20, placeholder: 'N', description: 'Log groups (1-100).' },
1097
+ series: { type: 'boolean', description: 'Include the metric time series in JSON.' },
1098
+ wide: { alias: 'w', type: 'boolean', description: 'Do not truncate patterns.' },
1099
+ },
1100
+ examples: ['sentinelctl devices correlate <agent-id> --peak cpu -r 24h', 'sentinelctl devices correlate <agent-id> --at 2026-09-30T14:58:00+09:00 --window 30m -o json'],
1101
+ output: '{at, window, peak?, metrics{<name>: {atValue, min, max, change, biggestStep{at, delta}}}, reboots[], logs{total, levels, groups[]: {source, pattern, count, first, last, latest}}}.',
873
1102
  },
874
1103
  {
875
1104
  group: 'metadata', name: 'get', run: metadataGet, role: 'viewer', mutates: false,
@@ -1006,10 +1235,10 @@ const commands = [
1006
1235
 
1007
1236
  export const catalog = commands;
1008
1237
  export const groups = {
1009
- fleet: 'One-call briefing of the whole fleet',
1010
- logs: 'Search, count and rank logs; compare windows; context around a log; volume trends; sources',
1011
- metrics: 'Rank devices by an aggregated metric across the fleet',
1012
- devices: 'Device list, details, lookup, reception overview',
1238
+ fleet: 'Fleet briefing',
1239
+ logs: 'Log counts, groups, patterns and raw lines',
1240
+ metrics: 'Fleet-wide metric rankings',
1241
+ devices: 'Device list, details, reception history, metric-log correlation',
1013
1242
  metadata: 'Device metadata: read, edit (editor), import/export (admin)',
1014
1243
  sessions: 'Your sessions and CLI tokens',
1015
1244
  admin: 'Roles, all sessions, audit clean-up, capacity (admin)',
package/src/main.mjs CHANGED
@@ -1,4 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
2
4
  import { closest, parseArguments, UsageError } from './args.mjs';
3
5
  import { ApiError, createClient } from './client.mjs';
4
6
  import { catalog, commandPath, findCommand, groups, outputChoices, roleLabels, usageLine } from './commands.mjs';
@@ -7,14 +9,23 @@ import { renderKeyValues } from './format.mjs';
7
9
  import { browserLogin, openBrowser } from './login.mjs';
8
10
 
9
11
  const version = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
12
+ const userAgent = `sentinelctl/${version} node/${process.versions.node}`;
13
+ const updateCommand = 'npm install -g sentinelctl@latest';
14
+
15
+ function newer(latest, current) {
16
+ const a = latest.split('.').map(Number);
17
+ const b = current.split('.').map(Number);
18
+ for (let index = 0; index < 3; index += 1) if (a[index] !== b[index]) return a[index] > b[index];
19
+ return false;
20
+ }
10
21
  const roleRank = { viewer: 0, editor: 1, admin: 2 };
11
- const exitCodes = { 0: 'success', 1: 'server or network error', 2: 'usage error (fix the arguments)', 3: 'not logged in or insufficient role (retrying will not help)' };
22
+ const exitCodes = { 0: 'success', 1: 'server or network error', 2: 'usage error (fix the arguments)', 3: 'not logged in, insufficient role or CLI too old (retrying will not help)' };
12
23
  const globalDefinitions = {
13
24
  server: { type: 'string', placeholder: 'url', description: 'Console URL (default https://monitor.intflow.dev; also SENTINEL_URL).' },
14
25
  profile: { type: 'string', placeholder: 'name', description: 'Saved login profile (default "default").' },
15
26
  output: { alias: 'o', type: 'string', choices: outputChoices, description: 'Output format. Use json or ndjson from agents and scripts.' },
16
27
  json: { type: 'boolean', description: 'Same as -o json.' },
17
- select: { type: 'string', placeholder: 'fields', description: 'json/ndjson: keep only these comma-separated fields (dotted paths like metadata.role) of each item in the main list, plus top-level scalars. Example: --select agentId,displayName,status,memory' },
28
+ select: { type: 'string', placeholder: 'fields', description: 'json/ndjson: keep only these fields of each item, e.g. agentId,status,metadata.role.' },
18
29
  quiet: { type: 'boolean', description: 'Suppress summaries and hints on stderr (errors are still printed).' },
19
30
  help: { alias: 'h', type: 'boolean', description: 'Show help.' },
20
31
  version: { alias: 'V', type: 'boolean', description: 'Show the version.' },
@@ -119,67 +130,54 @@ function overviewHelp() {
119
130
  return lines.join('\n');
120
131
  }
121
132
 
122
- const agentGuide = `Using sentinelctl from AI agents and scripts
133
+ const agentGuide = `sentinelctl for AI agents
134
+
135
+ Setup A human runs \`sentinelctl login\` once (30 days), or set SENTINEL_TOKEN and SENTINEL_URL.
136
+ Output Use -o json. stdout is data; notes, hints and errors go to stderr (--quiet drops notes).
137
+ Errors One stderr line {"error":{status,code,message,hint,exitCode}}; messages are Korean, hints English.
138
+ Exit 0 ok · 1 server/network (retry later) · 2 usage (fix arguments) · 3 login/role/too old (stop).
139
+ Updates A stderr {"notice":{"type":"update_available",…}} line means a newer CLI exists; tell the user.
140
+ Help sentinelctl <command> --help · sentinelctl commands <group> --json
123
141
 
124
- 1. A human logs in once: \`sentinelctl login\` and approve in the browser. The token lasts 30 days;
125
- every later command is non-interactive. The token carries the logged-in user's role, so log in
126
- with a viewer account for agents that should only read. Elsewhere, pass SENTINEL_TOKEN and
127
- SENTINEL_URL as environment variables.
128
- 2. Use -o json (one document) or -o ndjson (one item per line). stdout carries data only; summaries,
129
- hints and errors go to stderr. --quiet drops summaries and hints. With json/ndjson output an error
130
- is one stderr line: {"error":{"status","code","message","hint","exitCode"}}.
131
- 3. Exit codes: 0 success · 1 server/network error · 2 usage error (fix the arguments and retry)
132
- · 3 not logged in or insufficient role (do not retry). GET requests that hit 429 are retried
133
- automatically after Retry-After.
134
- 4. \`sentinelctl commands --json\` returns every command with its options, choices, required role,
135
- whether it changes data, examples and output fields; \`commands logs --json\` or
136
- \`commands "logs stats" --json\` returns only that part.
137
- 5. Typical investigation (start broad, then narrow)
138
- - overview: fleet summary -o json (~5KB: status, attention, busiest devices,
139
- log levels, top/rising/new error patterns)
140
- - what changed: logs stats -l error -r 24h --by message --compare --sort change -o json
141
- - fleet metrics: metrics top memory -r 7d -o json (ranked devices + fleet distribution)
142
- metrics top disk --agg last --above 85 · metrics top cpu --agg p95 --compare
143
- metrics top reboots -r 7d --above 0
144
- - find a device: devices lookup <part of name> -> agentId
145
- - status: devices list --focus attention --all -o json --select agentId,displayName,status,attentionReasons
146
- devices overview <id> -o json
147
- - metrics: devices show <id> -r 24h -o json --series cpu,memory (series are [epochSeconds, value])
148
- - logs: logs search -q "<searchText from logs stats>" -r 24h -o ndjson
149
- logs search -a <id> -l error -r 24h -o ndjson
150
- then logs context -a <id> -s <source> -t <timestamp> for surrounding lines
151
- - counts/ranking: logs stats -l error -r 24h --by agent|source|message -o json
152
- - trend: logs histogram -a <id> -r 7d -o json
153
- 6. Efficiency
154
- - Prefer aggregates: fleet summary, logs stats and metrics top answer "how many", "which the most"
155
- and "what changed" in one server-side query. Do not download thousands of logs or call
156
- devices show per device to compute fleet-wide numbers.
157
- - Keep JSON small: --select <fields> keeps only the fields you need (devices list --all -o json is
158
- ~700KB without it, ~20KB with a few fields); devices show --series none drops time series.
159
- - Narrow the window and filters (-a, -s, -l, -q) first. Use --pages N (max 40) to take only what you
160
- need; --all stops at 10,000 logs.
161
- - Each user can run 2 log queries and 2 metric aggregations at a time (web and CLI combined); run
162
- commands one after another.
163
- - Do not use --follow (it never ends). Repeat a -r 15m search instead.
164
- - The server caches identical log searches for 5s; stats, histograms, sources and metrics top for 60s.
165
- 7. For commands that change data (metadata set/bulk/rollback/import, admin ...), run with --dry-run
166
- first when available, check the result, then apply.
167
- 8. Server error messages are in Korean (shared with the web console); the "hint" field is English.`;
142
+ Workflow: broad to narrow, count before reading
143
+ 1. fleet summary status, attention, trends, error patterns
144
+ 2. logs stats -l error -r 24h --by message --compare which patterns, what changed
145
+ logs groups -l error -r 24h device x source x pattern, latest line
146
+ 3. logs stats -p "<pattern>" --by agent --timeline who and since when
147
+ devices correlate <id> --peak cpu metrics and logs around a spike
148
+ 4. logs search -p "<pattern>" -a <id> -n 50 a few raw lines (logs context for neighbors)
149
+ Metrics: metrics top <metric>, e.g. memory -r 7d · disk --agg last · diskDaysLeft · reboots
150
+
151
+ Rules
152
+ - Never count or summarize by downloading: logs search stops at 10,000 lines (minutes of this
153
+ fleet); logs stats and logs groups count the whole window.
154
+ - Pass patterns to -p exactly as printed (<N> = number). -q is a case-insensitive word match.
155
+ - Narrow first (-a, -s, --service, -l, -p) and start with 1h-24h. Fleet-wide windows over 7 days
156
+ need one of -a, -s, --service, -p or --case-sensitive (else 422 log_query_too_broad); narrowed
157
+ queries reach 31 days. Long aggregations are cached per day and continue automatically.
158
+ - Run commands one at a time: per user 2 log queries at once and 60s of log time per minute
159
+ (429 is retried automatically). Poll with --since <nextSince>, never --follow.
160
+ - Keep output small: --select a,b.c · devices show --series none.
161
+ - Commands that change data (metadata, admin): --dry-run first. Viewer tokens are read-only.`;
162
+
163
+ function compact(record) {
164
+ return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined && value !== null && !(Array.isArray(value) && !value.length)));
165
+ }
168
166
 
169
167
  function commandsCatalog(selected = null) {
170
168
  return {
171
169
  version,
172
- globalOptions: globalDefinitions,
170
+ globalOptions: Object.fromEntries(Object.entries(globalDefinitions).map(([name, spec]) => [name, compact(spec)])),
173
171
  exitCodes,
174
172
  commands: [
175
- ...(selected ? [] : Object.entries(specialCommands).map(([name, command]) => ({ command: name, usage: command.usage, summary: command.summary, role: null, mutates: name === 'logout' }))),
176
- ...(selected ?? catalog).map((command) => ({
177
- command: commandPath(command), usage: usageLine(command), summary: command.summary, description: command.description ?? null,
178
- role: command.role, roleNote: command.roleNote ?? null, mutates: command.mutates, args: command.args ?? [],
179
- options: Object.fromEntries(Object.entries(command.options).map(([name, spec]) => [name, {
180
- alias: spec.alias ?? null, type: spec.type, choices: spec.choices ?? null, default: spec.default ?? null,
181
- multiple: Boolean(spec.multiple), description: spec.description ?? null,
182
- }])),
173
+ ...(selected ? [] : Object.entries(specialCommands).map(([name, command]) => ({ command: name, usage: command.usage, summary: command.summary, mutates: name === 'logout' }))),
174
+ ...(selected ?? catalog).map((command) => compact({
175
+ command: commandPath(command), usage: usageLine(command), summary: command.summary, description: command.description,
176
+ role: command.role, roleNote: command.roleNote, mutates: command.mutates, args: command.args,
177
+ options: Object.fromEntries(Object.entries(command.options).map(([name, spec]) => [name, compact({
178
+ alias: spec.alias, type: spec.type, choices: spec.choices, default: spec.default,
179
+ multiple: spec.multiple || undefined, description: spec.description,
180
+ })])),
183
181
  examples: command.examples, output: command.output,
184
182
  })),
185
183
  ],
@@ -188,7 +186,7 @@ function commandsCatalog(selected = null) {
188
186
 
189
187
  async function login(context, argv) {
190
188
  const { options } = parseArguments(argv, { 'no-browser': { type: 'boolean' }, name: { type: 'string' } });
191
- const client = createClient({ server: context.connection.server, fetch: context.fetch });
189
+ const client = createClient({ server: context.connection.server, fetch: context.fetch, userAgent });
192
190
  const result = await browserLogin(client, {
193
191
  clientName: options.name,
194
192
  launchBrowser: !options['no-browser'],
@@ -223,6 +221,21 @@ async function logout(context) {
223
221
  context.out(removed ? 'Logged out.' : 'No saved login to remove.');
224
222
  }
225
223
 
224
+ async function noticeUpdate(context, environment) {
225
+ const latest = context?.versions?.latest;
226
+ if (!latest || !newer(latest, version) || environment.SENTINELCTL_NO_UPDATE_NOTICE) return;
227
+ const path = join(dirname(context.connection.path), 'update-notice.json');
228
+ let last = {};
229
+ try { last = JSON.parse(await readFile(path, 'utf8')); } catch {}
230
+ if (last.latest === latest && Date.now() - (last.at ?? 0) < 86_400_000) return;
231
+ const message = `sentinelctl ${latest} is available (you have ${version}). Update: ${updateCommand}`;
232
+ context.err(context.global.output === 'table' ? `notice: ${message}` : JSON.stringify({ notice: { type: 'update_available', current: version, latest, command: updateCommand } }));
233
+ try {
234
+ await mkdir(dirname(path), { recursive: true });
235
+ await writeFile(path, JSON.stringify({ latest, at: Date.now() }), { mode: 0o600 });
236
+ } catch {}
237
+ }
238
+
226
239
  async function rememberRole(context, role) {
227
240
  const saved = context.connection.profile;
228
241
  if (!saved || context.connection.fromEnvironment || saved.role === role) return;
@@ -237,13 +250,14 @@ async function whoami(context) {
237
250
  const identity = await context.client.get('/api/session');
238
251
  await rememberRole(context, identity.role);
239
252
  if (context.global.output === 'json' || context.global.output === 'ndjson') {
240
- context.out(JSON.stringify({ server: context.connection.server, ...identity, tokenExpiresAt: context.connection.profile?.expiresAt ?? null }));
253
+ context.out(JSON.stringify({ server: context.connection.server, ...identity, tokenExpiresAt: context.connection.profile?.expiresAt ?? null, cli: { version, latest: context.versions?.latest ?? null } }));
241
254
  return;
242
255
  }
243
256
  context.out(renderKeyValues([
244
257
  ['Server', context.connection.server], ['User', identity.user], ['Name', identity.name],
245
258
  ['Role', roleLabels[identity.role] ?? identity.role], ['Token ID', identity.sessionId],
246
259
  ['Token expires', context.connection.profile?.expiresAt],
260
+ ['CLI', `${version}${context.versions?.latest ? ` (latest ${context.versions.latest})` : ''}`],
247
261
  ]));
248
262
  }
249
263
 
@@ -282,6 +296,16 @@ function apiHint(error, context, command) {
282
296
  ? 'Check the agent ID with `sentinelctl devices lookup <part of the name>`.' : 'Not found; check the ID.';
283
297
  }
284
298
  if (error.status === 409) return 'Someone saved a change first. Run the command again to apply on top of the latest revision.';
299
+ if (error.code === 'cli_upgrade_required') return `Update the CLI: ${updateCommand} (or @intflow/sentinelctl@latest), then rerun.`;
300
+ if (error.code === 'log_query_too_broad') {
301
+ return 'Narrow it: add -a <agent-id>, -s <source>, --service <name>, -p "<pattern>" (from `logs stats --by message`) or --case-sensitive with -q, or use a window of 7 days or less.';
302
+ }
303
+ if (error.code === 'log_budget_exceeded') {
304
+ return `Your log query time for this minute is used up${error.retryAfterSeconds ? `; wait ${error.retryAfterSeconds}s` : ''}. Prefer logs stats/groups with filters over repeated wide searches.`;
305
+ }
306
+ if (error.status === 504) {
307
+ return 'The log/metric backend timed out. Narrow the window or add -a/-s/-l, use -p "<pattern>" instead of -q, or add --case-sensitive. Long aggregations resume from cache if you rerun.';
308
+ }
285
309
  if (error.status === 429) return `Too many requests${error.retryAfterSeconds ? `; wait ${error.retryAfterSeconds}s` : ''} and retry. Narrower filters reduce load.`;
286
310
  if ([400, 413, 415, 422].includes(error.status)) {
287
311
  return command ? `Check the input: sentinelctl ${commandPath(command)} --help` : 'Check the input.';
@@ -386,7 +410,7 @@ export async function main(argv, {
386
410
  select: global.select ? global.select.split(',').map((field) => field.trim()).filter(Boolean) : null,
387
411
  hint: (text) => { if (!global.quiet) err(`hint: ${text}`); },
388
412
  note: (text) => { if (!global.quiet && global.output === 'table') err(text); },
389
- client: createClient({ server: connection.server, token: connection.token, fetch, sleep }),
413
+ client: createClient({ server: connection.server, token: connection.token, fetch, sleep, userAgent, onVersions: (versions) => { context.versions = versions; } }),
390
414
  };
391
415
  if (name === 'login') { await login(context, commandArgv); return 0; }
392
416
  if (name === 'logout') { await logout(context); return 0; }
@@ -412,9 +436,11 @@ export async function main(argv, {
412
436
  `Ask an admin to run \`sentinelctl admin grant <your-email> --role ${error.required}\`. Nothing was sent to the server.`, 3);
413
437
  }
414
438
  if (error instanceof ApiError) {
415
- const exitCode = error.status === 401 || error.status === 403 || error.code === 'not_logged_in' ? 3 : 1;
439
+ const exitCode = error.status === 401 || error.status === 403 || error.status === 426 || error.code === 'not_logged_in' ? 3 : 1;
416
440
  return report(error, apiHint(error, context ?? { connection: {} }, command), exitCode);
417
441
  }
418
442
  return report(error, null, 1);
443
+ } finally {
444
+ if (context) await noticeUpdate(context, environment);
419
445
  }
420
446
  }