@zhin.js/host-http 1.0.1 → 1.0.3
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 +3 -2
- package/lib/console-events.d.ts +13 -0
- package/lib/console-events.js +33 -0
- package/lib/console-rest-pages.js +11 -4
- package/lib/console-rpc-extended.d.ts +44 -4
- package/lib/console-rpc-extended.js +247 -167
- package/lib/console-rpc.d.ts +9 -9
- package/lib/console-rpc.js +38 -61
- package/lib/http-host.js +9 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/json-body.js +10 -2
- package/lib/project-files.js +10 -0
- package/package.json +3 -2
- package/src/console-events.ts +47 -0
- package/src/console-rest-pages.ts +13 -4
- package/src/console-rpc-extended.ts +313 -183
- package/src/console-rpc.ts +52 -75
- package/src/http-host.ts +9 -0
- package/src/index.ts +10 -0
- package/src/json-body.ts +8 -2
- package/src/project-files.ts +10 -0
package/lib/console-rpc.js
CHANGED
|
@@ -1,42 +1,5 @@
|
|
|
1
|
+
import { assertDemoConsoleRpcAllowed, endpointSendResult, normalizeConsoleRpcMessage, } from '@zhin.js/console-protocol';
|
|
1
2
|
import { dispatchExtendedConsoleRpc } from './console-rpc-extended.js';
|
|
2
|
-
/**
|
|
3
|
-
* Frontend SDK (@zhin.js/client) calls colon-named aliases; normalize to the
|
|
4
|
-
* canonical dot names before scope checks and dispatch. Alias parameter shapes
|
|
5
|
-
* (`data.{adapter, endpointId, id, type, content, parent}`) are mapped onto the
|
|
6
|
-
* `$`-prefixed fields inside each endpoint case.
|
|
7
|
-
*/
|
|
8
|
-
const RPC_TYPE_ALIASES = Object.freeze({
|
|
9
|
-
'endpoint:list': 'endpoint.list',
|
|
10
|
-
'endpoint:info': 'endpoint.info',
|
|
11
|
-
'endpoint:sendMessage': 'endpoint.send_message',
|
|
12
|
-
});
|
|
13
|
-
/** ADR 0016 demo allowlist subset available on Plugin Runtime Host. */
|
|
14
|
-
const DEMO_RPC_ALLOWLIST = new Set([
|
|
15
|
-
'ping',
|
|
16
|
-
'entries:get',
|
|
17
|
-
'pages:list',
|
|
18
|
-
'config:get',
|
|
19
|
-
'config:get-all',
|
|
20
|
-
'config:get-yaml',
|
|
21
|
-
'schema:get',
|
|
22
|
-
'schema:get-all',
|
|
23
|
-
'endpoint.list',
|
|
24
|
-
'endpoint.info',
|
|
25
|
-
'endpoint.send_message',
|
|
26
|
-
]);
|
|
27
|
-
const DEMO_RPC_WRITE_BLOCKLIST = new Set([
|
|
28
|
-
'config:set',
|
|
29
|
-
'config:save-yaml',
|
|
30
|
-
'files:save',
|
|
31
|
-
'env:save',
|
|
32
|
-
'db:insert',
|
|
33
|
-
'db:update',
|
|
34
|
-
'db:delete',
|
|
35
|
-
'db:drop-table',
|
|
36
|
-
'db:kv:set',
|
|
37
|
-
'db:kv:delete',
|
|
38
|
-
'system:restart',
|
|
39
|
-
]);
|
|
40
3
|
export function pickRpcReply(message, payloads) {
|
|
41
4
|
const requestId = message.requestId;
|
|
42
5
|
return ((requestId != null ? payloads.find((payload) => payload.requestId === requestId) : undefined)
|
|
@@ -48,11 +11,19 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
48
11
|
const emit = (payload) => {
|
|
49
12
|
payloads.push(payload);
|
|
50
13
|
};
|
|
51
|
-
|
|
14
|
+
message = normalizeConsoleRpcMessage(message);
|
|
15
|
+
const type = String(message.type ?? '');
|
|
52
16
|
const requestId = message.requestId;
|
|
53
|
-
|
|
17
|
+
if (ctx.authScope === 'demo') {
|
|
18
|
+
const denied = assertDemoConsoleRpcAllowed(type);
|
|
19
|
+
if (denied) {
|
|
20
|
+
emit({ requestId, error: denied });
|
|
21
|
+
return payloads;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
// Extended RPC surface (cron/schedule、endpoint 社交/inbox)
|
|
54
25
|
if (ctx.extended) {
|
|
55
|
-
const extended = await dispatchExtendedConsoleRpc(type, message
|
|
26
|
+
const extended = await dispatchExtendedConsoleRpc(type, message, { ...ctx.extended, fullScope: ctx.authScope === 'full' });
|
|
56
27
|
if (extended !== undefined) {
|
|
57
28
|
emit('error' in extended
|
|
58
29
|
? { requestId, error: extended.error }
|
|
@@ -60,10 +31,6 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
60
31
|
return payloads;
|
|
61
32
|
}
|
|
62
33
|
}
|
|
63
|
-
if (ctx.authScope === 'demo' && (DEMO_RPC_WRITE_BLOCKLIST.has(type) || !DEMO_RPC_ALLOWLIST.has(type))) {
|
|
64
|
-
emit({ requestId, error: `Demo scope: RPC "${type}" is forbidden` });
|
|
65
|
-
return payloads;
|
|
66
|
-
}
|
|
67
34
|
switch (type) {
|
|
68
35
|
case 'ping':
|
|
69
36
|
emit({ type: 'pong', requestId });
|
|
@@ -118,7 +85,9 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
118
85
|
}
|
|
119
86
|
case 'config:get': {
|
|
120
87
|
try {
|
|
121
|
-
|
|
88
|
+
// 兼容 console UI 的 data:{plugin} / data:{key} 形状
|
|
89
|
+
const data = (message.data ?? {});
|
|
90
|
+
const key = String(message.key ?? message.pluginName ?? data.pluginName ?? data.plugin ?? data.key ?? '');
|
|
122
91
|
const document = ctx.readConfigDocument ? await ctx.readConfigDocument() : {};
|
|
123
92
|
emit({ requestId, data: key ? document[key] : document });
|
|
124
93
|
}
|
|
@@ -142,6 +111,7 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
142
111
|
return payloads;
|
|
143
112
|
}
|
|
144
113
|
await ctx.writeConfigYaml(yaml);
|
|
114
|
+
ctx.publishEvent?.('config:updated', { pluginName: null, keys: [] });
|
|
145
115
|
emit({
|
|
146
116
|
requestId,
|
|
147
117
|
data: { success: true, message: '配置已保存,需重启生效' },
|
|
@@ -167,6 +137,12 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
167
137
|
return payloads;
|
|
168
138
|
}
|
|
169
139
|
const result = await ctx.setConfigKey(pluginName, message.data);
|
|
140
|
+
ctx.publishEvent?.('config:updated', {
|
|
141
|
+
pluginName,
|
|
142
|
+
keys: message.data && typeof message.data === 'object' && !Array.isArray(message.data)
|
|
143
|
+
? Object.keys(message.data)
|
|
144
|
+
: [],
|
|
145
|
+
});
|
|
170
146
|
emit({
|
|
171
147
|
requestId,
|
|
172
148
|
data: {
|
|
@@ -312,7 +288,10 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
312
288
|
}
|
|
313
289
|
case 'schema:get': {
|
|
314
290
|
try {
|
|
315
|
-
|
|
291
|
+
// 兼容 console UI 的 data:{plugin} / data:{pluginName} 形状(SDK 发顶层 pluginName)
|
|
292
|
+
const data = (message.data ?? {});
|
|
293
|
+
const candidate = message.pluginName ?? data.pluginName ?? data.plugin;
|
|
294
|
+
const pluginName = typeof candidate === 'string' ? candidate : undefined;
|
|
316
295
|
const schema = ctx.getSchema ? await ctx.getSchema(pluginName) : null;
|
|
317
296
|
emit({ requestId, data: schema });
|
|
318
297
|
}
|
|
@@ -544,10 +523,9 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
544
523
|
}
|
|
545
524
|
case 'endpoint.info': {
|
|
546
525
|
try {
|
|
547
|
-
const data =
|
|
548
|
-
|
|
549
|
-
const
|
|
550
|
-
const endpointId = String(data.$endpoint ?? data.endpointId ?? '');
|
|
526
|
+
const data = message;
|
|
527
|
+
const adapter = String(data.$adapter ?? '');
|
|
528
|
+
const endpointId = String(data.$endpoint ?? '');
|
|
551
529
|
if (!adapter || !endpointId) {
|
|
552
530
|
emit({ requestId, error: '$adapter and $endpoint required' });
|
|
553
531
|
return payloads;
|
|
@@ -573,14 +551,12 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
573
551
|
}
|
|
574
552
|
case 'endpoint.send_message': {
|
|
575
553
|
try {
|
|
576
|
-
const data =
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
const
|
|
582
|
-
const channelType = String(data.$channel_type ?? data.type ?? '');
|
|
583
|
-
const content = data.$content !== undefined ? data.$content : data.content;
|
|
554
|
+
const data = message;
|
|
555
|
+
const adapter = String(data.$adapter ?? '');
|
|
556
|
+
const endpointId = String(data.$endpoint ?? '');
|
|
557
|
+
const channelId = String(data.$channel_id ?? '');
|
|
558
|
+
const channelType = String(data.$channel_type ?? '');
|
|
559
|
+
const content = data.$content;
|
|
584
560
|
// channelType is optional: ImRuntime tolerates an empty value and
|
|
585
561
|
// targets the bare channel id (parity with the legacy RPC handler).
|
|
586
562
|
if (!adapter || !endpointId || !channelId || content === undefined) {
|
|
@@ -600,10 +576,10 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
600
576
|
channelId,
|
|
601
577
|
channelType,
|
|
602
578
|
content,
|
|
603
|
-
parent:
|
|
579
|
+
parent: data.$parent,
|
|
604
580
|
});
|
|
605
581
|
// Legacy contract: { message_id }. Keep messageId for new callers.
|
|
606
|
-
emit({ requestId, data:
|
|
582
|
+
emit({ requestId, data: endpointSendResult(result.messageId) });
|
|
607
583
|
}
|
|
608
584
|
catch (error) {
|
|
609
585
|
emit({
|
|
@@ -619,6 +595,7 @@ export async function dispatchRuntimeConsoleRpc(message, ctx) {
|
|
|
619
595
|
emit({ requestId, error: 'Process restart is not configured' });
|
|
620
596
|
return payloads;
|
|
621
597
|
}
|
|
598
|
+
ctx.publishEvent?.('system:restarting', { timestamp: Date.now() });
|
|
622
599
|
emit({ requestId, data: { success: true, message: '正在重启...' } });
|
|
623
600
|
// Delay so the HTTP response can flush (parity with legacy host-api).
|
|
624
601
|
setTimeout(() => {
|
package/lib/http-host.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createToken } from '@zhin.js/plugin-runtime';
|
|
|
5
5
|
import { TokenRegistry, extractBearerToken, isDemoWebSocketPath, } from './token-registry.js';
|
|
6
6
|
import { buildOpenApiDocument, } from './openapi.js';
|
|
7
7
|
import { isDemoHttpAllowed } from './console-rpc.js';
|
|
8
|
+
import { HttpBodyError } from './json-body.js';
|
|
8
9
|
const logger = getLogger('HttpHost');
|
|
9
10
|
export const REMOTE_CONSOLE_ORIGIN = 'https://console.zhin.dev';
|
|
10
11
|
const DEFAULT_CORS_ORIGINS = Object.freeze([REMOTE_CONSOLE_ORIGIN]);
|
|
@@ -180,6 +181,14 @@ export function createHttpHost(options = {}) {
|
|
|
180
181
|
await route.handler(request, response, url, auth.scope);
|
|
181
182
|
}
|
|
182
183
|
catch (err) {
|
|
184
|
+
// 统一把请求体错误(400/413)映射回对应状态码,
|
|
185
|
+
// 避免个别端点未捕获时退化成 500 空响应。
|
|
186
|
+
if (err instanceof HttpBodyError) {
|
|
187
|
+
if (!response.headersSent) {
|
|
188
|
+
writeJson(response, err.statusCode, { success: false, error: err.message });
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
183
192
|
logger.warn(formatCompact({
|
|
184
193
|
op: 'http_handler_failed',
|
|
185
194
|
path: pathname,
|
package/lib/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, type HttpHandler, type HttpHost, type HttpHostAddress, type HttpHostOptions, type HttpRouteRegistration, type WsConnection, type WsHandle, } from './http-host.js';
|
|
2
2
|
export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, type AuthScope, type ScopedTokenConfig, type TokenRegistryConfig, } from './token-registry.js';
|
|
3
3
|
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
4
|
+
export { createConsoleEventHub, type ConsoleEventHub, } from './console-events.js';
|
|
4
5
|
export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, type BuildOpenApiOptions, type ListedRoute, type OpenApiParameter, type RouteMeta, } from './openapi.js';
|
|
5
6
|
export { HttpBodyError, readJsonBody, } from './json-body.js';
|
|
6
7
|
export { dispatchRuntimeConsoleRpc, isDemoHttpAllowed, pickRpcReply, type RuntimeConsolePage, type RuntimeConsoleRpcContext, type RuntimeConsoleRpcMessage, type RuntimeConsoleRpcReply, type RuntimeEndpointSendInput, type RuntimeEndpointSummary, } from './console-rpc.js';
|
|
7
8
|
export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, type FileTreeNode, } from './project-files.js';
|
|
8
9
|
export { registerConsoleRestPages, type ConsoleAgentIntrospection, type ConsoleAgentRuntime, type ConsoleAgentSessionTree, type ConsoleRestCtx, type ConsoleRestPagesOptions, } from './console-rest-pages.js';
|
|
10
|
+
export { dispatchExtendedConsoleRpc, type ConsoleRpcExtendedCtx, type ConsoleScheduleEngine, type ExtendedRpcResult, } from './console-rpc-extended.js';
|
package/lib/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
export { createHttpHost, httpHostToken, REMOTE_CONSOLE_ORIGIN, } from './http-host.js';
|
|
2
2
|
export { TokenRegistry, extractBearerToken, isDemoWebSocketPath, } from './token-registry.js';
|
|
3
3
|
export { timingSafeEqualString } from './timing-safe-equal.js';
|
|
4
|
+
export { createConsoleEventHub, } from './console-events.js';
|
|
4
5
|
export { buildOpenApiDocument, patternToOpenApiPath, routeRequiresBearerAuth, } from './openapi.js';
|
|
5
6
|
export { HttpBodyError, readJsonBody, } from './json-body.js';
|
|
6
7
|
export { dispatchRuntimeConsoleRpc, isDemoHttpAllowed, pickRpcReply, } from './console-rpc.js';
|
|
7
8
|
export { buildProjectFileTree, FILE_MANAGER_ALLOWED, FILE_MANAGER_BLOCKED, isProjectPathAllowed, listEnvFiles, readProjectFile, saveProjectFile, } from './project-files.js';
|
|
8
9
|
export { registerConsoleRestPages, } from './console-rest-pages.js';
|
|
10
|
+
export { dispatchExtendedConsoleRpc, } from './console-rpc-extended.js';
|
package/lib/json-body.js
CHANGED
|
@@ -14,15 +14,23 @@ export async function readJsonBody(request, options = {}) {
|
|
|
14
14
|
const limit = options.limit ?? 1_048_576;
|
|
15
15
|
const chunks = [];
|
|
16
16
|
let size = 0;
|
|
17
|
+
let exceeded = false;
|
|
17
18
|
for await (const chunk of request) {
|
|
19
|
+
// 超限后继续 drain 到 end:提前 break 会 destroy 底层 socket,
|
|
20
|
+
// 客户端将永远收不到后面的 413 响应。
|
|
21
|
+
if (exceeded)
|
|
22
|
+
continue;
|
|
18
23
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
19
24
|
size += buffer.length;
|
|
20
25
|
if (size > limit) {
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
exceeded = true;
|
|
27
|
+
chunks.length = 0;
|
|
28
|
+
continue;
|
|
23
29
|
}
|
|
24
30
|
chunks.push(buffer);
|
|
25
31
|
}
|
|
32
|
+
if (exceeded)
|
|
33
|
+
throw new HttpBodyError(`Request body exceeds ${limit} bytes`, 413);
|
|
26
34
|
if (chunks.length === 0)
|
|
27
35
|
return undefined;
|
|
28
36
|
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
package/lib/project-files.js
CHANGED
|
@@ -5,6 +5,16 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
|
|
|
5
5
|
'src',
|
|
6
6
|
'plugins',
|
|
7
7
|
'client',
|
|
8
|
+
// Plugin Runtime convention directories (file-based discovery)
|
|
9
|
+
'commands',
|
|
10
|
+
'adapters',
|
|
11
|
+
'components',
|
|
12
|
+
'middlewares',
|
|
13
|
+
'tools',
|
|
14
|
+
'mcp',
|
|
15
|
+
'pages',
|
|
16
|
+
'skills',
|
|
17
|
+
'agents',
|
|
8
18
|
'package.json',
|
|
9
19
|
'tsconfig.json',
|
|
10
20
|
'zhin.config.yml',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/host-http",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -18,8 +18,9 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"ws": "^8.21.0",
|
|
21
|
+
"@zhin.js/console-protocol": "1.1.0",
|
|
21
22
|
"@zhin.js/logger": "1.0.75",
|
|
22
|
-
"@zhin.js/plugin-runtime": "1.
|
|
23
|
+
"@zhin.js/plugin-runtime": "1.1.1"
|
|
23
24
|
},
|
|
24
25
|
"devDependencies": {
|
|
25
26
|
"@types/node": "^26.1.0",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Console 实时事件枢纽(Plugin Runtime Host 的 `/api/events` SSE 事件源)。
|
|
5
|
+
* 单进程内 fan-out:`publish` 向所有已订阅的 SSE response 写帧;
|
|
6
|
+
* write 失败或连接断开自动摘除订阅。
|
|
7
|
+
*/
|
|
8
|
+
export interface ConsoleEventHub {
|
|
9
|
+
publish(type: string, data: unknown): void;
|
|
10
|
+
/** 订阅事件流;返回 unsubscribe。连接断开时也会自动摘除。 */
|
|
11
|
+
subscribe(response: ServerResponse): () => void;
|
|
12
|
+
readonly subscriberCount: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createConsoleEventHub(): ConsoleEventHub {
|
|
16
|
+
let nextId = 0;
|
|
17
|
+
const subscribers = new Set<ServerResponse>();
|
|
18
|
+
|
|
19
|
+
const remove = (response: ServerResponse): void => {
|
|
20
|
+
subscribers.delete(response);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
get subscriberCount() {
|
|
25
|
+
return subscribers.size;
|
|
26
|
+
},
|
|
27
|
+
publish(type, data) {
|
|
28
|
+
nextId += 1;
|
|
29
|
+
const frame = `id: ${nextId}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
30
|
+
for (const response of [...subscribers]) {
|
|
31
|
+
try {
|
|
32
|
+
response.write(frame, (error) => {
|
|
33
|
+
if (error) remove(response);
|
|
34
|
+
});
|
|
35
|
+
} catch {
|
|
36
|
+
remove(response);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
subscribe(response) {
|
|
41
|
+
subscribers.add(response);
|
|
42
|
+
response.once('close', () => remove(response));
|
|
43
|
+
response.once('error', () => remove(response));
|
|
44
|
+
return () => remove(response);
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -85,6 +85,8 @@ export interface ConsoleRestPagesOptions {
|
|
|
85
85
|
type LogModelLike = {
|
|
86
86
|
select(...fields: string[]): LogSelectionLike;
|
|
87
87
|
delete(where: Record<string, unknown>): Promise<unknown>;
|
|
88
|
+
/** DB 侧 count 聚合(可选;缺失时降级为 select('id') 计数)。 */
|
|
89
|
+
count?(where?: Record<string, unknown>): Promise<number>;
|
|
88
90
|
};
|
|
89
91
|
|
|
90
92
|
type LogSelectionLike = PromiseLike<LogRow[]> & {
|
|
@@ -223,13 +225,20 @@ function registerLogsRoutes(
|
|
|
223
225
|
return;
|
|
224
226
|
}
|
|
225
227
|
|
|
226
|
-
|
|
228
|
+
// 计数走 DB 侧聚合(count);模型未实现 count 时降级为 select('id') 只取主键列
|
|
229
|
+
const countRows = async (where?: Record<string, unknown>): Promise<number> => {
|
|
230
|
+
if (typeof LogModel.count === 'function') return LogModel.count(where);
|
|
231
|
+
const selection = LogModel.select('id');
|
|
232
|
+
const rows = await (where ? selection.where(where) : selection);
|
|
233
|
+
return rows.length;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const total = await countRows();
|
|
227
237
|
const levels = ['info', 'warn', 'error'];
|
|
228
238
|
const levelCounts: Record<string, number> = {};
|
|
229
239
|
|
|
230
240
|
for (const level of levels) {
|
|
231
|
-
|
|
232
|
-
levelCounts[level] = count.length;
|
|
241
|
+
levelCounts[level] = await countRows({ level });
|
|
233
242
|
}
|
|
234
243
|
|
|
235
244
|
const oldestLog = await LogModel.select('timestamp')
|
|
@@ -244,7 +253,7 @@ function registerLogsRoutes(
|
|
|
244
253
|
|
|
245
254
|
writeJson(response, 200, {
|
|
246
255
|
success: true,
|
|
247
|
-
data: { total
|
|
256
|
+
data: { total, byLevel: levelCounts, oldestTimestamp },
|
|
248
257
|
});
|
|
249
258
|
}, { summary: 'System log stats', tags: ['console', 'logs'] });
|
|
250
259
|
|