@intflow/sentinelctl 0.1.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 +67 -43
- package/package.json +10 -3
- package/src/args.mjs +42 -11
- package/src/client.mjs +4 -8
- package/src/commands.mjs +603 -247
- package/src/credentials.mjs +7 -8
- package/src/format.mjs +4 -6
- package/src/login.mjs +1 -2
- package/src/main.mjs +291 -67
package/src/commands.mjs
CHANGED
|
@@ -1,29 +1,43 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { keyValue,
|
|
2
|
+
import { keyValue, UsageError } from './args.mjs';
|
|
3
3
|
import { ageText, localTime, percentText, renderKeyValues, renderTable, singleLine } from './format.mjs';
|
|
4
4
|
|
|
5
|
-
const roleLabels = { viewer: '
|
|
6
|
-
const outputChoices = ['table', 'json', 'ndjson'];
|
|
5
|
+
export const roleLabels = { viewer: 'viewer (read-only)', editor: 'editor', admin: 'admin' };
|
|
6
|
+
export const outputChoices = ['table', 'json', 'ndjson'];
|
|
7
7
|
const logRanges = ['15m', '1h', '6h', '24h', '7d'];
|
|
8
|
+
const deviceRanges = ['30m', '1h', '6h', '24h', '7d', '30d'];
|
|
8
9
|
const logLevels = ['emergency', 'critical', 'error', 'warning', 'info', 'debug'];
|
|
10
|
+
const deviceStatuses = ['all', 'online', 'delayed', 'offline', 'unknown', 'maintenance', 'retired'];
|
|
11
|
+
const lifecycles = ['operational', 'all', 'active', 'maintenance', 'retired'];
|
|
12
|
+
const maxPages = 40;
|
|
13
|
+
const deviceSorts = ['attention', 'status', 'displayName', 'cpu', 'memory', 'disk', 'load', 'temperature', 'ageSeconds', 'role', 'site', 'group', 'incidentCount'];
|
|
9
14
|
|
|
10
15
|
const logFilterOptions = {
|
|
11
|
-
query: { alias: 'q', type: 'string' },
|
|
12
|
-
agent: { alias: 'a', type: 'string' },
|
|
13
|
-
level: { alias: 'l', type: 'string', choices: logLevels },
|
|
14
|
-
source: { alias: 's', type: 'string' },
|
|
15
|
-
range: { alias: 'r', type: 'string', choices: logRanges },
|
|
16
|
-
from: { type: 'string' },
|
|
17
|
-
to: { type: 'string' },
|
|
16
|
+
query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Case-insensitive text in the message. Positional words are appended.' },
|
|
17
|
+
agent: { alias: 'a', type: 'string', placeholder: 'id', description: 'Exact agent ID (find it with `devices lookup`).' },
|
|
18
|
+
level: { alias: 'l', type: 'string', choices: logLevels, description: 'This severity and above (error includes emergency/alert/critical).' },
|
|
19
|
+
source: { alias: 's', type: 'string', placeholder: 'source', description: 'Exact source: systemd unit, container name, syslog identifier (see `logs sources`).' },
|
|
20
|
+
range: { alias: 'r', type: 'string', choices: logRanges, description: 'Relative window. Default 15m when --from/--to are absent.' },
|
|
21
|
+
from: { type: 'string', placeholder: 'ISO', description: 'Absolute window start, e.g. 2026-09-30T00:00:00+09:00. Requires --to; at most 30 days.' },
|
|
22
|
+
to: { type: 'string', placeholder: 'ISO', description: 'Absolute window end.' },
|
|
18
23
|
};
|
|
19
24
|
|
|
20
25
|
function logCriteria(options, positionals) {
|
|
21
26
|
const text = [options.query, ...positionals].filter(Boolean).join(' ');
|
|
22
|
-
if ((options.from && !options.to) || (!options.from && options.to))
|
|
23
|
-
|
|
27
|
+
if ((options.from && !options.to) || (!options.from && options.to)) {
|
|
28
|
+
throw new UsageError('--from and --to must be given together.', { hint: 'For a recent window use --range, e.g. -r 1h.' });
|
|
29
|
+
}
|
|
30
|
+
if (options.from && options.range) throw new UsageError('--range cannot be combined with --from/--to.');
|
|
31
|
+
for (const [name, value] of [['from', options.from], ['to', options.to]]) {
|
|
32
|
+
if (value && !Number.isFinite(Date.parse(value))) {
|
|
33
|
+
throw new UsageError(`Cannot parse --${name}: ${value}`, { hint: 'Use ISO 8601, e.g. 2026-09-30T09:00:00+09:00.' });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
24
36
|
return {
|
|
25
37
|
q: text || undefined, agentId: options.agent, level: options.level, source: options.source,
|
|
26
|
-
range: options.from ? undefined : (options.range ?? '15m'),
|
|
38
|
+
range: options.from ? undefined : (options.range ?? '15m'),
|
|
39
|
+
from: options.from ? new Date(options.from).toISOString() : undefined,
|
|
40
|
+
to: options.to ? new Date(options.to).toISOString() : undefined,
|
|
27
41
|
};
|
|
28
42
|
}
|
|
29
43
|
|
|
@@ -35,63 +49,68 @@ function emit(context, value, table) {
|
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
const logColumns = [
|
|
38
|
-
{ header: '
|
|
39
|
-
{ header: '
|
|
40
|
-
{ header: '
|
|
41
|
-
{ header: '
|
|
42
|
-
{ header: '
|
|
52
|
+
{ header: 'TIME', value: (log) => localTime(log.timestamp), max: 19 },
|
|
53
|
+
{ header: 'LEVEL', value: (log) => log.severity, max: 9 },
|
|
54
|
+
{ header: 'DEVICE', value: (log) => log.displayName ?? log.host ?? log.agentId, max: 24 },
|
|
55
|
+
{ header: 'SOURCE', value: (log) => log.source, max: 22 },
|
|
56
|
+
{ header: 'MESSAGE', value: (log) => log.message },
|
|
43
57
|
];
|
|
44
58
|
|
|
45
59
|
function logKey(log) {
|
|
46
60
|
return `${log.timestamp}\n${log.agentId}\n${log.source}\n${log.message}`;
|
|
47
61
|
}
|
|
48
62
|
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const criteria =
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
+
function emptyLogHint(criteria) {
|
|
64
|
+
const tips = [];
|
|
65
|
+
if (criteria.range && criteria.range !== '7d') tips.push('widen the window (-r 24h or -r 7d)');
|
|
66
|
+
if (criteria.q) tips.push('shorten or drop the search text');
|
|
67
|
+
if (criteria.source) tips.push('check source names with `sentinelctl logs sources -r 24h`');
|
|
68
|
+
if (criteria.agentId) tips.push('check the agent ID with `sentinelctl devices lookup <name>`');
|
|
69
|
+
if (criteria.level) tips.push('drop -l');
|
|
70
|
+
return tips.length ? `No logs matched. Try: ${tips.join('; ')}.` : 'No logs matched.';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function logsSearch(context, { options, positionals }) {
|
|
74
|
+
const criteria = logCriteria(options, positionals);
|
|
75
|
+
const paging = options.all || options.pages > 1;
|
|
76
|
+
const limit = options.fields ? 50 : (options.limit ?? (paging ? 250 : 100));
|
|
77
|
+
const query = { ...criteria, limit, includeFields: options.fields ? 1 : undefined };
|
|
78
|
+
if (options.follow) return followLogs(context, query, options);
|
|
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);
|
|
63
83
|
const logs = [];
|
|
64
84
|
let cursor;
|
|
65
85
|
let last;
|
|
66
|
-
for (let page = 0; page <
|
|
67
|
-
last = await context.client.get('/api/logs', { ...
|
|
86
|
+
for (let page = 0; page < pageBudget; page += 1) {
|
|
87
|
+
last = await context.client.get('/api/logs', { ...query, cursor });
|
|
68
88
|
logs.push(...last.logs);
|
|
69
89
|
if (!last.hasMore || !last.nextCursor) break;
|
|
70
90
|
cursor = last.nextCursor;
|
|
71
91
|
}
|
|
72
92
|
if (context.global.output === 'json') {
|
|
73
93
|
context.out(JSON.stringify({ criteria: last.criteria, window: last.window, count: logs.length, hasMore: last.hasMore, logs }, null, 2));
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
emit(context, logs, () => renderTable(logs, logColumns, { width: context.width, wide: options.wide }));
|
|
77
|
-
if (context.global.output === 'table') {
|
|
78
|
-
context.err(`${logs.length}건${last.hasMore ? ' · 더 있음 (--pages N 또는 --all)' : ''} · ${localTime(last.window.start)} ~ ${localTime(last.window.end)}`);
|
|
94
|
+
} else {
|
|
95
|
+
emit(context, logs, () => renderTable(logs, logColumns, { width: context.width, wide: options.wide }));
|
|
79
96
|
}
|
|
97
|
+
if (!logs.length) context.hint(emptyLogHint(criteria));
|
|
98
|
+
else context.note(`${logs.length} logs · ${localTime(last.window.start)} → ${localTime(last.window.end)}`);
|
|
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`.');
|
|
80
101
|
}
|
|
81
102
|
|
|
82
|
-
async function followLogs(context,
|
|
103
|
+
async function followLogs(context, query, options) {
|
|
83
104
|
const seen = new Set();
|
|
84
|
-
const intervalMs = Math.max(
|
|
85
|
-
|
|
86
|
-
// 간격 사이에 더 많은 로그가 들어와 일부를 건너뛴 것이므로 알린다.
|
|
87
|
-
const followCriteria = { ...criteria, range: '15m', from: undefined, to: undefined, limit: criteria.includeFields ? 50 : 250 };
|
|
105
|
+
const intervalMs = Math.max(5, options.interval) * 1_000;
|
|
106
|
+
const followQuery = { ...query, range: '15m', from: undefined, to: undefined, limit: query.includeFields ? 50 : 250 };
|
|
88
107
|
let first = true;
|
|
89
|
-
context.err('
|
|
108
|
+
context.err('Following new logs. Press Ctrl+C to stop.');
|
|
90
109
|
while (!context.signal?.aborted) {
|
|
91
|
-
const body = await context.client.get('/api/logs',
|
|
110
|
+
const body = await context.client.get('/api/logs', followQuery);
|
|
92
111
|
const fresh = body.logs.filter((log) => !seen.has(logKey(log))).reverse();
|
|
93
112
|
if (!first && body.hasMore && fresh.length === body.logs.length) {
|
|
94
|
-
context.err(
|
|
113
|
+
context.err(`warning: more than ${followQuery.limit} logs arrived within ${options.interval}s and some were skipped. Narrow with -a/-s/-l/-q or lower --interval.`);
|
|
95
114
|
}
|
|
96
115
|
for (const log of body.logs) seen.add(logKey(log));
|
|
97
116
|
if (seen.size > 20_000) for (const key of [...seen].slice(0, seen.size - 10_000)) seen.delete(key);
|
|
@@ -109,62 +128,75 @@ async function followLogs(context, criteria, options) {
|
|
|
109
128
|
}
|
|
110
129
|
}
|
|
111
130
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
|
|
151
|
+
async function logsContext(context, { options }) {
|
|
152
|
+
if (!options.agent || !options.source || !options.timestamp) {
|
|
153
|
+
throw new UsageError('--agent, --source and --timestamp are all required.',
|
|
154
|
+
{ hint: 'Copy agentId, source and timestamp from `logs search -o json` output.' });
|
|
155
|
+
}
|
|
118
156
|
const body = await context.client.get('/api/logs/context', {
|
|
119
157
|
agentId: options.agent, source: options.source, timestamp: options.timestamp,
|
|
120
158
|
before: options.before, after: options.after, windowSeconds: options.window,
|
|
121
159
|
});
|
|
122
160
|
emit(context, context.global.output === 'ndjson' ? body.logs : body,
|
|
123
161
|
() => renderTable(body.logs, logColumns, { width: context.width, wide: options.wide }));
|
|
162
|
+
if (!body.count) context.hint('No logs from that device and source around the timestamp. Try a larger --window (up to 3600).');
|
|
124
163
|
}
|
|
125
164
|
|
|
126
|
-
async function logsHistogram(context,
|
|
127
|
-
const { options, positionals } = parseArguments(argv, logFilterOptions);
|
|
165
|
+
async function logsHistogram(context, { options, positionals }) {
|
|
128
166
|
const body = await context.client.get('/api/logs/histogram', logCriteria(options, positionals));
|
|
129
167
|
emit(context, context.global.output === 'ndjson' ? body.buckets : body, () => {
|
|
130
168
|
const peak = Math.max(1, ...body.buckets.map((bucket) => bucket.total));
|
|
131
169
|
return renderTable(body.buckets, [
|
|
132
|
-
{ header: '
|
|
133
|
-
{ header: '
|
|
134
|
-
{ header: '
|
|
170
|
+
{ header: 'TIME', value: (bucket) => localTime(bucket.at) },
|
|
171
|
+
{ header: 'TOTAL', value: (bucket) => String(bucket.total) },
|
|
172
|
+
{ header: 'ERRORS', value: (bucket) => String(bucket.errors) },
|
|
135
173
|
{ header: '', value: (bucket) => '█'.repeat(Math.round((bucket.total / peak) * 30)) },
|
|
136
174
|
], { width: context.width, wide: true });
|
|
137
175
|
});
|
|
176
|
+
context.note(`${body.total} logs in ${body.buckets.length} buckets of ${body.stepSeconds}s`);
|
|
138
177
|
}
|
|
139
178
|
|
|
140
|
-
async function logsSources(context,
|
|
141
|
-
const { options } = parseArguments(argv, { range: { alias: 'r', type: 'string', choices: logRanges, default: '24h' } });
|
|
179
|
+
async function logsSources(context, { options }) {
|
|
142
180
|
const body = await context.client.get('/api/logs/sources', { range: options.range });
|
|
143
181
|
emit(context, context.global.output === 'ndjson' ? body.sources : body, () => renderTable(body.sources, [
|
|
144
|
-
{ header: '
|
|
145
|
-
{ header: '
|
|
182
|
+
{ header: 'SOURCE', value: (source) => source.value },
|
|
183
|
+
{ header: 'COUNT', value: (source) => String(source.count) },
|
|
146
184
|
], { width: context.width }));
|
|
185
|
+
context.note(`Top sources in a sample of the latest ${body.sampleSize} logs.`);
|
|
147
186
|
}
|
|
148
187
|
|
|
149
188
|
const deviceColumns = [
|
|
150
|
-
{ header: '
|
|
151
|
-
{ header: '
|
|
152
|
-
{ header: '
|
|
153
|
-
{ header: '
|
|
189
|
+
{ header: 'AGENT ID', value: (device) => device.agentId, max: 28 },
|
|
190
|
+
{ header: 'NAME', value: (device) => device.displayName ?? device.host, max: 28 },
|
|
191
|
+
{ header: 'STATUS', value: (device) => device.status, max: 11 },
|
|
192
|
+
{ header: 'SITE', value: (device) => device.site, max: 14 },
|
|
154
193
|
{ header: 'CPU', value: (device) => percentText(device.cpu), max: 5 },
|
|
155
|
-
{ header: '
|
|
156
|
-
{ header: '
|
|
157
|
-
{ header: '
|
|
194
|
+
{ header: 'MEM', value: (device) => percentText(device.memory), max: 5 },
|
|
195
|
+
{ header: 'DISK', value: (device) => percentText(device.disk), max: 5 },
|
|
196
|
+
{ header: 'LAST SEEN', value: (device) => ageText(device.ageSeconds) },
|
|
158
197
|
];
|
|
159
198
|
|
|
160
|
-
async function devicesList(context,
|
|
161
|
-
const { options, positionals } = parseArguments(argv, {
|
|
162
|
-
query: { alias: 'q', type: 'string' }, status: { type: 'string' }, lifecycle: { type: 'string' },
|
|
163
|
-
role: { type: 'string' }, site: { type: 'string' }, group: { type: 'string' }, maintainer: { type: 'string' },
|
|
164
|
-
fleet: { type: 'string' }, focus: { type: 'string' }, inventory: { type: 'string' },
|
|
165
|
-
label: { type: 'string', multiple: true }, sort: { type: 'string' }, order: { type: 'string', choices: ['asc', 'desc'] },
|
|
166
|
-
page: { type: 'integer', default: 1 }, 'page-size': { type: 'integer', default: 50 }, all: { type: 'boolean' },
|
|
167
|
-
});
|
|
199
|
+
async function devicesList(context, { options, positionals }) {
|
|
168
200
|
for (const label of options.label ?? []) keyValue(label, '--label');
|
|
169
201
|
const query = {
|
|
170
202
|
query: [options.query, ...positionals].filter(Boolean).join(' ') || undefined,
|
|
@@ -173,124 +205,147 @@ async function devicesList(context, argv) {
|
|
|
173
205
|
labels: options.label?.length ? JSON.stringify(options.label) : undefined, sort: options.sort, order: options.order,
|
|
174
206
|
pageSize: options.all ? 100 : options['page-size'],
|
|
175
207
|
};
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
208
|
+
const first = await context.client.get('/api/devices', { ...query, page: options.all ? 1 : options.page });
|
|
209
|
+
const devices = [...first.devices];
|
|
210
|
+
if (options.all && first.totalPages > 1) {
|
|
211
|
+
const pages = Array.from({ length: first.totalPages - 1 }, (_, index) => index + 2);
|
|
212
|
+
const results = new Map();
|
|
213
|
+
for (let index = 0; index < pages.length; index += 4) {
|
|
214
|
+
await Promise.all(pages.slice(index, index + 4).map(async (page) => {
|
|
215
|
+
results.set(page, (await context.client.get('/api/devices', { ...query, page })).devices);
|
|
216
|
+
}));
|
|
217
|
+
}
|
|
218
|
+
for (const page of pages) devices.push(...results.get(page));
|
|
182
219
|
}
|
|
183
220
|
if (context.global.output === 'json') {
|
|
184
|
-
context.out(JSON.stringify({ matched:
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
emit(context, devices, () => renderTable(devices, deviceColumns, { width: context.width }));
|
|
188
|
-
if (context.global.output === 'table') {
|
|
189
|
-
context.err(`${devices.length}/${body.matched}대${options.all ? '' : ` · ${body.page}/${body.totalPages} 페이지`}${body.stale ? ` · 오래된 데이터: ${body.staleReason ?? ''}` : ''}`);
|
|
221
|
+
context.out(JSON.stringify({ matched: first.matched, counts: first.counts, stale: first.stale, staleReason: first.staleReason, devices }, null, 2));
|
|
222
|
+
} else {
|
|
223
|
+
emit(context, devices, () => renderTable(devices, deviceColumns, { width: context.width }));
|
|
190
224
|
}
|
|
225
|
+
context.note(`${devices.length} of ${first.matched} devices${options.all ? '' : ` · page ${first.page}/${first.totalPages}`}`);
|
|
226
|
+
if (first.stale) context.hint(`Metrics are stale (${first.staleReason ?? 'metrics query failed'}). Check \`sentinelctl health\`.`);
|
|
227
|
+
if (!devices.length) context.hint('No devices matched. Add --lifecycle all to include maintenance/retired devices, or shorten the search text.');
|
|
228
|
+
else if (!options.all && first.totalPages > first.page) context.hint(`Next page: --page ${first.page + 1}; everything: --all`);
|
|
191
229
|
}
|
|
192
230
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
231
|
+
function requireAgentId(positionals) {
|
|
232
|
+
if (!positionals[0]) throw new UsageError('An agent ID is required.', { hint: 'Find it with `sentinelctl devices lookup <part of the name>`.' });
|
|
233
|
+
return positionals[0];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function devicesShow(context, { options, positionals }) {
|
|
237
|
+
const agentId = requireAgentId(positionals);
|
|
238
|
+
const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}`, { range: options.range, refresh: options.refresh ? 1 : undefined });
|
|
198
239
|
const device = body.device;
|
|
199
240
|
emit(context, body, () => renderKeyValues([
|
|
200
|
-
['
|
|
201
|
-
['
|
|
202
|
-
['CPU', percentText(device.cpu)], ['
|
|
203
|
-
['
|
|
204
|
-
['
|
|
205
|
-
['
|
|
241
|
+
['Agent ID', device.agentId], ['Name', device.displayName], ['Host', device.host],
|
|
242
|
+
['Status', device.observedStatus ?? device.status], ['Last seen', ageText(device.ageSeconds)],
|
|
243
|
+
['CPU', percentText(device.cpu)], ['Memory', percentText(device.memory)], ['Disk', percentText(device.disk)],
|
|
244
|
+
['Load', device.load], ['Temperature', device.temperature], ['Role', device.metadata?.role ?? device.role],
|
|
245
|
+
['Maintainer', device.metadata?.maintainer], ['Lifecycle', device.metadata?.lifecycle], ['Notes', device.metadata?.description],
|
|
246
|
+
['Data', body.stale ? `stale (${body.staleReason ?? ''})` : 'fresh'],
|
|
206
247
|
]));
|
|
248
|
+
if (context.global.output === 'table') context.hint(`Time series (${body.range}, ${body.stepSeconds}s step) are in \`series\` with -o json.`);
|
|
207
249
|
}
|
|
208
250
|
|
|
209
|
-
async function devicesLookup(context,
|
|
210
|
-
|
|
251
|
+
async function devicesLookup(context, { positionals }) {
|
|
252
|
+
if (!positionals.length) throw new UsageError('A search term is required.', { hint: 'Example: sentinelctl devices lookup edge-01' });
|
|
211
253
|
const body = await context.client.get('/api/devices/lookup', { query: positionals.join(' ') });
|
|
212
254
|
emit(context, context.global.output === 'ndjson' ? body.devices : body, () => renderTable(body.devices, [
|
|
213
|
-
{ header: '
|
|
214
|
-
{ header: '
|
|
215
|
-
{ header: '
|
|
255
|
+
{ header: 'AGENT ID', value: (device) => device.agentId },
|
|
256
|
+
{ header: 'NAME', value: (device) => device.displayName },
|
|
257
|
+
{ header: 'HOST', value: (device) => device.host },
|
|
216
258
|
], { width: context.width }));
|
|
259
|
+
if (!body.devices.length) context.hint('No device matched. Try a shorter part of the name, host or ID.');
|
|
217
260
|
}
|
|
218
261
|
|
|
219
|
-
async function devicesOverview(context,
|
|
220
|
-
const
|
|
221
|
-
const [agentId] = positionals;
|
|
222
|
-
if (!agentId) throw new UsageError('에이전트 ID가 필요합니다.');
|
|
262
|
+
async function devicesOverview(context, { options, positionals }) {
|
|
263
|
+
const agentId = requireAgentId(positionals);
|
|
223
264
|
const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}/overview`, { range: options.range });
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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'],
|
|
281
|
+
['Related', (body.related ?? []).map((item) => item.agentId ?? item).join(', ')],
|
|
228
282
|
]));
|
|
229
283
|
}
|
|
230
284
|
|
|
231
285
|
async function health(context) {
|
|
232
286
|
const body = await context.client.get('/api/health');
|
|
233
287
|
emit(context, body, () => [
|
|
234
|
-
renderKeyValues([['
|
|
235
|
-
['
|
|
288
|
+
renderKeyValues([['Overall', body.status], ['Version', `${body.application?.version} (${body.application?.commit})`],
|
|
289
|
+
['Observer', body.observer?.status ?? body.observer?.state]]),
|
|
236
290
|
'',
|
|
237
291
|
renderTable(Object.entries(body.components ?? {}), [
|
|
238
|
-
{ header: '
|
|
239
|
-
{ header: '
|
|
240
|
-
{ header: '
|
|
241
|
-
{ header: '
|
|
292
|
+
{ header: 'COMPONENT', value: ([name]) => name },
|
|
293
|
+
{ header: 'STATUS', value: ([, component]) => component.status },
|
|
294
|
+
{ header: 'LATENCY', value: ([, component]) => (Number.isFinite(component.latencyMs) ? `${component.latencyMs}ms` : '-') },
|
|
295
|
+
{ header: 'ERROR', value: ([, component]) => component.error ?? '' },
|
|
242
296
|
], { width: context.width }),
|
|
243
297
|
].join('\n'));
|
|
298
|
+
if (body.status !== 'ok') context.hint('Some backends are unhealthy; results may be stale or empty.');
|
|
244
299
|
}
|
|
245
300
|
|
|
246
|
-
async function metadataGet(context,
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
const body = await context.client.get(`/api/devices/${encodeURIComponent(positionals[0])}/metadata`);
|
|
301
|
+
async function metadataGet(context, { positionals }) {
|
|
302
|
+
const agentId = requireAgentId(positionals);
|
|
303
|
+
const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}/metadata`);
|
|
250
304
|
emit(context, body, () => renderKeyValues([
|
|
251
|
-
['
|
|
252
|
-
['
|
|
253
|
-
['
|
|
254
|
-
['
|
|
255
|
-
['
|
|
305
|
+
['Agent ID', body.agentId], ['Revision', body.revision], ['Name', body.displayName], ['Role', body.role],
|
|
306
|
+
['Maintainer', body.maintainer], ['Contact', body.maintainerContact], ['Lifecycle', body.lifecycle],
|
|
307
|
+
['Maintenance until', body.maintenanceUntil], ['Maintenance reason', body.maintenanceReason], ['Notes', body.description],
|
|
308
|
+
['Labels', Object.entries(body.labels ?? {}).map(([key, value]) => `${key}=${value}`).join(', ')],
|
|
309
|
+
['Updated', body.updatedAt],
|
|
256
310
|
]));
|
|
257
311
|
}
|
|
258
312
|
|
|
259
|
-
async function metadataHistory(context,
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
const body = await context.client.get(`/api/devices/${encodeURIComponent(positionals[0])}/metadata/history`, { limit: options.limit });
|
|
313
|
+
async function metadataHistory(context, { options, positionals }) {
|
|
314
|
+
const agentId = requireAgentId(positionals);
|
|
315
|
+
const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}/metadata/history`, { limit: options.limit });
|
|
263
316
|
emit(context, context.global.output === 'ndjson' ? body.history : body, () => renderTable(body.history, [
|
|
264
|
-
{ header: '
|
|
265
|
-
{ header: '
|
|
266
|
-
{ header: '
|
|
267
|
-
{ header: '
|
|
317
|
+
{ header: 'REVISION', value: (entry) => `${entry.beforeRevision}→${entry.afterRevision}` },
|
|
318
|
+
{ header: 'TIME', value: (entry) => localTime(entry.createdAt) },
|
|
319
|
+
{ header: 'ACTOR', value: (entry) => entry.actor, max: 32 },
|
|
320
|
+
{ header: 'CHANGED', value: (entry) => entry.changedFields.join(', ') },
|
|
268
321
|
], { width: context.width }));
|
|
322
|
+
if (body.history.length) context.hint(`Restore a revision with \`sentinelctl metadata rollback ${agentId} --to <revision>\`.`);
|
|
269
323
|
}
|
|
270
324
|
|
|
271
325
|
const metadataFieldOptions = {
|
|
272
326
|
'display-name': 'displayName', role: 'role', maintainer: 'maintainer', contact: 'maintainerContact',
|
|
273
327
|
description: 'description', lifecycle: 'lifecycle', 'maintenance-until': 'maintenanceUntil', 'maintenance-reason': 'maintenanceReason',
|
|
274
328
|
};
|
|
329
|
+
const metadataEditOptions = {
|
|
330
|
+
'display-name': { type: 'string', placeholder: 'name', description: 'Display name (max 80 chars).' },
|
|
331
|
+
role: { type: 'string', placeholder: 'role', description: 'Device role (max 80 chars).' },
|
|
332
|
+
maintainer: { type: 'string', placeholder: 'name', description: 'Maintainer (max 100 chars).' },
|
|
333
|
+
contact: { type: 'string', placeholder: 'contact', description: 'Maintainer contact (max 160 chars).' },
|
|
334
|
+
description: { type: 'string', placeholder: 'text', description: 'Operational notes (max 500 chars).' },
|
|
335
|
+
lifecycle: { type: 'string', choices: ['active', 'maintenance', 'retired'], description: 'Lifecycle. Pair maintenance with --maintenance-until.' },
|
|
336
|
+
'maintenance-until': { type: 'string', placeholder: 'ISO', description: 'Planned end of maintenance; the device counts as active afterwards.' },
|
|
337
|
+
'maintenance-reason': { type: 'string', placeholder: 'text', description: 'Maintenance reason (max 200 chars).' },
|
|
338
|
+
label: { type: 'string', multiple: true, placeholder: 'k=v', description: 'Add or change a label. Other labels are kept.' },
|
|
339
|
+
'remove-label': { type: 'string', multiple: true, placeholder: 'key', description: 'Remove a label.' },
|
|
340
|
+
clear: { type: 'string', multiple: true, choices: Object.keys(metadataFieldOptions), description: 'Clear a field.' },
|
|
341
|
+
};
|
|
275
342
|
|
|
276
|
-
|
|
277
|
-
const { options, positionals } = parseArguments(argv, {
|
|
278
|
-
...Object.fromEntries(Object.keys(metadataFieldOptions).map((name) => [name, { type: 'string' }])),
|
|
279
|
-
label: { type: 'string', multiple: true }, 'remove-label': { type: 'string', multiple: true },
|
|
280
|
-
clear: { type: 'string', multiple: true },
|
|
281
|
-
});
|
|
282
|
-
const [agentId] = positionals;
|
|
283
|
-
if (!agentId) throw new UsageError('에이전트 ID가 필요합니다.');
|
|
284
|
-
const path = `/api/devices/${encodeURIComponent(agentId)}/metadata`;
|
|
285
|
-
const current = await context.client.get(path);
|
|
343
|
+
function metadataPatch(options, current) {
|
|
286
344
|
const patch = { revision: current.revision };
|
|
287
345
|
for (const [option, field] of Object.entries(metadataFieldOptions)) {
|
|
288
346
|
if (options[option] !== undefined) patch[field] = options[option];
|
|
289
347
|
}
|
|
290
|
-
for (const option of options.clear ?? [])
|
|
291
|
-
if (!metadataFieldOptions[option]) throw new UsageError(`--clear에는 ${Object.keys(metadataFieldOptions).join(', ')} 중 하나를 지정하세요.`);
|
|
292
|
-
patch[metadataFieldOptions[option]] = null;
|
|
293
|
-
}
|
|
348
|
+
for (const option of options.clear ?? []) patch[metadataFieldOptions[option]] = null;
|
|
294
349
|
if (options.label?.length || options['remove-label']?.length) {
|
|
295
350
|
const labels = { ...(current.labels ?? {}) };
|
|
296
351
|
for (const item of options.label ?? []) {
|
|
@@ -300,119 +355,192 @@ async function metadataSet(context, argv) {
|
|
|
300
355
|
for (const key of options['remove-label'] ?? []) delete labels[key];
|
|
301
356
|
patch.labels = labels;
|
|
302
357
|
}
|
|
303
|
-
if (Object.keys(patch).length === 1)
|
|
358
|
+
if (Object.keys(patch).length === 1) {
|
|
359
|
+
throw new UsageError('Nothing to change. Give at least one field option.', { hint: 'Example: --display-name "DB 1" --label site=seoul (all fields: --help)' });
|
|
360
|
+
}
|
|
361
|
+
return patch;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function metadataSet(context, { options, positionals }) {
|
|
365
|
+
const agentId = requireAgentId(positionals);
|
|
366
|
+
metadataPatch(options, { revision: 0, labels: {} });
|
|
367
|
+
const path = `/api/devices/${encodeURIComponent(agentId)}/metadata`;
|
|
368
|
+
const current = await context.client.get(path);
|
|
369
|
+
const patch = metadataPatch(options, current);
|
|
370
|
+
if (options['dry-run']) {
|
|
371
|
+
const preview = await context.client.post('/api/devices/metadata/bulk/preview', { updates: [{ agentId, ...patch }] });
|
|
372
|
+
emit(context, preview, () => JSON.stringify(preview.results[0]?.changes ?? {}, null, 2));
|
|
373
|
+
context.hint('Dry run only. Run again without --dry-run to apply.');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
304
376
|
const updated = await context.client.patch(path, patch);
|
|
305
|
-
emit(context, updated, () =>
|
|
377
|
+
emit(context, updated, () => `Saved metadata for ${agentId} as revision ${updated.revision}.`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function metadataBulk(context, { options, positionals }) {
|
|
381
|
+
const agentIds = [...new Set(positionals)];
|
|
382
|
+
if (!agentIds.length) throw new UsageError('Give one or more agent IDs.', { hint: 'Example: sentinelctl metadata bulk id1 id2 --role db --dry-run' });
|
|
383
|
+
if (agentIds.length > 100) throw new UsageError('At most 100 devices per bulk update.');
|
|
384
|
+
metadataPatch(options, { revision: 0, labels: {} });
|
|
385
|
+
const currents = [];
|
|
386
|
+
for (let index = 0; index < agentIds.length; index += 8) {
|
|
387
|
+
currents.push(...await Promise.all(agentIds.slice(index, index + 8)
|
|
388
|
+
.map((agentId) => context.client.get(`/api/devices/${encodeURIComponent(agentId)}/metadata`))));
|
|
389
|
+
}
|
|
390
|
+
const updates = currents.map((current) => ({ agentId: current.agentId, ...metadataPatch(options, current) }));
|
|
391
|
+
const body = options['dry-run']
|
|
392
|
+
? await context.client.post('/api/devices/metadata/bulk/preview', { updates })
|
|
393
|
+
: await context.client.patch('/api/devices/metadata/bulk', { updates });
|
|
394
|
+
emit(context, context.global.output === 'ndjson' ? body.results : body, () => renderTable(body.results, [
|
|
395
|
+
{ header: 'AGENT ID', value: (result) => result.agentId },
|
|
396
|
+
{ header: 'RESULT', value: (result) => (result.ok ? 'ok' : `failed ${result.status ?? ''}`) },
|
|
397
|
+
{ header: 'DETAIL', value: (result) => {
|
|
398
|
+
if (!result.ok) return result.error;
|
|
399
|
+
if (result.metadata) return `revision ${result.metadata.revision}`;
|
|
400
|
+
return Object.keys(result.changes ?? {}).join(', ') || 'no change';
|
|
401
|
+
} },
|
|
402
|
+
], { width: context.width }));
|
|
403
|
+
const failures = body.results.filter((result) => !result.ok).length;
|
|
404
|
+
if (options['dry-run']) context.hint(`Dry run: ${body.changeCount ?? 0} would change, ${failures} would fail. Run without --dry-run to apply.`);
|
|
405
|
+
else if (failures) context.hint(`${failures} failed. A 409 means someone changed that device meanwhile; rerun for those devices only.`);
|
|
306
406
|
}
|
|
307
407
|
|
|
308
|
-
async function metadataRollback(context,
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
if (!agentId || options.to === undefined) throw new UsageError('에이전트 ID와 --to <변경 버전>이 필요합니다.');
|
|
408
|
+
async function metadataRollback(context, { options, positionals }) {
|
|
409
|
+
const agentId = requireAgentId(positionals);
|
|
410
|
+
if (options.to === undefined) throw new UsageError('--to <revision> is required.', { hint: `List revisions with \`sentinelctl metadata history ${agentId}\`.` });
|
|
312
411
|
const path = `/api/devices/${encodeURIComponent(agentId)}/metadata`;
|
|
313
412
|
const current = await context.client.get(path);
|
|
314
413
|
const updated = await context.client.post(`${path}/rollback`, { revision: current.revision, targetRevision: options.to });
|
|
315
|
-
emit(context, updated, () =>
|
|
414
|
+
emit(context, updated, () => `Restored ${agentId} to the content of revision ${options.to} (new revision ${updated.revision}).`);
|
|
316
415
|
}
|
|
317
416
|
|
|
318
|
-
async function metadataExport(context,
|
|
319
|
-
const { options } = parseArguments(argv, { file: { type: 'string' } });
|
|
417
|
+
async function metadataExport(context, { options }) {
|
|
320
418
|
const body = await context.client.get('/api/metadata/export');
|
|
321
419
|
if (options.file) {
|
|
322
|
-
await writeFile(options.file, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600, flag: 'wx' })
|
|
323
|
-
|
|
420
|
+
await writeFile(options.file, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600, flag: 'wx' }).catch((error) => {
|
|
421
|
+
if (error.code === 'EEXIST') throw new UsageError(`File already exists: ${options.file}`, { hint: 'Existing files are never overwritten; choose another path.' });
|
|
422
|
+
throw error;
|
|
423
|
+
});
|
|
424
|
+
context.note(`Saved metadata for ${body.devices?.length ?? 0} devices to ${options.file}.`);
|
|
324
425
|
return;
|
|
325
426
|
}
|
|
326
427
|
context.out(JSON.stringify(body, null, 2));
|
|
327
428
|
}
|
|
328
429
|
|
|
329
|
-
async function metadataImport(context,
|
|
330
|
-
|
|
331
|
-
'dry-run': { type: 'boolean' }, conflict: { type: 'string', choices: ['error', 'skip', 'overwrite'], default: 'error' },
|
|
332
|
-
});
|
|
333
|
-
if (!positionals[0]) throw new UsageError('가져올 JSON 파일 경로가 필요합니다.');
|
|
430
|
+
async function metadataImport(context, { options, positionals }) {
|
|
431
|
+
if (!positionals[0]) throw new UsageError('A JSON file path is required.', { hint: 'Use the format produced by `sentinelctl metadata export`.' });
|
|
334
432
|
let payload;
|
|
335
433
|
try {
|
|
336
434
|
payload = JSON.parse(await readFile(positionals[0], 'utf8'));
|
|
337
435
|
} catch {
|
|
338
|
-
throw new UsageError(`
|
|
436
|
+
throw new UsageError(`Cannot read JSON file: ${positionals[0]}`);
|
|
339
437
|
}
|
|
340
438
|
const path = options['dry-run'] ? '/api/metadata/import/validate' : '/api/metadata/import';
|
|
341
439
|
const body = (await context.client.request('POST', path, { query: { conflictPolicy: options.conflict }, body: payload })).body;
|
|
342
440
|
emit(context, body, () => JSON.stringify(body, null, 2));
|
|
441
|
+
if (options['dry-run']) context.hint('Validated only. Run again without --dry-run to import.');
|
|
343
442
|
}
|
|
344
443
|
|
|
345
444
|
const sessionColumns = [
|
|
346
445
|
{ header: 'ID', value: (session) => session.id },
|
|
347
|
-
{ header: '
|
|
348
|
-
{ header: '
|
|
349
|
-
{ header: '
|
|
350
|
-
{ header: '
|
|
351
|
-
{ header: '
|
|
446
|
+
{ header: 'KIND', value: (session) => (session.kind === 'cli' ? 'cli' : 'web') },
|
|
447
|
+
{ header: 'USER', value: (session) => session.user, max: 32 },
|
|
448
|
+
{ header: 'CLIENT', value: (session) => session.clientName ?? '-', max: 28 },
|
|
449
|
+
{ header: 'LAST USED', value: (session) => localTime(session.lastSeenAt) },
|
|
450
|
+
{ header: 'EXPIRES', value: (session) => localTime(session.expiresAt) },
|
|
352
451
|
];
|
|
353
452
|
|
|
354
453
|
async function sessionsList(context) {
|
|
355
454
|
const body = await context.client.get('/api/auth/sessions');
|
|
356
455
|
emit(context, context.global.output === 'ndjson' ? body.sessions : body, () => renderTable(
|
|
357
|
-
body.sessions, [...sessionColumns, { header: '', value: (session) => (session.current ? '←
|
|
456
|
+
body.sessions, [...sessionColumns, { header: '', value: (session) => (session.current ? '← current' : '') }], { width: context.width },
|
|
358
457
|
));
|
|
359
458
|
}
|
|
360
459
|
|
|
361
|
-
async function sessionsRevoke(context,
|
|
362
|
-
|
|
363
|
-
if (!positionals[0]) throw new UsageError('폐기할 세션 ID가 필요합니다.');
|
|
460
|
+
async function sessionsRevoke(context, { positionals }) {
|
|
461
|
+
if (!positionals[0]) throw new UsageError('A session ID is required.', { hint: 'List them with `sentinelctl sessions list`.' });
|
|
364
462
|
await context.client.post(`/api/auth/sessions/${encodeURIComponent(positionals[0])}/revoke`, {});
|
|
365
|
-
context.out(
|
|
463
|
+
context.out(`Revoked session ${positionals[0]}.`);
|
|
366
464
|
}
|
|
367
465
|
|
|
368
466
|
async function adminUsers(context) {
|
|
369
467
|
const body = await context.client.get('/api/admin/users');
|
|
370
468
|
emit(context, context.global.output === 'ndjson' ? body.users : body, () => renderTable(body.users, [
|
|
371
|
-
{ header: '
|
|
372
|
-
{ header: '
|
|
373
|
-
{ header: '
|
|
374
|
-
{ header: '
|
|
375
|
-
{ header: '
|
|
469
|
+
{ header: 'EMAIL', value: (user) => user.email, max: 36 },
|
|
470
|
+
{ header: 'ROLE', value: (user) => (user.disabled ? 'disabled' : user.effectiveRole) },
|
|
471
|
+
{ header: 'SOURCE', value: (user) => ({ bootstrap: 'bootstrap admin', grant: 'granted', default: 'default', disabled: 'disabled', intflow_app_admin: 'intflow app admin' })[user.roleSource] ?? user.roleSource },
|
|
472
|
+
{ header: 'WEB/CLI', value: (user) => `${user.sessions.web}/${user.sessions.cli}` },
|
|
473
|
+
{ header: 'LAST LOGIN', value: (user) => localTime(user.lastLoginAt) },
|
|
376
474
|
], { width: context.width }));
|
|
377
475
|
}
|
|
378
476
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
:
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
emit(context, body, () => `${body.email}: ${body.disabled ? '접근 중지' : (roleLabels[body.effectiveRole] ?? body.effectiveRole)}`);
|
|
477
|
+
function adminGrantCommand(grant) {
|
|
478
|
+
return async (context, { options, positionals }) => {
|
|
479
|
+
const [email] = positionals;
|
|
480
|
+
if (!email) throw new UsageError('An email is required.', { hint: 'Example: sentinelctl admin grant kim@intflow.ai --role editor' });
|
|
481
|
+
const role = grant.role === undefined ? options.role : grant.role;
|
|
482
|
+
if (grant.role === undefined && !role) throw new UsageError('--role is required.', { hint: 'Allowed values: viewer, editor, admin' });
|
|
483
|
+
const body = await context.client.post('/api/admin/users/grant', { email, role, disabled: grant.disabled ?? false });
|
|
484
|
+
emit(context, body, () => `${body.email}: ${body.disabled ? 'disabled' : body.effectiveRole}`);
|
|
485
|
+
};
|
|
389
486
|
}
|
|
390
487
|
|
|
391
|
-
async function adminSessions(context,
|
|
392
|
-
const { options } = parseArguments(argv, { kind: { type: 'string', choices: ['web', 'cli'] } });
|
|
488
|
+
async function adminSessions(context, { options }) {
|
|
393
489
|
const body = await context.client.get('/api/admin/sessions', { kind: options.kind });
|
|
394
490
|
emit(context, context.global.output === 'ndjson' ? body.sessions : body, () => renderTable(body.sessions, sessionColumns, { width: context.width }));
|
|
395
491
|
}
|
|
396
492
|
|
|
397
|
-
async function adminRevokeSession(context,
|
|
398
|
-
|
|
399
|
-
if (!positionals[0]) throw new UsageError('폐기할 세션 ID가 필요합니다.');
|
|
493
|
+
async function adminRevokeSession(context, { positionals }) {
|
|
494
|
+
if (!positionals[0]) throw new UsageError('A session ID is required.', { hint: 'List them with `sentinelctl admin sessions`.' });
|
|
400
495
|
await context.client.post(`/api/admin/sessions/${encodeURIComponent(positionals[0])}/revoke`, {});
|
|
401
|
-
context.out(
|
|
496
|
+
context.out(`Revoked session ${positionals[0]}.`);
|
|
402
497
|
}
|
|
403
498
|
|
|
404
|
-
async function
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
499
|
+
async function adminAudit(context, { options }) {
|
|
500
|
+
if (options.cleanup) {
|
|
501
|
+
const result = await context.client.post('/api/metadata/audit-maintenance/cleanup', {});
|
|
502
|
+
emit(context, result, () => `Removed ${result.deletedCount} audit entries under the retention policy.`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const body = await context.client.get('/api/metadata/audit-maintenance', { limit: options.limit });
|
|
506
|
+
emit(context, context.global.output === 'ndjson' ? body.history : body, () => renderTable(body.history, [
|
|
507
|
+
{ header: 'TIME', value: (entry) => localTime(entry.createdAt) },
|
|
508
|
+
{ header: 'ACTOR', value: (entry) => entry.actor, max: 32 },
|
|
509
|
+
{ header: 'DELETED', value: (entry) => String(entry.deletedCount) },
|
|
510
|
+
{ header: 'POLICY', value: (entry) => `${entry.retentionDays}d / ${Math.round(entry.maximumBytes / 1_048_576)}MiB` },
|
|
511
|
+
], { width: context.width }));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function bytesText(value) {
|
|
515
|
+
if (!Number.isFinite(value)) return '-';
|
|
516
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
517
|
+
let size = value;
|
|
518
|
+
let index = 0;
|
|
519
|
+
while (Math.abs(size) >= 1024 && index < units.length - 1) { size /= 1024; index += 1; }
|
|
520
|
+
return `${size.toFixed(index ? 1 : 0)}${units[index]}`;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function adminOperations(context, { options }) {
|
|
524
|
+
const body = await context.client.get('/api/operations', { range: options.range });
|
|
525
|
+
emit(context, body, () => renderTable(Object.entries(body.components ?? {}), [
|
|
526
|
+
{ header: 'COMPONENT', value: ([name]) => name },
|
|
527
|
+
{ header: 'LATEST', value: ([, component]) => component.latestAttempt?.status ?? component.latest?.status ?? '-' },
|
|
528
|
+
{ header: 'STORED', value: ([, component]) => bytesText(component.latest?.dataBytes) },
|
|
529
|
+
{ header: 'CHANGE', value: ([, component]) => bytesText(component.storageChangeBytes) },
|
|
530
|
+
{ header: 'EST/DAY', value: ([, component]) => bytesText(component.estimatedDailyBytes) },
|
|
531
|
+
{ header: 'COVERAGE', value: ([, component]) => (Number.isFinite(component.coverage?.ratio) ? `${Math.round(component.coverage.ratio * 100)}%` : '-') },
|
|
532
|
+
], { width: context.width }));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
async function rawApi(context, { options, positionals }) {
|
|
408
536
|
const [path] = positionals;
|
|
409
|
-
if (!path?.startsWith('/api/')) throw new UsageError('/api
|
|
537
|
+
if (!path?.startsWith('/api/')) throw new UsageError('A path starting with /api/ is required.', { hint: 'Example: sentinelctl api "/api/devices?status=offline"' });
|
|
410
538
|
let body;
|
|
411
539
|
if (options.data !== undefined) {
|
|
412
540
|
try {
|
|
413
541
|
body = JSON.parse(options.data);
|
|
414
542
|
} catch {
|
|
415
|
-
throw new UsageError('--data
|
|
543
|
+
throw new UsageError('--data must be JSON.');
|
|
416
544
|
}
|
|
417
545
|
}
|
|
418
546
|
const url = new URL(path, context.client.server);
|
|
@@ -420,59 +548,287 @@ async function rawApi(context, argv) {
|
|
|
420
548
|
context.out(JSON.stringify(result.body, null, 2));
|
|
421
549
|
}
|
|
422
550
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
551
|
+
const commands = [
|
|
552
|
+
{
|
|
553
|
+
group: 'logs', name: 'search', run: logsSearch, role: 'viewer', mutates: false,
|
|
554
|
+
summary: 'Search logs by window, device, severity, source and text; or follow live',
|
|
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.',
|
|
556
|
+
args: [{ name: 'text', optional: true, description: 'Same as -q (words are joined with spaces).' }],
|
|
557
|
+
options: {
|
|
558
|
+
...logFilterOptions,
|
|
559
|
+
limit: { alias: 'n', type: 'integer', choices: [50, 100, 250], description: 'Logs per page. Default 100 (250 when paging).' },
|
|
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.' },
|
|
562
|
+
fields: { type: 'boolean', description: 'Include raw fields (admin only, audited, 50 logs per page).' },
|
|
563
|
+
follow: { alias: 'f', type: 'boolean', description: 'Keep printing new logs (for humans; never ends, agents should not use it).' },
|
|
564
|
+
interval: { type: 'integer', default: 5, placeholder: 'seconds', description: 'Polling interval for --follow (min 5).' },
|
|
565
|
+
wide: { alias: 'w', type: 'boolean', description: 'Do not truncate messages in table output.' },
|
|
566
|
+
},
|
|
567
|
+
roleFor: (options) => (options.fields ? 'admin' : 'viewer'),
|
|
568
|
+
roleNote: '--fields requires admin',
|
|
569
|
+
examples: [
|
|
570
|
+
'sentinelctl logs search -l error -r 1h',
|
|
571
|
+
'sentinelctl logs search "connection reset" -r 24h -o ndjson',
|
|
572
|
+
'sentinelctl logs search -a <agent-id> -s ssh.service -r 6h --all -o json',
|
|
573
|
+
'sentinelctl logs search --from 2026-09-30T09:00:00+09:00 --to 2026-09-30T10:00:00+09:00 -l warning',
|
|
574
|
+
],
|
|
575
|
+
output: 'logs[]: timestamp (UTC ISO), severity, priority, agentId, host, displayName, source, message (+fields). -o json wraps them as {criteria, window, count, hasMore, logs}.',
|
|
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.' },
|
|
431
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?}}.',
|
|
432
595
|
},
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
596
|
+
{
|
|
597
|
+
group: 'logs', name: 'context', run: logsContext, role: 'viewer', mutates: false,
|
|
598
|
+
summary: 'Logs just before and after one log, from the same device and source',
|
|
599
|
+
options: {
|
|
600
|
+
agent: { alias: 'a', type: 'string', placeholder: 'id', description: 'Agent ID (required).' },
|
|
601
|
+
source: { alias: 's', type: 'string', placeholder: 'source', description: 'Source (required).' },
|
|
602
|
+
timestamp: { alias: 't', type: 'string', placeholder: 'ISO', description: 'The anchor log timestamp exactly as returned (required).' },
|
|
603
|
+
before: { type: 'integer', placeholder: 'N', description: 'Logs before (1-50, default 10).' },
|
|
604
|
+
after: { type: 'integer', placeholder: 'N', description: 'Logs after (1-50, default 10).' },
|
|
605
|
+
window: { type: 'integer', placeholder: 'seconds', description: 'Search distance on each side (30-3600, default 300).' },
|
|
606
|
+
wide: { alias: 'w', type: 'boolean', description: 'Do not truncate messages.' },
|
|
440
607
|
},
|
|
608
|
+
examples: ['sentinelctl logs context -a <agent-id> -s app.service -t 2026-09-30T01:02:03.456789Z -o json'],
|
|
609
|
+
output: '{criteria, count, logs[]} with logs in time order, same shape as logs search.',
|
|
441
610
|
},
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
611
|
+
{
|
|
612
|
+
group: 'logs', name: 'histogram', run: logsHistogram, role: 'viewer', mutates: false,
|
|
613
|
+
summary: 'Log volume and error counts over time',
|
|
614
|
+
args: [{ name: 'text', optional: true, description: 'Same as -q.' }],
|
|
615
|
+
options: logFilterOptions,
|
|
616
|
+
examples: ['sentinelctl logs histogram -r 24h', 'sentinelctl logs histogram -a <agent-id> -r 7d -o json'],
|
|
617
|
+
output: '{start, end, stepSeconds, total, buckets[]: {at (epoch seconds), total, errors}}. Cached for 60s on the server.',
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
group: 'logs', name: 'sources', run: logsSources, role: 'viewer', mutates: false,
|
|
621
|
+
summary: 'Log sources (systemd units, containers) seen recently',
|
|
622
|
+
options: { range: { alias: 'r', type: 'string', choices: logRanges, default: '24h', description: 'Sample window.' } },
|
|
623
|
+
examples: ['sentinelctl logs sources -r 1h'],
|
|
624
|
+
output: '{range, sampleSize, sources[]: {value, count}}: top 50 in a sample of the latest 250 logs.',
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
group: 'devices', name: 'list', run: devicesList, role: 'viewer', mutates: false,
|
|
628
|
+
summary: 'Devices with status and resource usage; search, filter and sort',
|
|
629
|
+
args: [{ name: 'text', optional: true, description: 'Substring of name, host, ID, OS, site, group, role, maintainer, notes or labels.' }],
|
|
630
|
+
options: {
|
|
631
|
+
query: { alias: 'q', type: 'string', placeholder: 'text', description: 'Same as the positional text.' },
|
|
632
|
+
status: { type: 'string', choices: deviceStatuses, description: 'Reception status.' },
|
|
633
|
+
lifecycle: { type: 'string', choices: lifecycles, description: 'Lifecycle scope (default operational = active + maintenance).' },
|
|
634
|
+
role: { type: 'string', placeholder: 'role', description: 'Device role.' },
|
|
635
|
+
site: { type: 'string', placeholder: 'site', description: 'Site.' },
|
|
636
|
+
group: { type: 'string', placeholder: 'group', description: 'Group.' },
|
|
637
|
+
maintainer: { type: 'string', placeholder: 'name', description: 'Maintainer.' },
|
|
638
|
+
fleet: { type: 'string', choices: ['edge', 'infrastructure', 'metrics-only'], description: 'Fleet type.' },
|
|
639
|
+
focus: { type: 'string', choices: ['attention', 'resources', 'collection', 'repeated', 'recovered', 'spool', 'metadata'], description: 'Only devices needing attention of this kind.' },
|
|
640
|
+
inventory: { type: 'string', choices: ['registered', 'discovered', 'history'], description: 'Inventory state.' },
|
|
641
|
+
label: { type: 'string', multiple: true, placeholder: 'k=v', description: 'Label match (up to 8).' },
|
|
642
|
+
sort: { type: 'string', choices: deviceSorts, description: 'Sort key.' },
|
|
643
|
+
order: { type: 'string', choices: ['asc', 'desc'], description: 'Sort direction.' },
|
|
644
|
+
page: { type: 'integer', default: 1, placeholder: 'N', description: 'Page number.' },
|
|
645
|
+
'page-size': { type: 'integer', default: 50, placeholder: 'N', description: 'Page size (max 100).' },
|
|
646
|
+
all: { type: 'boolean', description: 'Fetch every page (remaining pages are fetched concurrently).' },
|
|
451
647
|
},
|
|
648
|
+
examples: ['sentinelctl devices list --status offline', 'sentinelctl devices list --focus attention --all -o json',
|
|
649
|
+
'sentinelctl devices list --sort cpu --order desc --page-size 10'],
|
|
650
|
+
output: 'devices[]: agentId, displayName, host, status, cpu/memory/disk (%), ageSeconds, site, group, metadata, attentionReasons … -o json wraps them as {matched, counts, stale, devices}.',
|
|
452
651
|
},
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
652
|
+
{
|
|
653
|
+
group: 'devices', name: 'show', run: devicesShow, role: 'viewer', mutates: false,
|
|
654
|
+
summary: 'One device: current state and metric time series',
|
|
655
|
+
args: [{ name: 'agent-id' }],
|
|
656
|
+
options: {
|
|
657
|
+
range: { alias: 'r', type: 'string', choices: deviceRanges, default: '6h', description: 'Time-series window (about 120 points).' },
|
|
658
|
+
refresh: { type: 'boolean', description: 'Bypass the server cache.' },
|
|
458
659
|
},
|
|
660
|
+
examples: ['sentinelctl devices show <agent-id> -r 24h -o json'],
|
|
661
|
+
output: '{device, current, series{cpu, memory, disk, load, temperature, networkRx, networkTx, …: [[epochSeconds, value], …]}, range, stepSeconds, stale}.',
|
|
662
|
+
},
|
|
663
|
+
{
|
|
664
|
+
group: 'devices', name: 'lookup', run: devicesLookup, role: 'viewer', mutates: false,
|
|
665
|
+
summary: 'Find agent IDs by part of a name or host',
|
|
666
|
+
args: [{ name: 'text' }],
|
|
667
|
+
options: {},
|
|
668
|
+
examples: ['sentinelctl devices lookup 10357'],
|
|
669
|
+
output: '{devices[]: {agentId, host, displayName}, matched} (up to 100).',
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
group: 'devices', name: 'overview', run: devicesOverview, role: 'viewer', mutates: false,
|
|
673
|
+
summary: 'Reception state, outage counts and durations, and attention reasons for one device',
|
|
674
|
+
args: [{ name: 'agent-id' }],
|
|
675
|
+
options: { range: { alias: 'r', type: 'string', default: '24h', placeholder: 'window', description: 'History window, e.g. 24h or 7d.' } },
|
|
676
|
+
examples: ['sentinelctl devices overview <agent-id> -r 7d -o json'],
|
|
677
|
+
output: '{summary{metrics, logs}: {state, outages, delays, missingMinutes, longestOutageMinutes, normalRatio}, agentId, device, products, attentionReasons[], history, related[]}.',
|
|
678
|
+
},
|
|
679
|
+
{
|
|
680
|
+
group: 'metadata', name: 'get', run: metadataGet, role: 'viewer', mutates: false,
|
|
681
|
+
summary: 'Device metadata: name, role, maintainer, labels, lifecycle',
|
|
682
|
+
args: [{ name: 'agent-id' }], options: {},
|
|
683
|
+
examples: ['sentinelctl metadata get <agent-id> -o json'],
|
|
684
|
+
output: '{agentId, revision, displayName, role, maintainer, maintainerContact, description, labels, lifecycle, maintenanceUntil, maintenanceReason, updatedAt}.',
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
group: 'metadata', name: 'history', run: metadataHistory, role: 'viewer', mutates: false,
|
|
688
|
+
summary: 'Metadata change history: who, when, what',
|
|
689
|
+
args: [{ name: 'agent-id' }],
|
|
690
|
+
options: { limit: { alias: 'n', type: 'integer', default: 20, placeholder: 'N', description: 'Maximum entries (1-100).' } },
|
|
691
|
+
examples: ['sentinelctl metadata history <agent-id>'],
|
|
692
|
+
output: '{history[]: {beforeRevision, afterRevision, changedFields[], actor, createdAt, before, after}}.',
|
|
459
693
|
},
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
694
|
+
{
|
|
695
|
+
group: 'metadata', name: 'set', run: metadataSet, role: 'editor', mutates: true,
|
|
696
|
+
summary: 'Edit metadata (reads the latest revision and applies on top of it)',
|
|
697
|
+
args: [{ name: 'agent-id' }],
|
|
698
|
+
options: { ...metadataEditOptions, 'dry-run': { type: 'boolean', description: 'Show what would change without saving.' } },
|
|
699
|
+
examples: ['sentinelctl metadata set <agent-id> --display-name "Edge 1" --label site=seoul --dry-run',
|
|
700
|
+
'sentinelctl metadata set <agent-id> --lifecycle maintenance --maintenance-until 2026-10-01T09:00:00+09:00 --maintenance-reason "disk swap"'],
|
|
701
|
+
output: 'Saved metadata (same shape as metadata get). With --dry-run: {results[0].changes}.',
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
group: 'metadata', name: 'bulk', run: metadataBulk, role: 'editor', mutates: true,
|
|
705
|
+
summary: 'Apply the same metadata change to up to 100 devices',
|
|
706
|
+
args: [{ name: 'agent-id…', description: 'Space separated.' }],
|
|
707
|
+
options: { ...metadataEditOptions, 'dry-run': { type: 'boolean', description: 'Preview the change per device.' } },
|
|
708
|
+
examples: ['sentinelctl metadata bulk id1 id2 id3 --role camera --label site=farm-a --dry-run'],
|
|
709
|
+
output: '{count, successCount|changeCount, failureCount, results[]: {agentId, ok, status?, error?, changes|metadata}}. Partial success per device.',
|
|
710
|
+
},
|
|
711
|
+
{
|
|
712
|
+
group: 'metadata', name: 'rollback', run: metadataRollback, role: 'editor', mutates: true,
|
|
713
|
+
summary: 'Restore metadata to the content of an earlier revision (recorded as a new revision)',
|
|
714
|
+
args: [{ name: 'agent-id' }],
|
|
715
|
+
options: { to: { type: 'integer', placeholder: 'revision', description: 'Revision to restore (required; see metadata history).' } },
|
|
716
|
+
examples: ['sentinelctl metadata rollback <agent-id> --to 3'],
|
|
717
|
+
output: 'Metadata after the rollback.',
|
|
718
|
+
},
|
|
719
|
+
{
|
|
720
|
+
group: 'metadata', name: 'export', run: metadataExport, role: 'admin', mutates: false,
|
|
721
|
+
summary: 'Export metadata for all devices as JSON',
|
|
722
|
+
options: { file: { type: 'string', placeholder: 'path', description: 'Write to a new file (0600, never overwrites). Default: stdout.' } },
|
|
723
|
+
examples: ['sentinelctl metadata export --file metadata-backup.json'],
|
|
724
|
+
output: '{schemaVersion, scope, exportedAt, devices[]}.',
|
|
725
|
+
},
|
|
726
|
+
{
|
|
727
|
+
group: 'metadata', name: 'import', run: metadataImport, role: 'admin', mutates: true,
|
|
728
|
+
summary: 'Import metadata in export format',
|
|
729
|
+
args: [{ name: 'file' }],
|
|
730
|
+
options: {
|
|
731
|
+
'dry-run': { type: 'boolean', description: 'Validate only.' },
|
|
732
|
+
conflict: { type: 'string', choices: ['error', 'skip', 'overwrite'], default: 'error', description: 'What to do with devices that already have metadata.' },
|
|
469
733
|
},
|
|
734
|
+
examples: ['sentinelctl metadata import backup.json --dry-run --conflict skip'],
|
|
735
|
+
output: '{valid, count, insertCount, overwriteCount, skipCount, conflictCount, …}.',
|
|
470
736
|
},
|
|
471
|
-
|
|
737
|
+
{
|
|
738
|
+
group: 'sessions', name: 'list', run: sessionsList, role: 'viewer', mutates: false,
|
|
739
|
+
summary: 'Your web sessions and CLI tokens', options: {},
|
|
740
|
+
examples: ['sentinelctl sessions list'], output: '{sessions[]: {id, kind, user, clientName, createdAt, lastSeenAt, expiresAt, current}}.',
|
|
741
|
+
},
|
|
742
|
+
{
|
|
743
|
+
group: 'sessions', name: 'revoke', run: sessionsRevoke, role: 'viewer', mutates: true,
|
|
744
|
+
summary: 'Revoke one of your sessions or CLI tokens', args: [{ name: 'session-id' }], options: {},
|
|
745
|
+
examples: ['sentinelctl sessions revoke <session-id>'], output: 'Confirmation message.',
|
|
746
|
+
},
|
|
747
|
+
{
|
|
748
|
+
group: 'admin', name: 'users', run: adminUsers, role: 'admin', mutates: false,
|
|
749
|
+
summary: 'Users with their role, where it comes from, and session counts', options: {},
|
|
750
|
+
examples: ['sentinelctl admin users -o json'],
|
|
751
|
+
output: '{users[]: {email, name, effectiveRole, roleSource, grantedRole, disabled, bootstrapAdmin, sessions{web, cli}, lastLoginAt}}.',
|
|
752
|
+
},
|
|
753
|
+
{
|
|
754
|
+
group: 'admin', name: 'grant', run: adminGrantCommand({}), role: 'admin', mutates: true,
|
|
755
|
+
summary: 'Grant a role (works before the user first logs in)', args: [{ name: 'email' }],
|
|
756
|
+
options: { role: { type: 'string', choices: ['viewer', 'editor', 'admin'], description: 'Role to grant (required).' } },
|
|
757
|
+
examples: ['sentinelctl admin grant kim@intflow.ai --role editor'], output: 'The updated user row.',
|
|
758
|
+
},
|
|
759
|
+
{
|
|
760
|
+
group: 'admin', name: 'reset', run: adminGrantCommand({ role: null }), role: 'admin', mutates: true,
|
|
761
|
+
summary: 'Remove a granted role (back to the default viewer)', args: [{ name: 'email' }], options: {},
|
|
762
|
+
examples: ['sentinelctl admin reset kim@intflow.ai'], output: 'The updated user row.',
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
group: 'admin', name: 'disable', run: adminGrantCommand({ role: null, disabled: true }), role: 'admin', mutates: true,
|
|
766
|
+
summary: 'Block access and end all of the user\'s sessions and CLI tokens now', args: [{ name: 'email' }], options: {},
|
|
767
|
+
examples: ['sentinelctl admin disable kim@intflow.ai'], output: 'The updated user row. Undo with admin reset.',
|
|
768
|
+
},
|
|
769
|
+
{
|
|
770
|
+
group: 'admin', name: 'sessions', run: adminSessions, role: 'admin', mutates: false,
|
|
771
|
+
summary: 'All web sessions and CLI tokens',
|
|
772
|
+
options: { kind: { type: 'string', choices: ['web', 'cli'], description: 'Filter by kind.' } },
|
|
773
|
+
examples: ['sentinelctl admin sessions --kind cli'], output: '{sessions[]}.',
|
|
774
|
+
},
|
|
775
|
+
{
|
|
776
|
+
group: 'admin', name: 'revoke', run: adminRevokeSession, role: 'admin', mutates: true,
|
|
777
|
+
summary: 'Revoke anyone\'s session or CLI token', args: [{ name: 'session-id' }], options: {},
|
|
778
|
+
examples: ['sentinelctl admin revoke <session-id>'], output: 'Confirmation message.',
|
|
779
|
+
},
|
|
780
|
+
{
|
|
781
|
+
group: 'admin', name: 'audit', run: adminAudit, role: 'admin', mutates: false,
|
|
782
|
+
summary: 'Metadata audit clean-up records; run the clean-up now',
|
|
783
|
+
options: {
|
|
784
|
+
cleanup: { type: 'boolean', description: 'Apply the retention policy now (default 365 days / 64 MiB).' },
|
|
785
|
+
limit: { alias: 'n', type: 'integer', placeholder: 'N', description: 'Records to show (1-100).' },
|
|
786
|
+
},
|
|
787
|
+
examples: ['sentinelctl admin audit', 'sentinelctl admin audit --cleanup'], output: '{history[]} or {deletedCount}.',
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
group: 'admin', name: 'operations', run: adminOperations, role: 'admin', mutates: false,
|
|
791
|
+
summary: 'Backend (metrics, logs, console) storage and ingestion trends',
|
|
792
|
+
options: { range: { alias: 'r', type: 'string', choices: ['24h', '7d'], default: '24h', description: 'Window.' } },
|
|
793
|
+
examples: ['sentinelctl admin operations -r 7d -o json'], output: '{components{metrics, logs, console}: {latest, storageChangeBytes, estimatedDailyBytes, coverage, series}}.',
|
|
794
|
+
},
|
|
795
|
+
{
|
|
796
|
+
group: null, name: 'health', run: health, role: 'viewer', mutates: false,
|
|
797
|
+
summary: 'Backend connectivity', options: {},
|
|
798
|
+
examples: ['sentinelctl health -o json'], output: '{status, application, components{metrics, logs, metadata}: {status, latencyMs}}.',
|
|
799
|
+
},
|
|
800
|
+
{
|
|
801
|
+
group: null, name: 'api', run: rawApi, role: 'viewer', mutates: false,
|
|
802
|
+
summary: 'Call a console API directly (JSON output; the server enforces roles)',
|
|
803
|
+
args: [{ name: 'path', description: 'Starts with /api/.' }],
|
|
804
|
+
options: {
|
|
805
|
+
method: { alias: 'X', type: 'string', choices: ['GET', 'POST', 'PATCH'], default: 'GET', description: 'HTTP method.' },
|
|
806
|
+
data: { alias: 'd', type: 'string', placeholder: 'JSON', description: 'Request body.' },
|
|
807
|
+
},
|
|
808
|
+
examples: ['sentinelctl api "/api/devices?status=offline&pageSize=5"'], output: 'The server response JSON as is.',
|
|
809
|
+
},
|
|
810
|
+
];
|
|
472
811
|
|
|
473
|
-
export const
|
|
474
|
-
|
|
475
|
-
|
|
812
|
+
export const catalog = commands;
|
|
813
|
+
export const groups = {
|
|
814
|
+
logs: 'Search, count and rank logs; context around a log; volume trends; sources',
|
|
815
|
+
devices: 'Device list, details, lookup, reception overview',
|
|
816
|
+
metadata: 'Device metadata: read, edit (editor), import/export (admin)',
|
|
817
|
+
sessions: 'Your sessions and CLI tokens',
|
|
818
|
+
admin: 'Roles, all sessions, audit clean-up, capacity (admin)',
|
|
476
819
|
};
|
|
477
820
|
|
|
478
|
-
export
|
|
821
|
+
export function findCommand(group, name) {
|
|
822
|
+
return commands.find((command) => command.group === group && command.name === name) ?? null;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export function commandPath(command) {
|
|
826
|
+
return command.group ? `${command.group} ${command.name}` : command.name;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
export function usageLine(command) {
|
|
830
|
+
const args = (command.args ?? []).map((arg) => (arg.optional ? `[${arg.name}]` : `<${arg.name}>`)).join(' ');
|
|
831
|
+
return `sentinelctl ${commandPath(command)}${args ? ` ${args}` : ''}${Object.keys(command.options).length ? ' [options]' : ''}`;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export { singleLine };
|