@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 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, up to the server cap of 10,000. `--fields` (raw fields) is admin-only and audited.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intflow/sentinelctl",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Sentinel Fleet Console CLI: search device logs, inspect status and metrics, edit metadata and manage roles",
5
5
  "type": "module",
6
6
  "bin": {
package/src/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
- const maxPages = options.all ? 200 : Math.max(1, options.pages ?? 1);
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 < maxPages; page += 1) {
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('More logs match. Continue with --pages N or --all (up to 10,000), or narrow the filters.');
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(2, options.interval) * 1_000;
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
- emit(context, body, () => renderKeyValues([
241
- ['Agent ID', body.agentId], ['Name', body.device?.displayName], ['Metrics', body.products?.metrics?.state],
242
- ['Logs', body.products?.logs?.state], ['Attention', (body.attentionReasons ?? []).join(', ') || 'none'],
243
- ['Related', (body.related ?? []).map((item) => item.agentId ?? item).join(', ')],
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: 'Follow the cursor for up to N pages.' },
523
- all: { type: 'boolean', description: 'Fetch every page of the window (server cap: 10,000 logs).' },
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 2).' },
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: 'Metrics and log reception state, outage history and attention reasons',
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{metrics, logs}, attentionReasons[], history, related[]}.',
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, context around a log, volume trends, sources',
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
- - Narrow the window and filters (-a, -s, -l, -q) first. Use --pages N to take only what you need
143
- (--all fetches up to 10,000 logs).
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