@intflow/sentinelctl 0.5.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 +12 -5
- package/package.json +1 -1
- package/src/client.mjs +3 -3
- package/src/commands.mjs +130 -66
- package/src/main.mjs +74 -74
package/README.md
CHANGED
|
@@ -54,6 +54,8 @@ sentinelctl metrics top diskDaysLeft --below 14 # disks that fill up
|
|
|
54
54
|
sentinelctl metrics top cpu --agg p95 -r 24h --compare --sort change
|
|
55
55
|
sentinelctl metrics top reboots -r 7d --above 0 # many devices reboot daily on schedule; compare with p50
|
|
56
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
|
|
57
59
|
```
|
|
58
60
|
|
|
59
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.
|
|
@@ -121,6 +123,10 @@ sentinelctl admin operations -r 7d
|
|
|
121
123
|
|
|
122
124
|
Any other endpoint: `sentinelctl api "/api/devices?status=offline"`.
|
|
123
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
|
+
|
|
124
130
|
## Output and exit codes
|
|
125
131
|
|
|
126
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.
|
|
@@ -130,9 +136,10 @@ Any other endpoint: `sentinelctl api "/api/devices?status=offline"`.
|
|
|
130
136
|
|
|
131
137
|
## Publishing
|
|
132
138
|
|
|
133
|
-
|
|
139
|
+
GitHub Actions publishes both packages (`.github/workflows/publish-cli.yml`) with npm trusted publishing, so no npm token is stored.
|
|
134
140
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
@@ -13,17 +13,17 @@ 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: 'Words
|
|
17
|
-
pattern: { alias: 'p', type: 'string', placeholder: 'pattern', description: 'Exact
|
|
18
|
-
'case-sensitive': { type: 'boolean', description: '
|
|
19
|
-
agent: { alias: 'a', type: 'string', placeholder: 'id', description: '
|
|
20
|
-
level: { alias: 'l', type: 'string', choices: logLevels, description: 'emergency/critical/error include more severe levels; warning, info
|
|
21
|
-
source: { alias: 's', type: 'string', placeholder: 'source', description: '
|
|
22
|
-
service: { type: 'string', placeholder: 'name', description: '
|
|
23
|
-
env: { type: 'string', placeholder: 'name', description: '
|
|
24
|
-
range: { alias: 'r', type: 'string', choices: logRanges, description: 'Relative window
|
|
25
|
-
from: { type: 'string', placeholder: 'ISO', description: '
|
|
26
|
-
to: { type: 'string', placeholder: 'ISO', description: '
|
|
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.' },
|
|
27
27
|
};
|
|
28
28
|
|
|
29
29
|
async function getComplete(context, path, query) {
|
|
@@ -552,6 +552,52 @@ async function devicesOverview(context, { options, positionals }) {
|
|
|
552
552
|
]));
|
|
553
553
|
}
|
|
554
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
|
+
|
|
555
601
|
async function health(context) {
|
|
556
602
|
const body = await context.client.get('/api/health');
|
|
557
603
|
emit(context, body, () => [
|
|
@@ -821,22 +867,22 @@ async function rawApi(context, { options, positionals }) {
|
|
|
821
867
|
const commands = [
|
|
822
868
|
{
|
|
823
869
|
group: 'logs', name: 'search', run: logsSearch, role: 'viewer', mutates: false,
|
|
824
|
-
summary: 'Raw log lines
|
|
825
|
-
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.',
|
|
826
872
|
args: [{ name: 'text', optional: true, description: 'Same as -q (words are joined with spaces).' }],
|
|
827
873
|
options: {
|
|
828
874
|
...logFilterOptions,
|
|
829
|
-
limit: { alias: 'n', type: 'integer', choices: [50, 100, 250], description: '
|
|
830
|
-
pages: { type: 'integer', placeholder: 'N', description: `
|
|
831
|
-
all: { type: 'boolean', description: '
|
|
832
|
-
fields: { type: 'boolean', description: '
|
|
833
|
-
collapse: { type: 'boolean', description: '
|
|
834
|
-
top: { type: 'integer', placeholder: 'N', description: '
|
|
835
|
-
dedupe: { type: 'boolean', description: 'Fold
|
|
836
|
-
since: { type: 'string', placeholder: 'ISO', description: 'Only logs after this
|
|
837
|
-
follow: { alias: 'f', type: 'boolean', description: '
|
|
838
|
-
interval: { type: 'integer', default: 5, placeholder: 'seconds', description: '
|
|
839
|
-
wide: { alias: 'w', type: 'boolean', description: 'Do not truncate
|
|
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.' },
|
|
840
886
|
},
|
|
841
887
|
roleFor: (options) => (options.fields ? 'admin' : 'viewer'),
|
|
842
888
|
roleNote: '--fields requires admin',
|
|
@@ -849,21 +895,21 @@ const commands = [
|
|
|
849
895
|
'sentinelctl logs search -a <agent-id> -s ssh.service -r 6h --all -o json',
|
|
850
896
|
'sentinelctl logs search --from 2026-09-30T09:00:00+09:00 --to 2026-09-30T10:00:00+09:00 -l warning',
|
|
851
897
|
],
|
|
852
|
-
output: 'logs[]: timestamp
|
|
898
|
+
output: 'logs[]: {timestamp, severity, agentId, displayName, source, message, repeat?, fields?}; json adds {window, count, hasMore, nextSince?}.',
|
|
853
899
|
},
|
|
854
900
|
{
|
|
855
901
|
group: 'logs', name: 'stats', run: logsStats, role: 'viewer', mutates: false,
|
|
856
|
-
summary: '
|
|
857
|
-
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.',
|
|
858
904
|
args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
|
|
859
905
|
options: {
|
|
860
906
|
...logFilterOptions,
|
|
861
|
-
by: { alias: 'b', type: 'string', choices: statsDimensions, default: 'source', description: '
|
|
862
|
-
top: { type: 'integer', default: 20, placeholder: 'N', description: 'Groups
|
|
863
|
-
compare: { type: 'boolean', description: '
|
|
864
|
-
timeline: { type: 'boolean', description: 'Add
|
|
865
|
-
sort: { type: 'string', choices: ['count', 'change'], description: '
|
|
866
|
-
wide: { alias: 'w', type: 'boolean', description: 'Do not truncate
|
|
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.' },
|
|
867
913
|
},
|
|
868
914
|
examples: [
|
|
869
915
|
'sentinelctl logs stats -l error -r 1h --by source',
|
|
@@ -873,36 +919,36 @@ const commands = [
|
|
|
873
919
|
'sentinelctl logs stats -a <agent-id> -r 7d --by message',
|
|
874
920
|
'sentinelctl logs stats "connection refused" -r 24h --by agent',
|
|
875
921
|
],
|
|
876
|
-
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?}.',
|
|
877
923
|
},
|
|
878
924
|
{
|
|
879
925
|
group: 'logs', name: 'groups', run: logsGroups, role: 'viewer', mutates: false,
|
|
880
|
-
summary: 'Every
|
|
881
|
-
description: '
|
|
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.',
|
|
882
928
|
args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
|
|
883
929
|
options: {
|
|
884
930
|
...logFilterOptions,
|
|
885
|
-
top: { type: 'integer', default: 50, placeholder: 'N', description: 'Groups
|
|
886
|
-
wide: { alias: 'w', type: 'boolean', description: 'Do not truncate
|
|
931
|
+
top: { type: 'integer', default: 50, placeholder: 'N', description: 'Groups, largest first (1-500).' },
|
|
932
|
+
wide: { alias: 'w', type: 'boolean', description: 'Do not truncate.' },
|
|
887
933
|
},
|
|
888
934
|
examples: [
|
|
889
935
|
'sentinelctl logs groups -l error -r 1h',
|
|
890
936
|
'sentinelctl logs groups -a <agent-id> -r 7d -o json',
|
|
891
937
|
'sentinelctl logs groups -p "Failed to start <N>.service" -r 24h',
|
|
892
938
|
],
|
|
893
|
-
output: '{
|
|
939
|
+
output: '{total, devices, groupCount, patternCount, truncated, complete, groups[]: {agentId, displayName, source, pattern, count, first, last, latest{timestamp, severity, message}}}.',
|
|
894
940
|
},
|
|
895
941
|
{
|
|
896
942
|
group: 'logs', name: 'facets', run: logsFacets, role: 'viewer', mutates: false,
|
|
897
|
-
summary: 'Top
|
|
898
|
-
description: '
|
|
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.',
|
|
899
945
|
options: {
|
|
900
946
|
...logFilterOptions,
|
|
901
947
|
range: { alias: 'r', type: 'string', choices: logRanges, description: 'Window (default 1h).' },
|
|
902
948
|
top: { type: 'integer', default: 10, placeholder: 'N', description: 'Values per field (1-50).' },
|
|
903
949
|
},
|
|
904
950
|
examples: ['sentinelctl logs facets -l error -r 24h', 'sentinelctl logs facets -a <agent-id> -r 7d -o json'],
|
|
905
|
-
output: '{
|
|
951
|
+
output: '{facets{agent_id[{value, count, displayName}], severity[], _SYSTEMD_UNIT[], service[], …}}.',
|
|
906
952
|
},
|
|
907
953
|
{
|
|
908
954
|
group: 'logs', name: 'context', run: logsContext, role: 'viewer', mutates: false,
|
|
@@ -925,14 +971,14 @@ const commands = [
|
|
|
925
971
|
args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
|
|
926
972
|
options: logFilterOptions,
|
|
927
973
|
examples: ['sentinelctl logs histogram -r 24h', 'sentinelctl logs histogram -a <agent-id> -r 30d -o json'],
|
|
928
|
-
output: '{
|
|
974
|
+
output: '{stepSeconds, total, complete, buckets[]: {at (epoch), total, errors}}.',
|
|
929
975
|
},
|
|
930
976
|
{
|
|
931
977
|
group: 'logs', name: 'sources', run: logsSources, role: 'viewer', mutates: false,
|
|
932
978
|
summary: 'Log sources (systemd units, containers) ranked by exact count',
|
|
933
979
|
options: { range: { alias: 'r', type: 'string', choices: logRanges, default: '24h', description: 'Sample window.' } },
|
|
934
980
|
examples: ['sentinelctl logs sources -r 1h'],
|
|
935
|
-
output: '{
|
|
981
|
+
output: '{total, sources[]: {value, count}} (top 50).',
|
|
936
982
|
},
|
|
937
983
|
{
|
|
938
984
|
group: 'devices', name: 'list', run: devicesList, role: 'viewer', mutates: false,
|
|
@@ -990,21 +1036,21 @@ const commands = [
|
|
|
990
1036
|
},
|
|
991
1037
|
{
|
|
992
1038
|
group: 'metrics', name: 'top', run: metricsTop, role: 'viewer', mutates: false,
|
|
993
|
-
summary: 'Rank devices by a metric
|
|
994
|
-
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.',
|
|
995
1041
|
args: [{ name: 'metric', description: metricNames.join(', ') }],
|
|
996
1042
|
options: {
|
|
997
|
-
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.' },
|
|
998
1044
|
range: { alias: 'r', type: 'string', choices: metricWindows, description: 'Window (default 24h).' },
|
|
999
|
-
from: { type: 'string', placeholder: 'ISO', description: '
|
|
1000
|
-
to: { type: 'string', placeholder: 'ISO', description: '
|
|
1001
|
-
top: { type: 'integer', default: 10, placeholder: 'N', description: 'Devices
|
|
1002
|
-
order: { type: 'string', choices: ['desc', 'asc'], description: 'desc
|
|
1003
|
-
compare: { type: 'boolean', description: '
|
|
1004
|
-
sort: { type: 'string', choices: ['value', 'change'], description: '
|
|
1005
|
-
above: { type: 'number', placeholder: 'N', description: 'Only
|
|
1006
|
-
below: { type: 'number', placeholder: 'N', description: 'Only
|
|
1007
|
-
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.' },
|
|
1008
1054
|
status: { type: 'string', choices: deviceStatuses, description: 'Reception status.' },
|
|
1009
1055
|
lifecycle: { type: 'string', choices: lifecycles, description: 'Lifecycle scope (default operational).' },
|
|
1010
1056
|
role: { type: 'string', placeholder: 'role', description: 'Device role.' },
|
|
@@ -1022,19 +1068,37 @@ const commands = [
|
|
|
1022
1068
|
'sentinelctl metrics top diskDaysLeft -r 24h --below 14',
|
|
1023
1069
|
'sentinelctl metrics top networkTx -r 24h --label fleet=edge -o json',
|
|
1024
1070
|
],
|
|
1025
|
-
output: '{metric,
|
|
1071
|
+
output: '{metric, unit, scope{devices, withData, matched}, distribution{min, avg, p50, p90, p95, max}, devices[]: {agentId, displayName, status, value, previous?, change?}}.',
|
|
1026
1072
|
},
|
|
1027
1073
|
{
|
|
1028
1074
|
group: 'fleet', name: 'summary', run: fleetSummary, role: 'viewer', mutates: false,
|
|
1029
|
-
summary: '
|
|
1030
|
-
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.',
|
|
1031
1077
|
options: {
|
|
1032
1078
|
range: { alias: 'r', type: 'string', choices: logRanges, default: '24h', description: 'Log window.' },
|
|
1033
|
-
logs: { type: 'boolean', description: '
|
|
1034
|
-
trends: { type: 'boolean', description: '
|
|
1079
|
+
logs: { type: 'boolean', description: 'Log statistics (--no-logs skips).' },
|
|
1080
|
+
trends: { type: 'boolean', description: 'Metric trends (--no-trends skips).' },
|
|
1035
1081
|
},
|
|
1036
1082
|
examples: ['sentinelctl fleet summary', 'sentinelctl fleet summary -r 1h -o json', 'sentinelctl fleet summary --no-logs'],
|
|
1037
|
-
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}}}.',
|
|
1038
1102
|
},
|
|
1039
1103
|
{
|
|
1040
1104
|
group: 'metadata', name: 'get', run: metadataGet, role: 'viewer', mutates: false,
|
|
@@ -1171,10 +1235,10 @@ const commands = [
|
|
|
1171
1235
|
|
|
1172
1236
|
export const catalog = commands;
|
|
1173
1237
|
export const groups = {
|
|
1174
|
-
fleet: '
|
|
1175
|
-
logs: '
|
|
1176
|
-
metrics: '
|
|
1177
|
-
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',
|
|
1178
1242
|
metadata: 'Device metadata: read, edit (editor), import/export (admin)',
|
|
1179
1243
|
sessions: 'Your sessions and CLI tokens',
|
|
1180
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,84 +130,54 @@ function overviewHelp() {
|
|
|
119
130
|
return lines.join('\n');
|
|
120
131
|
}
|
|
121
132
|
|
|
122
|
-
const agentGuide = `
|
|
123
|
-
|
|
124
|
-
SETUP
|
|
125
|
-
- A human logs in once (\`sentinelctl login\`, approve in the browser; 30 days). Later commands are
|
|
126
|
-
non-interactive. Elsewhere pass SENTINEL_TOKEN and SENTINEL_URL.
|
|
127
|
-
- Use -o json or -o ndjson. stdout is data only; notes, hints and errors go to stderr (--quiet drops
|
|
128
|
-
notes and hints). A json/ndjson error is one stderr line {"error":{status,code,message,hint,exitCode}}.
|
|
129
|
-
- Exit codes: 0 ok · 1 server/network (retry later) · 2 usage (fix the arguments) · 3 login/role (do
|
|
130
|
-
not retry). \`sentinelctl commands <group> --json\` describes options and output fields.
|
|
131
|
-
|
|
132
|
-
HOW TO INVESTIGATE: broad to narrow, count before you read
|
|
133
|
-
1. Overview fleet summary -o json
|
|
134
|
-
status, attention reasons, busiest devices, trends (disk full soon, temperature
|
|
135
|
-
rises, unusual reboots), error levels, top/rising/falling/new error patterns.
|
|
136
|
-
2. What and where logs stats -l error -r 24h --by message --compare -o json (patterns, change)
|
|
137
|
-
logs groups -l error -r 24h -o json (every device x source x pattern with
|
|
138
|
-
count, first/last time, latest line)
|
|
139
|
-
3. When logs stats -p "<pattern>" --by agent --timeline -r 7d -o json
|
|
140
|
-
logs histogram -a <id> -r 7d -o json
|
|
141
|
-
4. Read a few lines logs search -p "<pattern>" -a <id> -r 24h -n 50 -o json
|
|
142
|
-
logs context -a <id> -s <source> -t <timestamp> (lines around one log)
|
|
143
|
-
5. Device state devices overview <id> -r 7d -o json · devices show <id> --series cpu,memory -o json
|
|
144
|
-
6. Fleet metrics metrics top memory -r 7d · metrics top disk --agg last --above 85
|
|
145
|
-
metrics top diskDaysLeft --below 14 · metrics top cpu --agg p95 --compare
|
|
146
|
-
metrics top reboots -r 7d (most devices reboot daily; compare with p50)
|
|
133
|
+
const agentGuide = `sentinelctl for AI agents
|
|
147
134
|
|
|
148
|
-
|
|
149
|
-
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
- Copy patterns exactly. Patterns from logs stats --by message / logs groups (numbers shown as <N>)
|
|
155
|
-
go to -p/--pattern, which matches that pattern exactly and quickly. -q matches words
|
|
156
|
-
case-insensitively and can also hit unrelated messages.
|
|
157
|
-
- Narrow before widening: device (-a), source (-s), service (--service), severity (-l), pattern (-p).
|
|
158
|
-
Start with 1h-24h; widen to 7d or 30d only when you need history.
|
|
159
|
-
- Fleet-wide windows over 7 days need a narrowing filter (-a, -s, --service, -p or -q with
|
|
160
|
-
--case-sensitive); otherwise the server answers 422 log_query_too_broad.
|
|
161
|
-
Single-device, single-service and single-pattern queries can use the whole retention period
|
|
162
|
-
(31 days by default).
|
|
163
|
-
- Windows over one day are aggregated per day and finished days are cached: repeating a long query
|
|
164
|
-
is cheap, and the CLI continues automatically if the server needs several requests. A partial
|
|
165
|
-
result says so in complete/coverage.
|
|
166
|
-
- To watch for new logs, poll with --since <nextSince from the previous run>, not --follow.
|
|
167
|
-
- --dedupe folds lines repeated within the same second (the agent writes some messages twice).
|
|
168
|
-
- Levels: emergency/critical/error include more severe levels; warning, info and debug are exact.
|
|
169
|
-
- Applications are found by source (-s: systemd unit, container) on one device, or by the service
|
|
170
|
-
label (--service, --env) across devices when the collector sets it; logs stats --by service ranks them.
|
|
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
|
|
171
141
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
176
150
|
|
|
177
|
-
|
|
178
|
-
-
|
|
179
|
-
|
|
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.`;
|
|
180
162
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
- Server error messages are Korean (shared with the web console); hints are English.`;
|
|
163
|
+
function compact(record) {
|
|
164
|
+
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined && value !== null && !(Array.isArray(value) && !value.length)));
|
|
165
|
+
}
|
|
185
166
|
|
|
186
167
|
function commandsCatalog(selected = null) {
|
|
187
168
|
return {
|
|
188
169
|
version,
|
|
189
|
-
globalOptions: globalDefinitions,
|
|
170
|
+
globalOptions: Object.fromEntries(Object.entries(globalDefinitions).map(([name, spec]) => [name, compact(spec)])),
|
|
190
171
|
exitCodes,
|
|
191
172
|
commands: [
|
|
192
|
-
...(selected ? [] : Object.entries(specialCommands).map(([name, command]) => ({ command: name, usage: command.usage, summary: command.summary,
|
|
193
|
-
...(selected ?? catalog).map((command) => ({
|
|
194
|
-
command: commandPath(command), usage: usageLine(command), summary: command.summary, description: command.description
|
|
195
|
-
role: command.role, roleNote: command.roleNote
|
|
196
|
-
options: Object.fromEntries(Object.entries(command.options).map(([name, spec]) => [name, {
|
|
197
|
-
alias: spec.alias
|
|
198
|
-
multiple:
|
|
199
|
-
}])),
|
|
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
|
+
})])),
|
|
200
181
|
examples: command.examples, output: command.output,
|
|
201
182
|
})),
|
|
202
183
|
],
|
|
@@ -205,7 +186,7 @@ function commandsCatalog(selected = null) {
|
|
|
205
186
|
|
|
206
187
|
async function login(context, argv) {
|
|
207
188
|
const { options } = parseArguments(argv, { 'no-browser': { type: 'boolean' }, name: { type: 'string' } });
|
|
208
|
-
const client = createClient({ server: context.connection.server, fetch: context.fetch });
|
|
189
|
+
const client = createClient({ server: context.connection.server, fetch: context.fetch, userAgent });
|
|
209
190
|
const result = await browserLogin(client, {
|
|
210
191
|
clientName: options.name,
|
|
211
192
|
launchBrowser: !options['no-browser'],
|
|
@@ -240,6 +221,21 @@ async function logout(context) {
|
|
|
240
221
|
context.out(removed ? 'Logged out.' : 'No saved login to remove.');
|
|
241
222
|
}
|
|
242
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
|
+
|
|
243
239
|
async function rememberRole(context, role) {
|
|
244
240
|
const saved = context.connection.profile;
|
|
245
241
|
if (!saved || context.connection.fromEnvironment || saved.role === role) return;
|
|
@@ -254,13 +250,14 @@ async function whoami(context) {
|
|
|
254
250
|
const identity = await context.client.get('/api/session');
|
|
255
251
|
await rememberRole(context, identity.role);
|
|
256
252
|
if (context.global.output === 'json' || context.global.output === 'ndjson') {
|
|
257
|
-
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 } }));
|
|
258
254
|
return;
|
|
259
255
|
}
|
|
260
256
|
context.out(renderKeyValues([
|
|
261
257
|
['Server', context.connection.server], ['User', identity.user], ['Name', identity.name],
|
|
262
258
|
['Role', roleLabels[identity.role] ?? identity.role], ['Token ID', identity.sessionId],
|
|
263
259
|
['Token expires', context.connection.profile?.expiresAt],
|
|
260
|
+
['CLI', `${version}${context.versions?.latest ? ` (latest ${context.versions.latest})` : ''}`],
|
|
264
261
|
]));
|
|
265
262
|
}
|
|
266
263
|
|
|
@@ -299,6 +296,7 @@ function apiHint(error, context, command) {
|
|
|
299
296
|
? 'Check the agent ID with `sentinelctl devices lookup <part of the name>`.' : 'Not found; check the ID.';
|
|
300
297
|
}
|
|
301
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.`;
|
|
302
300
|
if (error.code === 'log_query_too_broad') {
|
|
303
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.';
|
|
304
302
|
}
|
|
@@ -412,7 +410,7 @@ export async function main(argv, {
|
|
|
412
410
|
select: global.select ? global.select.split(',').map((field) => field.trim()).filter(Boolean) : null,
|
|
413
411
|
hint: (text) => { if (!global.quiet) err(`hint: ${text}`); },
|
|
414
412
|
note: (text) => { if (!global.quiet && global.output === 'table') err(text); },
|
|
415
|
-
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; } }),
|
|
416
414
|
};
|
|
417
415
|
if (name === 'login') { await login(context, commandArgv); return 0; }
|
|
418
416
|
if (name === 'logout') { await logout(context); return 0; }
|
|
@@ -438,9 +436,11 @@ export async function main(argv, {
|
|
|
438
436
|
`Ask an admin to run \`sentinelctl admin grant <your-email> --role ${error.required}\`. Nothing was sent to the server.`, 3);
|
|
439
437
|
}
|
|
440
438
|
if (error instanceof ApiError) {
|
|
441
|
-
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;
|
|
442
440
|
return report(error, apiHint(error, context ?? { connection: {} }, command), exitCode);
|
|
443
441
|
}
|
|
444
442
|
return report(error, null, 1);
|
|
443
|
+
} finally {
|
|
444
|
+
if (context) await noticeUpdate(context, environment);
|
|
445
445
|
}
|
|
446
446
|
}
|