@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/src/commands.mjs CHANGED
@@ -1,29 +1,43 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
- import { keyValue, parseArguments, UsageError } from './args.mjs';
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: '조회 전용', editor: '편집 가능', admin: '관리자' };
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)) throw new UsageError('--from과 --to는 함께 지정해야 합니다.');
23
- if (options.from && options.range) throw new UsageError('--range와 --from/--to는 함께 쓸 수 없습니다.');
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'), from: options.from, to: options.to,
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: '시각', value: (log) => localTime(log.timestamp), max: 19 },
39
- { header: '수준', value: (log) => log.severity, max: 9 },
40
- { header: '장비', value: (log) => log.displayName ?? log.host ?? log.agentId, max: 24 },
41
- { header: '출처', value: (log) => log.source, max: 22 },
42
- { header: '메시지', value: (log) => log.message },
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
- async function logsSearch(context, argv) {
50
- const { options, positionals } = parseArguments(argv, {
51
- ...logFilterOptions,
52
- limit: { alias: 'n', type: 'integer', choices: [50, 100, 250], default: 100 },
53
- pages: { type: 'integer', default: 1 },
54
- all: { type: 'boolean' },
55
- fields: { type: 'boolean' },
56
- follow: { alias: 'f', type: 'boolean' },
57
- interval: { type: 'integer', default: 5 },
58
- wide: { alias: 'w', type: 'boolean' },
59
- });
60
- const criteria = { ...logCriteria(options, positionals), limit: options.fields ? 50 : options.limit, includeFields: options.fields ? 1 : undefined };
61
- if (options.follow) return followLogs(context, criteria, options);
62
- const maxPages = options.all ? 200 : Math.max(1, options.pages);
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 < maxPages; page += 1) {
67
- last = await context.client.get('/api/logs', { ...criteria, cursor });
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
- return;
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, criteria, options) {
103
+ async function followLogs(context, query, options) {
83
104
  const seen = new Set();
84
- const intervalMs = Math.max(2, options.interval) * 1_000;
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('새 로그를 기다립니다. Ctrl+C로 종료합니다.');
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', followCriteria);
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(`경고: ${options.interval}초 사이에 ${followCriteria.limit}건보다 많은 로그가 들어와 일부를 건너뛰었습니다. -a/-s/-l/-q로 범위를 좁히거나 --interval을 줄이세요.`);
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
- async function logsContext(context, argv) {
113
- const { options } = parseArguments(argv, {
114
- agent: { alias: 'a', type: 'string' }, source: { alias: 's', type: 'string' }, timestamp: { alias: 't', type: 'string' },
115
- before: { type: 'integer' }, after: { type: 'integer' }, window: { type: 'integer' }, wide: { alias: 'w', type: 'boolean' },
116
- });
117
- if (!options.agent || !options.source || !options.timestamp) throw new UsageError('--agent, --source, --timestamp가 필요합니다.');
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, argv) {
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: '시각', value: (bucket) => localTime(bucket.at) },
133
- { header: '전체', value: (bucket) => String(bucket.total) },
134
- { header: '오류', value: (bucket) => String(bucket.errors) },
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, argv) {
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: '출처', value: (source) => source.value },
145
- { header: '건수', value: (source) => String(source.count) },
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: '에이전트 ID', value: (device) => device.agentId, max: 28 },
151
- { header: '이름', value: (device) => device.displayName ?? device.host, max: 28 },
152
- { header: '상태', value: (device) => device.status, max: 11 },
153
- { header: '사이트', value: (device) => device.site, max: 14 },
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: '메모리', value: (device) => percentText(device.memory), max: 6 },
156
- { header: '디스크', value: (device) => percentText(device.disk), max: 6 },
157
- { header: '마지막 수신', value: (device) => ageText(device.ageSeconds) },
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, argv) {
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 devices = [];
177
- let body;
178
- for (let page = options.all ? 1 : options.page; ; page += 1) {
179
- body = await context.client.get('/api/devices', { ...query, page });
180
- devices.push(...body.devices);
181
- if (!options.all || page >= body.totalPages) break;
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: body.matched, counts: body.counts, stale: body.stale, devices }, null, 2));
185
- return;
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
- async function devicesShow(context, argv) {
194
- const { options, positionals } = parseArguments(argv, { range: { alias: 'r', type: 'string', default: '6h' } });
195
- const [agentId] = positionals;
196
- if (!agentId) throw new UsageError('에이전트 ID가 필요합니다.');
197
- const body = await context.client.get(`/api/devices/${encodeURIComponent(agentId)}`, { range: options.range });
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
- ['에이전트 ID', device.agentId], ['이름', device.displayName], ['호스트', device.host],
201
- ['상태', device.observedStatus ?? device.status], ['마지막 수신', ageText(device.ageSeconds)],
202
- ['CPU', percentText(device.cpu)], ['메모리', percentText(device.memory)], ['디스크', percentText(device.disk)],
203
- ['부하', device.load], ['온도', device.temperature], ['용도', device.metadata?.role ?? device.role],
204
- ['담당자', device.metadata?.maintainer], ['운영 상태', device.metadata?.lifecycle], ['메모', device.metadata?.description],
205
- ['데이터', body.stale ? `오래됨 (${body.staleReason ?? ''})` : '최신'],
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, argv) {
210
- const { positionals } = parseArguments(argv, {});
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: '에이전트 ID', value: (device) => device.agentId },
214
- { header: '이름', value: (device) => device.displayName },
215
- { header: '호스트', value: (device) => device.host },
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, argv) {
220
- const { options, positionals } = parseArguments(argv, { range: { alias: 'r', type: 'string', default: '24h' } });
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
- emit(context, body, () => renderKeyValues([
225
- ['에이전트 ID', body.agentId], ['이름', body.device?.displayName], ['메트릭', body.products?.metrics?.state],
226
- ['로그', body.products?.logs?.state], ['주의 사유', (body.attentionReasons ?? []).join(', ') || '없음'],
227
- ['관련 장비', (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 ?? '-'} 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([['전체', body.status], ['버전', `${body.application?.version} (${body.application?.commit})`],
235
- ['관측기', body.observer?.status ?? body.observer?.state]]),
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: '구성요소', value: ([name]) => name },
239
- { header: '상태', value: ([, component]) => component.status },
240
- { header: '지연', value: ([, component]) => (Number.isFinite(component.latencyMs) ? `${component.latencyMs}ms` : '-') },
241
- { header: '오류', value: ([, component]) => component.error ?? '' },
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, argv) {
247
- const { positionals } = parseArguments(argv, {});
248
- if (!positionals[0]) throw new UsageError('에이전트 ID가 필요합니다.');
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
- ['에이전트 ID', body.agentId], ['변경 버전', body.revision], ['이름', body.displayName], ['용도', body.role],
252
- ['담당자', body.maintainer], ['연락처', body.maintainerContact], ['운영 상태', body.lifecycle],
253
- ['점검 종료 예정', body.maintenanceUntil], ['점검 사유', body.maintenanceReason], ['메모', body.description],
254
- ['라벨', Object.entries(body.labels ?? {}).map(([key, value]) => `${key}=${value}`).join(', ')],
255
- ['갱신', body.updatedAt],
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, argv) {
260
- const { options, positionals } = parseArguments(argv, { limit: { alias: 'n', type: 'integer', default: 20 } });
261
- if (!positionals[0]) throw new UsageError('에이전트 ID가 필요합니다.');
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: '버전', value: (entry) => `${entry.beforeRevision}→${entry.afterRevision}` },
265
- { header: '시각', value: (entry) => localTime(entry.createdAt) },
266
- { header: '변경자', value: (entry) => entry.actor, max: 32 },
267
- { header: '변경 항목', value: (entry) => entry.changedFields.join(', ') },
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
- async function metadataSet(context, argv) {
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) throw new UsageError('바꿀 항목을 하나 이상 지정하세요. 예: --display-name "DB 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, () => `${agentId} 메타데이터를 변경 버전 ${updated.revision}으로 저장했습니다.`);
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, argv) {
309
- const { options, positionals } = parseArguments(argv, { to: { type: 'integer' } });
310
- const [agentId] = positionals;
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, () => `${agentId} 메타데이터를 버전 ${options.to} 내용으로 되돌렸습니다(새 변경 버전 ${updated.revision}).`);
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, argv) {
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
- context.err(`${body.devices?.length ?? 0}대 메타데이터를 ${options.file}에 저장했습니다.`);
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, argv) {
330
- const { options, positionals } = parseArguments(argv, {
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(`JSON 파일을 읽을 수 없습니다: ${positionals[0]}`);
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: '종류', value: (session) => (session.kind === 'cli' ? 'CLI' : '웹') },
348
- { header: '사용자', value: (session) => session.user, max: 32 },
349
- { header: '클라이언트', value: (session) => session.clientName ?? '-', max: 28 },
350
- { header: '마지막 사용', value: (session) => localTime(session.lastSeenAt) },
351
- { header: '만료', value: (session) => localTime(session.expiresAt) },
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 ? '← 현재' : '') }], { width: context.width },
456
+ body.sessions, [...sessionColumns, { header: '', value: (session) => (session.current ? '← current' : '') }], { width: context.width },
358
457
  ));
