@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 +35 -24
- package/package.json +1 -1
- package/src/client.mjs +3 -3
- package/src/commands.mjs +295 -66
- package/src/main.mjs +86 -60
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,
|
|
50
|
-
sentinelctl fleet summary -r 1h -o json # about
|
|
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
|
|
64
|
-
sentinelctl logs
|
|
65
|
-
sentinelctl logs
|
|
66
|
-
sentinelctl logs search -
|
|
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
|
|
70
|
-
sentinelctl logs
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
139
|
+
GitHub Actions publishes both packages (`.github/workflows/publish-cli.yml`) with npm trusted publishing, so no npm token is stored.
|
|
130
140
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
package/src/client.mjs
CHANGED
|
@@ -9,9 +9,7 @@ export class ApiError extends Error {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
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: '
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
|
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.
|
|
272
|
+
const sample = body.groups.find((group) => group.count > 0);
|
|
194
273
|
if (body.criteria.by === 'message' && sample) {
|
|
195
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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: '
|
|
700
|
-
description: '
|
|
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: '
|
|
705
|
-
pages: { type: 'integer', placeholder: 'N', description: `
|
|
706
|
-
all: { type: 'boolean', description: '
|
|
707
|
-
fields: { type: 'boolean', description: '
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
|
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: '
|
|
725
|
-
description: '
|
|
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: '
|
|
730
|
-
top: { type: 'integer', default: 20, placeholder: 'N', description: 'Groups
|
|
731
|
-
compare: { type: 'boolean', description: '
|
|
732
|
-
|
|
733
|
-
|
|
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: '{
|
|
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
|
|
765
|
-
output: '{
|
|
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)
|
|
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: '{
|
|
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
|
|
831
|
-
description: '
|
|
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
|
|
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: '
|
|
837
|
-
to: { type: 'string', placeholder: 'ISO', description: '
|
|
838
|
-
top: { type: 'integer', default: 10, placeholder: 'N', description: 'Devices
|
|
839
|
-
order: { type: 'string', choices: ['desc', 'asc'],
|
|
840
|
-
compare: { type: 'boolean', description: '
|
|
841
|
-
sort: { type: 'string', choices: ['value', 'change'], description: '
|
|
842
|
-
above: { type: 'number', placeholder: 'N', description: 'Only
|
|
843
|
-
below: { type: 'number', placeholder: 'N', description: 'Only
|
|
844
|
-
query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Device text
|
|
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,
|
|
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: '
|
|
866
|
-
description: 'Start here
|
|
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: '
|
|
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: '{
|
|
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: '
|
|
1010
|
-
logs: '
|
|
1011
|
-
metrics: '
|
|
1012
|
-
devices: 'Device list, details,
|
|
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
|
|
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
|
|
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 = `
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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,
|
|
176
|
-
...(selected ?? catalog).map((command) => ({
|
|
177
|
-
command: commandPath(command), usage: usageLine(command), summary: command.summary, description: command.description
|
|
178
|
-
role: command.role, roleNote: command.roleNote
|
|
179
|
-
options: Object.fromEntries(Object.entries(command.options).map(([name, spec]) => [name, {
|
|
180
|
-
alias: spec.alias
|
|
181
|
-
multiple:
|
|
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
|
}
|