@intflow/sentinelctl 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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.0",
4
4
  "description": "Sentinel Fleet Console CLI: search device logs, inspect status and metrics, edit metadata and manage roles",
5
5
  "type": "module",
6
6
  "bin": {
package/src/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,9 +262,22 @@ 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'],
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 ?? '-'} 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'],
243
281
  ['Related', (body.related ?? []).map((item) => item.agentId ?? item).join(', ')],
244
282
  ]));
245
283
  }
@@ -514,16 +552,16 @@ const commands = [
514
552
  {
515
553
  group: 'logs', name: 'search', run: logsSearch, role: 'viewer', mutates: false,
516
554
  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.',
555
+ 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
556
  args: [{ name: 'text', optional: true, description: 'Same as -q (words are joined with spaces).' }],
519
557
  options: {
520
558
  ...logFilterOptions,
521
559
  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).' },
560
+ pages: { type: 'integer', placeholder: 'N', description: `Follow the cursor for up to N pages (1-${maxPages}).` },
561
+ all: { type: 'boolean', description: 'Fetch every page of the window, stopping at 10,000 logs.' },
524
562
  fields: { type: 'boolean', description: 'Include raw fields (admin only, audited, 50 logs per page).' },
525
563
  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).' },
564
+ interval: { type: 'integer', default: 5, placeholder: 'seconds', description: 'Polling interval for --follow (min 5).' },
527
565
  wide: { alias: 'w', type: 'boolean', description: 'Do not truncate messages in table output.' },
528
566
  },
529
567
  roleFor: (options) => (options.fields ? 'admin' : 'viewer'),
@@ -536,6 +574,25 @@ const commands = [
536
574
  ],
537
575
  output: 'logs[]: timestamp (UTC ISO), severity, priority, agentId, host, displayName, source, message (+fields). -o json wraps them as {criteria, window, count, hasMore, logs}.',
538
576
  },
577
+ {
578
+ group: 'logs', name: 'stats', run: logsStats, role: 'viewer', mutates: false,
579
+ summary: 'Count and rank logs by source, device, severity or message pattern (one server-side query)',
580
+ 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.',
581
+ args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
582
+ options: {
583
+ ...logFilterOptions,
584
+ by: { alias: 'b', type: 'string', choices: statsDimensions, default: 'source', description: 'Group by source, agent (device), level or message pattern.' },
585
+ top: { type: 'integer', default: 20, placeholder: 'N', description: 'Groups to return (1-100).' },
586
+ wide: { alias: 'w', type: 'boolean', description: 'Do not truncate long keys in table output.' },
587
+ },
588
+ examples: [
589
+ 'sentinelctl logs stats -l error -r 1h --by source',
590
+ 'sentinelctl logs stats -l error -r 24h --by agent --top 10 -o json',
591
+ 'sentinelctl logs stats -a <agent-id> -r 7d --by message',
592
+ 'sentinelctl logs stats "connection refused" -r 24h --by agent',
593
+ ],
594
+ output: '{criteria, window, total, devices, groupCount, truncated, approximate, groups[]: {key, count, devices (distinct agents), host?, displayName?}}.',
595
+ },
539
596
  {
540
597
  group: 'logs', name: 'context', run: logsContext, role: 'viewer', mutates: false,
541
598
  summary: 'Logs just before and after one log, from the same device and source',
@@ -613,11 +670,11 @@ const commands = [
613
670
  },
614
671
  {
615
672
  group: 'devices', name: 'overview', run: devicesOverview, role: 'viewer', mutates: false,
616
- summary: 'Metrics and log reception state, outage history and attention reasons',
673
+ summary: 'Reception state, outage counts and durations, and attention reasons for one device',
617
674
  args: [{ name: 'agent-id' }],
618
675
  options: { range: { alias: 'r', type: 'string', default: '24h', placeholder: 'window', description: 'History window, e.g. 24h or 7d.' } },
619
676
  examples: ['sentinelctl devices overview <agent-id> -r 7d -o json'],
620
- output: '{agentId, device, products{metrics, logs}, attentionReasons[], history, related[]}.',
677
+ output: '{summary{metrics, logs}: {state, outages, delays, missingMinutes, longestOutageMinutes, normalRatio}, agentId, device, products, attentionReasons[], history, related[]}.',
621
678
  },
622
679
  {
623
680
  group: 'metadata', name: 'get', run: metadataGet, role: 'viewer', mutates: false,
@@ -754,7 +811,7 @@ const commands = [
754
811
 
755
812
  export const catalog = commands;
756
813
  export const groups = {
757
- logs: 'Search logs, context around a log, volume trends, sources',
814
+ logs: 'Search, count and rank logs; context around a log; volume trends; sources',
758
815
  devices: 'Device list, details, lookup, reception overview',
759
816
  metadata: 'Device metadata: read, edit (editor), import/export (admin)',
760
817
  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