@intflow/sentinelctl 0.2.0 → 0.3.1
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 +6 -1
- package/package.json +1 -1
- package/src/commands.mjs +73 -15
- package/src/main.mjs +6 -2
package/README.md
CHANGED
|
@@ -52,10 +52,15 @@ sentinelctl logs search -l error -f # follow new errors (
|
|
|
52
52
|
sentinelctl logs search -q boom -o ndjson | jq .message
|
|
53
53
|
sentinelctl logs context -a <agent-id> -s app.service -t 2026-09-30T01:02:03.456Z
|
|
54
54
|
sentinelctl logs histogram -l error -r 24h
|
|
55
|
+
sentinelctl logs stats -l error -r 1h --by source # exact counts, one server-side query
|
|
56
|
+
sentinelctl logs stats -l error -r 24h --by agent --top 10
|
|
57
|
+
sentinelctl logs stats -a <agent-id> -r 7d --by message # message patterns, numbers collapsed to <N>
|
|
55
58
|
sentinelctl logs sources -r 24h
|
|
56
59
|
```
|
|
57
60
|
|
|
58
|
-
Windows are `15m`, `1h`, `6h`, `24h`, `7d`, or `--from`/`--to` up to 30 days. One page holds 50, 100 or 250 logs; when you ask for several pages (`--pages N`, `--all`) the CLI fetches 250 per request,
|
|
61
|
+
Windows are `15m`, `1h`, `6h`, `24h`, `7d`, or `--from`/`--to` up to 30 days. One page holds 50, 100 or 250 logs; when you ask for several pages (`--pages N`, at most 40, or `--all`) the CLI fetches 250 per request and stops at 10,000 logs. To answer "how many" or "which devices the most", use `logs stats`: it aggregates on the server and returns exact counts instead of downloading logs. `--fields` (raw fields) is admin-only and audited. `--follow` polls at most every 5 seconds.
|
|
62
|
+
|
|
63
|
+
Each user can run 2 log queries at a time, web and CLI combined; a third concurrent query gets 429 and GET requests retry automatically.
|
|
59
64
|
|
|
60
65
|
## Devices and metadata
|
|
61
66
|
|
package/package.json
CHANGED
package/src/commands.mjs
CHANGED
|
@@ -9,6 +9,7 @@ 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'];
|
|
11
11
|
const lifecycles = ['operational', 'all', 'active', 'maintenance', 'retired'];
|
|
12
|
+
const maxPages = 40;
|
|
12
13
|
const deviceSorts = ['attention', 'status', 'displayName', 'cpu', 'memory', 'disk', 'load', 'temperature', 'ageSeconds', 'role', 'site', 'group', 'incidentCount'];
|
|
13
14
|
|
|
14
15
|
const logFilterOptions = {
|
|
@@ -75,11 +76,14 @@ async function logsSearch(context, { options, positionals }) {
|
|
|
75
76
|
const limit = options.fields ? 50 : (options.limit ?? (paging ? 250 : 100));
|
|
76
77
|
const query = { ...criteria, limit, includeFields: options.fields ? 1 : undefined };
|
|
77
78
|
if (options.follow) return followLogs(context, query, options);
|
|
78
|
-
|
|
79
|
+
if (options.pages !== undefined && (options.pages < 1 || options.pages > maxPages)) {
|
|
80
|
+
throw new UsageError(`--pages must be between 1 and ${maxPages}.`, { hint: 'For counts over large windows use `sentinelctl logs stats` instead of fetching logs.' });
|
|
81
|
+
}
|
|
82
|
+
const pageBudget = options.all ? maxPages : (options.pages ?? 1);
|
|
79
83
|
const logs = [];
|
|
80
84
|
let cursor;
|
|
81
85
|
let last;
|
|
82
|
-
for (let page = 0; page <
|
|
86
|
+
for (let page = 0; page < pageBudget; page += 1) {
|
|
83
87
|
last = await context.client.get('/api/logs', { ...query, cursor });
|
|
84
88
|
logs.push(...last.logs);
|
|
85
89
|
if (!last.hasMore || !last.nextCursor) break;
|
|
@@ -92,12 +96,13 @@ async function logsSearch(context, { options, positionals }) {
|
|
|
92
96
|
}
|
|
93
97
|
if (!logs.length) context.hint(emptyLogHint(criteria));
|
|
94
98
|
else context.note(`${logs.length} logs · ${localTime(last.window.start)} → ${localTime(last.window.end)}`);
|
|
95
|
-
if (last.hasMore) context.hint(
|
|
99
|
+
if (last.hasMore && options.all) context.hint(`Stopped at the ${maxPages * 250}-log cap. Narrow the filters, or use \`sentinelctl logs stats\` for counts.`);
|
|
100
|
+
else if (last.hasMore) context.hint('More logs match. Continue with --pages N or --all (up to 10,000), narrow the filters, or count them with `sentinelctl logs stats`.');
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
async function followLogs(context, query, options) {
|
|
99
104
|
const seen = new Set();
|
|
100
|
-
const intervalMs = Math.max(
|
|
105
|
+
const intervalMs = Math.max(5, options.interval) * 1_000;
|
|
101
106
|
const followQuery = { ...query, range: '15m', from: undefined, to: undefined, limit: query.includeFields ? 50 : 250 };
|
|
102
107
|
let first = true;
|
|
103
108
|
context.err('Following new logs. Press Ctrl+C to stop.');
|
|
@@ -123,6 +128,26 @@ async function followLogs(context, query, options) {
|
|
|
123
128
|
}
|
|
124
129
|
}
|
|
125
130
|
|
|
131
|
+
|
|
132
|
+
const statsDimensions = ['source', 'agent', 'level', 'message'];
|
|
133
|
+
|
|
134
|
+
async function logsStats(context, { options, positionals }) {
|
|
135
|
+
const criteria = logCriteria(options, positionals);
|
|
136
|
+
const body = await context.client.get('/api/logs/stats', { ...criteria, by: options.by, top: options.top });
|
|
137
|
+
const label = { source: 'SOURCE', agent: 'DEVICE', level: 'LEVEL', message: 'MESSAGE PATTERN' }[body.criteria.by];
|
|
138
|
+
emit(context, context.global.output === 'ndjson' ? body.groups : body, () => renderTable(body.groups, [
|
|
139
|
+
{ header: 'COUNT', value: (group) => String(group.count) },
|
|
140
|
+
{ header: 'SHARE', value: (group) => (body.total ? `${((group.count / body.total) * 100).toFixed(1)}%` : '-') },
|
|
141
|
+
{ header: 'DEVICES', value: (group) => String(group.devices) },
|
|
142
|
+
...(body.criteria.by === 'agent' ? [{ header: 'AGENT ID', value: (group) => group.key, max: 36 }] : []),
|
|
143
|
+
{ header: label, value: (group) => (body.criteria.by === 'agent' ? group.displayName ?? group.host ?? group.key : group.key) },
|
|
144
|
+
], { width: context.width, wide: options.wide }));
|
|
145
|
+
context.note(`${body.total} logs from ${body.devices} devices · ${localTime(body.window.start)} → ${localTime(body.window.end)}`);
|
|
146
|
+
if (body.truncated) context.hint(`Showing the top ${body.groups.length} of ${body.groupCount} groups. Raise --top (max 100) or add filters.`);
|
|
147
|
+
if (body.approximate) context.hint('Message patterns are approximate on this server (collapse_nums is unavailable); narrow the window or filters for exact counts.');
|
|
148
|
+
if (!body.total) context.hint(emptyLogHint(criteria));
|
|
149
|
+
}
|
|
150
|
+
|
|
126
151
|
async function logsContext(context, { options }) {
|
|
127
152
|
if (!options.agent || !options.source || !options.timestamp) {
|
|
128
153
|
throw new UsageError('--agent, --source and --timestamp are all required.',
|
|
@@ -237,10 +262,24 @@ async function devicesLookup(context, { positionals }) {
|
|
|
237
262
|
async function devicesOverview(context, { options, positionals }) {
|
|
238
263
|
const agentId = requireAgentId(positionals);
|
|
239
264
|
const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}/overview`, { range: options.range });
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
265
|
+
const summary = Object.fromEntries(['metrics', 'logs'].map((product) => {
|
|
266
|
+
const history = body.history?.products?.[product]?.summary ?? {};
|
|
267
|
+
return [product, {
|
|
268
|
+
state: body.products?.[product]?.state ?? null,
|
|
269
|
+
outages: history.incidentCount ?? null,
|
|
270
|
+
delays: history.delayedCount ?? null,
|
|
271
|
+
missingMinutes: Number.isFinite(history.missingSeconds) ? Math.round(history.missingSeconds / 60) : null,
|
|
272
|
+
longestOutageMinutes: Number.isFinite(history.longestMissingSeconds) ? Math.round(history.longestMissingSeconds / 60) : null,
|
|
273
|
+
normalRatio: Number.isFinite(history.normalRatio) ? Math.round(history.normalRatio * 1000) / 10 : null,
|
|
274
|
+
}];
|
|
275
|
+
}));
|
|
276
|
+
const describe = (item) => `${item.state ?? '-'} · ${item.outages ?? '-'} ${item.outages === 1 ? 'outage' : 'outages'}, ${item.missingMinutes ?? '-'} min missing (longest ${item.longestOutageMinutes ?? '-'} min), normal ${item.normalRatio ?? '-'}%`;
|
|
277
|
+
emit(context, { summary, ...body }, () => renderKeyValues([
|
|
278
|
+
['Agent ID', body.agentId], ['Name', body.device?.displayName], ['Window', options.range],
|
|
279
|
+
['Metrics', describe(summary.metrics)], ['Logs', describe(summary.logs)],
|
|
280
|
+
['Attention', (body.attentionReasons ?? []).join(', ') || 'none'],
|
|
281
|
+
['Hypervisor', body.related?.parent ? `${body.related.parent.displayName} (${body.related.parent.agentId})` : body.related?.expectedParent ?? null],
|
|
282
|
+
['VMs', (body.related?.children ?? []).map((child) => child.displayName ?? child.agentId).join(', ') || null],
|
|
244
283
|
]));
|
|
245
284
|
}
|
|
246
285
|
|
|
@@ -514,16 +553,16 @@ const commands = [
|
|
|
514
553
|
{
|
|
515
554
|
group: 'logs', name: 'search', run: logsSearch, role: 'viewer', mutates: false,
|
|
516
555
|
summary: 'Search logs by window, device, severity, source and text; or follow live',
|
|
517
|
-
description: 'Newest first. Default 15m window and 100 logs. When fetching several pages, 250 logs are fetched per request.',
|
|
556
|
+
description: 'Newest first. Default 15m window and 100 logs. When fetching several pages, 250 logs are fetched per request, up to 10,000 in total. To count or rank logs, use `logs stats` instead.',
|
|
518
557
|
args: [{ name: 'text', optional: true, description: 'Same as -q (words are joined with spaces).' }],
|
|
519
558
|
options: {
|
|
520
559
|
...logFilterOptions,
|
|
521
560
|
limit: { alias: 'n', type: 'integer', choices: [50, 100, 250], description: 'Logs per page. Default 100 (250 when paging).' },
|
|
522
|
-
pages: { type: 'integer', placeholder: 'N', description:
|
|
523
|
-
all: { type: 'boolean', description: 'Fetch every page of the window
|
|
561
|
+
pages: { type: 'integer', placeholder: 'N', description: `Follow the cursor for up to N pages (1-${maxPages}).` },
|
|
562
|
+
all: { type: 'boolean', description: 'Fetch every page of the window, stopping at 10,000 logs.' },
|
|
524
563
|
fields: { type: 'boolean', description: 'Include raw fields (admin only, audited, 50 logs per page).' },
|
|
525
564
|
follow: { alias: 'f', type: 'boolean', description: 'Keep printing new logs (for humans; never ends, agents should not use it).' },
|
|
526
|
-
interval: { type: 'integer', default: 5, placeholder: 'seconds', description: 'Polling interval for --follow (min
|
|
565
|
+
interval: { type: 'integer', default: 5, placeholder: 'seconds', description: 'Polling interval for --follow (min 5).' },
|
|
527
566
|
wide: { alias: 'w', type: 'boolean', description: 'Do not truncate messages in table output.' },
|
|
528
567
|
},
|
|
529
568
|
roleFor: (options) => (options.fields ? 'admin' : 'viewer'),
|
|
@@ -536,6 +575,25 @@ const commands = [
|
|
|
536
575
|
],
|
|
537
576
|
output: 'logs[]: timestamp (UTC ISO), severity, priority, agentId, host, displayName, source, message (+fields). -o json wraps them as {criteria, window, count, hasMore, logs}.',
|
|
538
577
|
},
|
|
578
|
+
{
|
|
579
|
+
group: 'logs', name: 'stats', run: logsStats, role: 'viewer', mutates: false,
|
|
580
|
+
summary: 'Count and rank logs by source, device, severity or message pattern (one server-side query)',
|
|
581
|
+
description: 'Aggregates in VictoriaLogs instead of downloading logs, so counts are exact over the whole window. message groups by pattern with numbers collapsed to <N>. Cached for 60s on the server.',
|
|
582
|
+
args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
|
|
583
|
+
options: {
|
|
584
|
+
...logFilterOptions,
|
|
585
|
+
by: { alias: 'b', type: 'string', choices: statsDimensions, default: 'source', description: 'Group by source, agent (device), level or message pattern.' },
|
|
586
|
+
top: { type: 'integer', default: 20, placeholder: 'N', description: 'Groups to return (1-100).' },
|
|
587
|
+
wide: { alias: 'w', type: 'boolean', description: 'Do not truncate long keys in table output.' },
|
|
588
|
+
},
|
|
589
|
+
examples: [
|
|
590
|
+
'sentinelctl logs stats -l error -r 1h --by source',
|
|
591
|
+
'sentinelctl logs stats -l error -r 24h --by agent --top 10 -o json',
|
|
592
|
+
'sentinelctl logs stats -a <agent-id> -r 7d --by message',
|
|
593
|
+
'sentinelctl logs stats "connection refused" -r 24h --by agent',
|
|
594
|
+
],
|
|
595
|
+
output: '{criteria, window, total, devices, groupCount, truncated, approximate, groups[]: {key, count, devices (distinct agents), host?, displayName?}}.',
|
|
596
|
+
},
|
|
539
597
|
{
|
|
540
598
|
group: 'logs', name: 'context', run: logsContext, role: 'viewer', mutates: false,
|
|
541
599
|
summary: 'Logs just before and after one log, from the same device and source',
|
|
@@ -613,11 +671,11 @@ const commands = [
|
|
|
613
671
|
},
|
|
614
672
|
{
|
|
615
673
|
group: 'devices', name: 'overview', run: devicesOverview, role: 'viewer', mutates: false,
|
|
616
|
-
summary: '
|
|
674
|
+
summary: 'Reception state, outage counts and durations, and attention reasons for one device',
|
|
617
675
|
args: [{ name: 'agent-id' }],
|
|
618
676
|
options: { range: { alias: 'r', type: 'string', default: '24h', placeholder: 'window', description: 'History window, e.g. 24h or 7d.' } },
|
|
619
677
|
examples: ['sentinelctl devices overview <agent-id> -r 7d -o json'],
|
|
620
|
-
output: '{agentId, device, products
|
|
678
|
+
output: '{summary{metrics, logs}: {state, outages, delays, missingMinutes, longestOutageMinutes, normalRatio}, agentId, device, products, attentionReasons[], history, related{parent, children[], expectedParent}}.',
|
|
621
679
|
},
|
|
622
680
|
{
|
|
623
681
|
group: 'metadata', name: 'get', run: metadataGet, role: 'viewer', mutates: false,
|
|
@@ -754,7 +812,7 @@ const commands = [
|
|
|
754
812
|
|
|
755
813
|
export const catalog = commands;
|
|
756
814
|
export const groups = {
|
|
757
|
-
logs: 'Search logs
|
|
815
|
+
logs: 'Search, count and rank logs; context around a log; volume trends; sources',
|
|
758
816
|
devices: 'Device list, details, lookup, reception overview',
|
|
759
817
|
metadata: 'Device metadata: read, edit (editor), import/export (admin)',
|
|
760
818
|
sessions: 'Your sessions and CLI tokens',
|
package/src/main.mjs
CHANGED
|
@@ -137,10 +137,14 @@ const agentGuide = `Using sentinelctl from AI agents and scripts
|
|
|
137
137
|
- metrics: devices show <id> -r 24h -o json (series.cpu etc. are [epochSeconds, value])
|
|
138
138
|
- logs: logs search -a <id> -l error -r 24h -o ndjson
|
|
139
139
|
then logs context -a <id> -s <source> -t <timestamp> for surrounding lines
|
|
140
|
+
- counts/ranking: logs stats -l error -r 24h --by agent|source|message -o json
|
|
140
141
|
- trend: logs histogram -a <id> -r 7d -o json
|
|
141
142
|
6. Efficiency
|
|
142
|
-
-
|
|
143
|
-
|
|
143
|
+
- To answer "how many" or "which devices/sources the most", use logs stats: one server-side
|
|
144
|
+
aggregation with exact counts. Do not download thousands of logs to count them.
|
|
145
|
+
- Narrow the window and filters (-a, -s, -l, -q) first. Use --pages N (max 40) to take only what you
|
|
146
|
+
need; --all stops at 10,000 logs.
|
|
147
|
+
- Each user can run 2 log queries at a time (web and CLI combined); run commands one after another.
|
|
144
148
|
- Do not use --follow (it never ends). Repeat a -r 15m search instead.
|
|
145
149
|
- The server caches identical log searches for 5s, histograms and sources for 60s.
|
|
146
150
|
7. For commands that change data (metadata set/bulk/rollback/import, admin ...), run with --dry-run
|