359
458
  }
360
459
 
361
- async function sessionsRevoke(context, argv) {
362
- const { positionals } = parseArguments(argv, {});
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(`세션 ${positionals[0]}을 폐기했습니다.`);
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: '이메일', value: (user) => user.email, max: 36 },
372
- { header: '권한', value: (user) => (user.disabled ? '중지됨' : roleLabels[user.effectiveRole] ?? user.effectiveRole) },
373
- { header: '근거', value: (user) => ({ bootstrap: '기본 관리자', grant: '콘솔 지정', default: '기본값', disabled: '중지' })[user.roleSource] ?? user.roleSource },
374
- { header: '웹/CLI', value: (user) => `${user.sessions.web}/${user.sessions.cli}` },
375
- { header: '마지막 로그인', value: (user) => localTime(user.lastLoginAt) },
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
- async function adminGrant(context, argv, grant) {
380
- const { options, positionals } = parseArguments(argv, grant.role === undefined
381
- ? { role: { type: 'string', choices: ['viewer', 'editor', 'admin'] } }
382
- : {});
383
- const [email] = positionals;
384
- if (!email) throw new UsageError('이메일이 필요합니다.');
385
- const role = grant.role === undefined ? options.role : grant.role;
386
- if (grant.role === undefined && !role) throw new UsageError('--role viewer|editor|admin이 필요합니다.');
387
- const body = await context.client.post('/api/admin/users/grant', { email, role, disabled: grant.disabled ?? false });
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, argv) {
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, argv) {
398
- const { positionals } = parseArguments(argv, {});
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(`세션 ${positionals[0]}을 폐기했습니다.`);
496
+ context.out(`Revoked session ${positionals[0]}.`);
402
497
  }
403
498
 
404
- async function rawApi(context, argv) {
405
- const { options, positionals } = parseArguments(argv, {
406
- method: { alias: 'X', type: 'string', choices: ['GET', 'POST', 'PATCH'], default: 'GET' }, data: { alias: 'd', type: 'string' },
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는 JSON이어야 합니다.');
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
- export const commandTable = {
424
- logs: {
425
- summary: '로그 검색·주변 로그·히스토그램·출처',
426
- commands: {
427
- search: { run: logsSearch, usage: 'logs search [검색어] [-q 텍스트] [-a 에이전트] [-l 수준] [-s 출처] [-r 15m|1h|6h|24h|7d | --from ISO --to ISO] [-n 50|100|250] [--pages N | --all] [--fields] [-f]' },
428
- context: { run: logsContext, usage: 'logs context -a 에이전트 -s 출처 -t 2026-09-30T01:02:03.000Z [--before N] [--after N]' },
429
- histogram: { run: logsHistogram, usage: 'logs histogram [로그 검색과 같은 필터]' },
430
- sources: { run: logsSources, usage: 'logs sources [-r 24h]' },
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
- devices: {
434
- summary: '장비 목록·상세·찾기·개요',
435
- commands: {
436
- list: { run: devicesList, usage: 'devices list [검색어] [--status online|delayed|offline|…] [--site S] [--group G] [--role R] [--label k=v] [--sort 필드] [--order asc|desc] [--page N] [--page-size N | --all]' },
437
- show: { run: devicesShow, usage: 'devices show <에이전트 ID> [-r 30m|1h|6h|24h|7d|30d]' },
438
- lookup: { run: devicesLookup, usage: 'devices lookup <검색어>' },
439
- overview: { run: devicesOverview, usage: 'devices overview <에이전트 ID> [-r 24h]' },
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
- metadata: {
443
- summary: '장비 메타데이터 조회·변경(편집 권한)·가져오기/내보내기(관리자)',
444
- commands: {
445
- get: { run: metadataGet, usage: 'metadata get <에이전트 ID>' },
446
- history: { run: metadataHistory, usage: 'metadata history <에이전트 ID> [-n 20]' },
447
- set: { run: metadataSet, usage: 'metadata set <에이전트 ID> [--display-name ..] [--role ..] [--maintainer ..] [--contact ..] [--description ..] [--lifecycle active|maintenance|retired] [--label k=v] [--remove-label k] [--clear 필드]' },
448
- rollback: { run: metadataRollback, usage: 'metadata rollback <에이전트 ID> --to <변경 버전>' },
449
- export: { run: metadataExport, usage: 'metadata export [--file 경로]' },
450
- import: { run: metadataImport, usage: 'metadata import <파일> [--dry-run] [--conflict error|skip|overwrite]' },
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
- sessions: {
454
- summary: '내 웹 세션·CLI 토큰 조회와 폐기',
455
- commands: {
456
- list: { run: sessionsList, usage: 'sessions list' },
457
- revoke: { run: sessionsRevoke, usage: 'sessions revoke <세션 ID>' },
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
- admin: {
461
- summary: '사용자 권한 지정·접근 중지, 전체 세션 관리(관리자)',
462
- commands: {
463
- users: { run: adminUsers, usage: 'admin users' },
464
- grant: { run: (context, argv) => adminGrant(context, argv, {}), usage: 'admin grant <이메일> --role viewer|editor|admin' },
465
- reset: { run: (context, argv) => adminGrant(context, argv, { role: null }), usage: 'admin reset <이메일> (콘솔 지정 권한을 지우고 기본값으로)' },
466
- disable: { run: (context, argv) => adminGrant(context, argv, { role: null, disabled: true }), usage: 'admin disable <이메일> (접근 중지·세션 즉시 종료)' },
467
- sessions: { run: adminSessions, usage: 'admin sessions [--kind web|cli]' },
468
- revoke: { run: adminRevokeSession, usage: 'admin revoke <세션 ID>' },
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 standaloneCommands = {
474
- health: { run: health, usage: 'health', summary: '백엔드 연결 상태' },
475
- api: { run: rawApi, usage: 'api <경로> [-X GET|POST|PATCH] [-d JSON]', summary: '콘솔 API 직접 호출(JSON 출력)' },
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 { outputChoices, singleLine };
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 };