claw-dashboard 1.8.4 → 2.0.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/.c8rc.json +38 -0
- package/.dockerignore +68 -0
- package/.github/dependabot.yml +45 -0
- package/.github/pull_request_template.md +39 -0
- package/.github/workflows/ci.yml +147 -0
- package/.github/workflows/release.yml +40 -0
- package/.github/workflows/security.yml +84 -0
- package/.husky/pre-commit +1 -0
- package/.planning/codebase/ARCHITECTURE.md +206 -0
- package/.planning/codebase/INTEGRATIONS.md +150 -0
- package/.planning/codebase/STACK.md +122 -0
- package/.planning/codebase/STRUCTURE.md +201 -0
- package/CHANGELOG.md +293 -0
- package/CONTRIBUTING.md +378 -0
- package/Dockerfile +54 -0
- package/FEATURES.md +195 -21
- package/README.md +83 -6
- package/TODO.md +28 -0
- package/build-cjs.js +127 -0
- package/cjs-shim.js +13 -0
- package/dist/clawdash +1729 -0
- package/dist/clawdash.meta.json +2236 -0
- package/dist/widgets.cjs +6511 -0
- package/docker-compose.yml +67 -0
- package/docs/API.md +1050 -0
- package/docs/PLUGINS.md +1504 -0
- package/esbuild.config.js +158 -0
- package/eslint.config.js +56 -0
- package/examples/plugins/README.md +122 -0
- package/examples/plugins/api-status/index.js +294 -0
- package/examples/plugins/api-status/plugin.json +19 -0
- package/examples/plugins/hello-world/index.js +104 -0
- package/examples/plugins/hello-world/plugin.json +15 -0
- package/examples/plugins/system-metrics-chart/index.js +339 -0
- package/examples/plugins/system-metrics-chart/plugin.json +18 -0
- package/examples/plugins/weather-widget/index.js +136 -0
- package/examples/plugins/weather-widget/plugin.json +19 -0
- package/index.cjs +23005 -0
- package/index.js +5331 -512
- package/jest.config.js +11 -0
- package/man/clawdash.1 +276 -0
- package/package.json +54 -5
- package/schemas/plugin-manifest.json +128 -0
- package/scripts/release.js +595 -0
- package/src/alerts.js +693 -0
- package/src/auto-save.js +584 -0
- package/src/cache.js +390 -0
- package/src/checksum.js +146 -0
- package/src/cli/args.js +110 -0
- package/src/cli/export-schedule.js +423 -0
- package/src/cli/export-snapshot.js +138 -0
- package/src/cli/help.js +69 -0
- package/src/cli/import-snapshot.js +230 -0
- package/src/cli/index.js +14 -0
- package/src/cli/list-templates.js +69 -0
- package/src/cli/validate-config.js +76 -0
- package/src/cli/validate-plugin.js +187 -0
- package/src/cli/version.js +15 -0
- package/src/config-validator.js +586 -0
- package/src/config-watcher.js +401 -0
- package/src/config.js +684 -0
- package/src/container-detector.js +499 -0
- package/src/database.js +734 -0
- package/src/differential-render.js +327 -0
- package/src/errors.js +169 -0
- package/src/export-scheduler.js +581 -0
- package/src/gateway-manager.js +685 -0
- package/src/hints.js +371 -0
- package/src/loading-states.js +509 -0
- package/src/logger.js +185 -0
- package/src/memory-pressure.js +467 -0
- package/src/performance-monitor.js +374 -0
- package/src/plugin-errors.js +586 -0
- package/src/plugin-manifest-validator.js +219 -0
- package/src/plugin-reload.js +481 -0
- package/src/plugin-scaffold.js +1941 -0
- package/src/plugin-validator.js +284 -0
- package/src/retry.js +218 -0
- package/src/security.js +860 -0
- package/src/snapshot.js +372 -0
- package/src/splash.js +115 -0
- package/src/theme-selector.js +411 -0
- package/src/themes.js +874 -0
- package/src/transitions.js +534 -0
- package/src/types.d.ts +174 -0
- package/src/validation.js +926 -0
- package/src/web-server.js +885 -0
- package/src/widgets/builtin-widgets.js +1057 -0
- package/src/widgets/config-processor.js +401 -0
- package/src/widgets/dependency-resolver.js +596 -0
- package/src/widgets/index.js +79 -0
- package/src/widgets/plugin-api.js +845 -0
- package/src/widgets/widget-error-boundary.js +773 -0
- package/src/widgets/widget-error-isolation.js +551 -0
- package/src/widgets/widget-loader.js +1437 -0
- package/src/workers/system-worker.js +130 -0
- package/src/workers/worker-pool.js +633 -0
- package/tests/alerts.test.js +437 -0
- package/tests/auto-save.test.js +529 -0
- package/tests/cache.test.js +317 -0
- package/tests/cli.test.js +351 -0
- package/tests/command-palette.test.js +320 -0
- package/tests/config-processor.test.js +452 -0
- package/tests/config-validator.test.js +710 -0
- package/tests/config-watcher.test.js +594 -0
- package/tests/config.test.js +755 -0
- package/tests/database.test.js +438 -0
- package/tests/errors.test.js +189 -0
- package/tests/example-plugins.test.js +624 -0
- package/tests/integration.test.js +1321 -0
- package/tests/loading-states.test.js +300 -0
- package/tests/manifest-validation-on-load.test.js +671 -0
- package/tests/performance-monitor.test.js +190 -0
- package/tests/plugin-api-rate-limit.test.js +302 -0
- package/tests/plugin-errors.test.js +311 -0
- package/tests/plugin-lifecycle-e2e.test.js +1036 -0
- package/tests/plugin-reload.test.js +764 -0
- package/tests/rate-limiter.test.js +539 -0
- package/tests/retry.test.js +308 -0
- package/tests/security.test.js +411 -0
- package/tests/settings-modal.test.js +226 -0
- package/tests/theme-selector.test.js +57 -0
- package/tests/utils.js +242 -0
- package/tests/utils.test.js +317 -0
- package/tests/validate-plugin-cli.test.js +359 -0
- package/tests/validation.test.js +837 -0
- package/tests/web-server.test.js +646 -0
- package/tests/widget-arrange-mode.test.js +183 -0
- package/tests/widget-config-hot-reload.test.js +465 -0
- package/tests/widget-dependency.test.js +591 -0
- package/tests/widget-error-boundary.test.js +749 -0
- package/tests/widget-error-isolation.test.js +469 -0
- package/tests/widget-integration.test.js +1147 -0
- package/tests/widget-refresh-intervals.test.js +284 -0
- package/tests/worker-pool.test.js +522 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway Manager Module
|
|
3
|
+
* Manages multiple OpenClaw gateway endpoints and aggregates data
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from 'fs';
|
|
7
|
+
import https from 'https';
|
|
8
|
+
import http from 'http';
|
|
9
|
+
import { exec } from 'child_process';
|
|
10
|
+
import { promisify } from 'util';
|
|
11
|
+
import logger from './logger.js';
|
|
12
|
+
import config, { DEFAULT_GATEWAY_ENDPOINT, GATEWAY } from './config.js';
|
|
13
|
+
import { GatewayError, NetworkError, AuthError, TimeoutError, ChecksumError } from './errors.js';
|
|
14
|
+
import { verifyResponseChecksum, getChecksumMetadata } from './checksum.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} GatewayEndpoint
|
|
18
|
+
* @property {string} name - Endpoint display name
|
|
19
|
+
* @property {string} host - Hostname or IP address
|
|
20
|
+
* @property {number} port - Port number
|
|
21
|
+
* @property {string|null} token - Authentication token
|
|
22
|
+
* @property {boolean} enabled - Whether endpoint is enabled
|
|
23
|
+
* @property {string} type - Endpoint type: 'local', 'remote', 'cloud'
|
|
24
|
+
* @property {boolean} [reachable] - Last known reachability status
|
|
25
|
+
* @property {number} [lastSeen] - Timestamp of last successful connection
|
|
26
|
+
* @property {string} [error] - Last error message
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @typedef {Object} AggregatedSession
|
|
31
|
+
* @property {string} key - Session key
|
|
32
|
+
* @property {string} channel - Channel name
|
|
33
|
+
* @property {string} displayName - Display name
|
|
34
|
+
* @property {number} updatedAt - Last update timestamp
|
|
35
|
+
* @property {string} sessionId - Session ID
|
|
36
|
+
* @property {string} model - Model name
|
|
37
|
+
* @property {number} contextTokens - Context window size
|
|
38
|
+
* @property {number} totalTokens - Total tokens used
|
|
39
|
+
* @property {string} kind - Session kind
|
|
40
|
+
* @property {Object} deliveryContext - Delivery context
|
|
41
|
+
* @property {boolean} systemSent - System message sent
|
|
42
|
+
* @property {boolean} abortedLastRun - Was last run aborted
|
|
43
|
+
* @property {string} lastChannel - Last channel
|
|
44
|
+
* @property {string} lastTo - Last recipient
|
|
45
|
+
* @property {string} lastAccountId - Last account ID
|
|
46
|
+
* @property {string} transcriptPath - Path to transcript
|
|
47
|
+
* @property {string} gatewayEndpoint - Name of gateway endpoint this session belongs to
|
|
48
|
+
* @property {string} gatewayHost - Host of gateway endpoint
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
// Promisified exec for async command execution
|
|
52
|
+
const execAsync = promisify(exec);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get log filter function based on filter level
|
|
56
|
+
* @param {string} filter - Filter level ('all', 'error', 'warn', 'info', 'debug')
|
|
57
|
+
* @returns {Function} Filter function that takes a log line and returns boolean
|
|
58
|
+
*/
|
|
59
|
+
function getLogFilterFn(filter) {
|
|
60
|
+
switch (filter) {
|
|
61
|
+
case 'error':
|
|
62
|
+
return (line) => line.includes('ERROR') || line.includes('error') || line.includes('ERR');
|
|
63
|
+
case 'warn':
|
|
64
|
+
return (line) => line.includes('WARN') || line.includes('warning') || line.includes('WARNING');
|
|
65
|
+
case 'info':
|
|
66
|
+
return (line) => !line.includes('DEBUG') && !line.includes('debug');
|
|
67
|
+
case 'debug':
|
|
68
|
+
case 'all':
|
|
69
|
+
default:
|
|
70
|
+
return () => true;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fetch OpenClaw logs from the system
|
|
76
|
+
* @param {Object} options - Options for fetching logs
|
|
77
|
+
* @param {number} options.limit - Maximum number of log lines to fetch (default: 200)
|
|
78
|
+
* @param {string} options.filter - Log level filter ('all', 'error', 'warn', 'info', 'debug')
|
|
79
|
+
* @returns {Promise<Object>} Object with logs array
|
|
80
|
+
*/
|
|
81
|
+
export async function getOpenClawLogs(options = {}) {
|
|
82
|
+
const { limit = 200, filter = 'all' } = options;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const { stdout } = await execAsync(
|
|
86
|
+
`openclaw logs --limit ${limit} --plain 2>/dev/null`,
|
|
87
|
+
{ timeout: config.COMMAND_TIMEOUTS.OPENCLAW_LOGS }
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const filterFn = getLogFilterFn(filter);
|
|
91
|
+
const lines = stdout
|
|
92
|
+
.trim()
|
|
93
|
+
.split('\n')
|
|
94
|
+
.filter(line => !line.includes('plugin CLI register skipped'))
|
|
95
|
+
.filter(line => filterFn(line))
|
|
96
|
+
.filter(line => line.length > 0);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
logs: lines,
|
|
100
|
+
count: lines.length,
|
|
101
|
+
timestamp: Date.now()
|
|
102
|
+
};
|
|
103
|
+
} catch (err) {
|
|
104
|
+
logger.debug(`Failed to fetch OpenClaw logs: ${err.message}`);
|
|
105
|
+
return {
|
|
106
|
+
logs: [],
|
|
107
|
+
count: 0,
|
|
108
|
+
timestamp: Date.now(),
|
|
109
|
+
error: err.message
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class GatewayManager {
|
|
115
|
+
constructor() {
|
|
116
|
+
/** @type {GatewayEndpoint[]} */
|
|
117
|
+
this.endpoints = [];
|
|
118
|
+
/** @type {Map<string, number>} */
|
|
119
|
+
this.endpointLatency = new Map();
|
|
120
|
+
/** @type {Map<string, number>} */
|
|
121
|
+
this.endpointFailCount = new Map();
|
|
122
|
+
/** @type {Map<string, number>} */
|
|
123
|
+
this.endpointChecksumFailCount = new Map();
|
|
124
|
+
/** @type {Map<string, boolean>} */
|
|
125
|
+
this.endpointChecksumVerified = new Map();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Initialize the gateway manager with settings
|
|
130
|
+
* @param {Object} settings - Dashboard settings
|
|
131
|
+
*/
|
|
132
|
+
init(settings) {
|
|
133
|
+
if (settings.gatewayEndpoints && Array.isArray(settings.gatewayEndpoints)) {
|
|
134
|
+
this.endpoints = settings.gatewayEndpoints.map(ep => ({
|
|
135
|
+
...DEFAULT_GATEWAY_ENDPOINT,
|
|
136
|
+
...ep,
|
|
137
|
+
// Ensure required fields
|
|
138
|
+
name: ep.name || 'unnamed',
|
|
139
|
+
host: ep.host || 'localhost',
|
|
140
|
+
port: ep.port || GATEWAY.DEFAULT_PORT,
|
|
141
|
+
enabled: ep.enabled !== false, // Default to true
|
|
142
|
+
}));
|
|
143
|
+
} else {
|
|
144
|
+
// Fallback to default single endpoint
|
|
145
|
+
this.endpoints = [{ ...DEFAULT_GATEWAY_ENDPOINT }];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
logger.info(`GatewayManager initialized with ${this.endpoints.length} endpoint(s)`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Get all enabled endpoints
|
|
153
|
+
* @returns {GatewayEndpoint[]}
|
|
154
|
+
*/
|
|
155
|
+
getEnabledEndpoints() {
|
|
156
|
+
return this.endpoints.filter(ep => ep.enabled);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get all endpoints (including disabled)
|
|
161
|
+
* @returns {GatewayEndpoint[]}
|
|
162
|
+
*/
|
|
163
|
+
getAllEndpoints() {
|
|
164
|
+
return [...this.endpoints];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get a specific endpoint by name
|
|
169
|
+
* @param {string} name - Endpoint name
|
|
170
|
+
* @returns {GatewayEndpoint|undefined}
|
|
171
|
+
*/
|
|
172
|
+
getEndpoint(name) {
|
|
173
|
+
return this.endpoints.find(ep => ep.name === name);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Add a new endpoint
|
|
178
|
+
* @param {Partial<GatewayEndpoint>} endpointConfig - Endpoint configuration
|
|
179
|
+
* @returns {GatewayEndpoint|null} - The added endpoint or null if failed
|
|
180
|
+
*/
|
|
181
|
+
addEndpoint(endpointConfig) {
|
|
182
|
+
if (this.endpoints.length >= GATEWAY.MAX_ENDPOINTS) {
|
|
183
|
+
logger.warn(`Cannot add endpoint: maximum of ${GATEWAY.MAX_ENDPOINTS} endpoints reached`);
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check for duplicate names
|
|
188
|
+
if (this.endpoints.some(ep => ep.name === endpointConfig.name)) {
|
|
189
|
+
logger.warn(`Cannot add endpoint: name '${endpointConfig.name}' already exists`);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const newEndpoint = {
|
|
194
|
+
...DEFAULT_GATEWAY_ENDPOINT,
|
|
195
|
+
...endpointConfig,
|
|
196
|
+
enabled: true,
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
this.endpoints.push(newEndpoint);
|
|
200
|
+
logger.info(`Added gateway endpoint: ${newEndpoint.name} (${newEndpoint.host}:${newEndpoint.port})`);
|
|
201
|
+
return newEndpoint;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Remove an endpoint by name
|
|
206
|
+
* @param {string} name - Endpoint name to remove
|
|
207
|
+
* @returns {boolean} - True if removed, false if not found
|
|
208
|
+
*/
|
|
209
|
+
removeEndpoint(name) {
|
|
210
|
+
const idx = this.endpoints.findIndex(ep => ep.name === name);
|
|
211
|
+
if (idx === -1) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Don't allow removing the last endpoint
|
|
216
|
+
if (this.endpoints.length <= 1) {
|
|
217
|
+
logger.warn('Cannot remove the last gateway endpoint');
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
this.endpoints.splice(idx, 1);
|
|
222
|
+
logger.info(`Removed gateway endpoint: ${name}`);
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Update an endpoint
|
|
228
|
+
* @param {string} name - Endpoint name to update
|
|
229
|
+
* @param {Partial<GatewayEndpoint>} updates - Fields to update
|
|
230
|
+
* @returns {GatewayEndpoint|null} - Updated endpoint or null if not found
|
|
231
|
+
*/
|
|
232
|
+
updateEndpoint(name, updates) {
|
|
233
|
+
const idx = this.endpoints.findIndex(ep => ep.name === name);
|
|
234
|
+
if (idx === -1) {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Don't allow renaming to a duplicate name
|
|
239
|
+
if (updates.name && updates.name !== name && this.endpoints.some(ep => ep.name === updates.name)) {
|
|
240
|
+
logger.warn(`Cannot rename endpoint: name '${updates.name}' already exists`);
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.endpoints[idx] = { ...this.endpoints[idx], ...updates };
|
|
245
|
+
logger.info(`Updated gateway endpoint: ${name}`);
|
|
246
|
+
return this.endpoints[idx];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Toggle endpoint enabled state
|
|
251
|
+
* @param {string} name - Endpoint name
|
|
252
|
+
* @param {boolean} enabled - New enabled state
|
|
253
|
+
* @returns {boolean} - True if toggled, false if not found
|
|
254
|
+
*/
|
|
255
|
+
toggleEndpoint(name, enabled) {
|
|
256
|
+
const ep = this.getEndpoint(name);
|
|
257
|
+
if (!ep) {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Don't allow disabling the last enabled endpoint
|
|
262
|
+
if (!enabled && this.getEnabledEndpoints().length <= 1) {
|
|
263
|
+
logger.warn('Cannot disable the last enabled gateway endpoint');
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
ep.enabled = enabled;
|
|
268
|
+
logger.info(`Gateway endpoint ${name} ${enabled ? 'enabled' : 'disabled'}`);
|
|
269
|
+
return true;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Build sessions URL for an endpoint
|
|
274
|
+
* @param {GatewayEndpoint} endpoint
|
|
275
|
+
* @returns {string}
|
|
276
|
+
*/
|
|
277
|
+
buildSessionsUrl(endpoint) {
|
|
278
|
+
const protocol = endpoint.port === 443 ? 'https' : 'http';
|
|
279
|
+
return `${protocol}://${endpoint.host}:${endpoint.port}/sessions`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Fetch sessions from a single endpoint
|
|
284
|
+
* @param {GatewayEndpoint} endpoint
|
|
285
|
+
* @returns {Promise<AggregatedSession[]>}
|
|
286
|
+
*/
|
|
287
|
+
async fetchSessionsFromEndpoint(endpoint) {
|
|
288
|
+
const startTime = Date.now();
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
// First try HTTP API endpoint
|
|
292
|
+
const sessions = await this.fetchFromHttpApi(endpoint);
|
|
293
|
+
if (sessions && sessions.length > 0) {
|
|
294
|
+
// HTTP API succeeded - mark as reachable
|
|
295
|
+
// Note: checksum verification happens inside fetchFromHttpApi, so we only get here if it passed
|
|
296
|
+
this.updateEndpointHealth(endpoint.name, true, Date.now() - startTime, null, true, false);
|
|
297
|
+
return sessions.map(s => this.enrichSession(s, endpoint));
|
|
298
|
+
}
|
|
299
|
+
} catch (err) {
|
|
300
|
+
logger.debug(`HTTP API fetch failed for ${endpoint.name}: ${err.message}`);
|
|
301
|
+
|
|
302
|
+
// Handle checksum verification failures specifically
|
|
303
|
+
if (err instanceof ChecksumError) {
|
|
304
|
+
this.updateEndpointHealth(endpoint.name, false, null, err.message, false, true);
|
|
305
|
+
logger.error(`Checksum verification failed for ${endpoint.name}: ${err.message}`);
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Fallback to local file system for local endpoints
|
|
311
|
+
if (endpoint.type === 'local' || endpoint.host === 'localhost' || endpoint.host === '127.0.0.1') {
|
|
312
|
+
try {
|
|
313
|
+
const sessions = await this.fetchFromLocalFile(endpoint);
|
|
314
|
+
if (sessions) {
|
|
315
|
+
this.updateEndpointHealth(endpoint.name, true, Date.now() - startTime);
|
|
316
|
+
return sessions.map(s => this.enrichSession(s, endpoint));
|
|
317
|
+
}
|
|
318
|
+
} catch (err) {
|
|
319
|
+
logger.debug(`Local file fetch failed for ${endpoint.name}: ${err.message}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
this.updateEndpointHealth(endpoint.name, false, null, 'Failed to fetch sessions');
|
|
324
|
+
return [];
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Fetch sessions from HTTP API
|
|
329
|
+
* @param {GatewayEndpoint} endpoint
|
|
330
|
+
* @returns {Promise<Object[]|null>}
|
|
331
|
+
*/
|
|
332
|
+
fetchFromHttpApi(endpoint) {
|
|
333
|
+
return new Promise((resolve, reject) => {
|
|
334
|
+
const url = this.buildSessionsUrl(endpoint);
|
|
335
|
+
const client = url.startsWith('https:') ? https : http;
|
|
336
|
+
|
|
337
|
+
const options = {
|
|
338
|
+
timeout: GATEWAY.TIMEOUT_MS,
|
|
339
|
+
headers: {
|
|
340
|
+
'Accept': 'application/json',
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
if (endpoint.token) {
|
|
345
|
+
options.headers['Authorization'] = `Bearer ${endpoint.token}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const req = client.get(url, options, (res) => {
|
|
349
|
+
let data = '';
|
|
350
|
+
|
|
351
|
+
res.on('data', chunk => data += chunk);
|
|
352
|
+
res.on('end', () => {
|
|
353
|
+
if (res.statusCode === 200) {
|
|
354
|
+
try {
|
|
355
|
+
// Verify checksum if enabled and header present
|
|
356
|
+
const checksumResult = verifyResponseChecksum(res, data);
|
|
357
|
+
|
|
358
|
+
if (!checksumResult.verified) {
|
|
359
|
+
// Log checksum failure with metadata
|
|
360
|
+
const metadata = getChecksumMetadata(res);
|
|
361
|
+
logger.warn(`Checksum verification failed for ${endpoint.name}: ${checksumResult.error}`, {
|
|
362
|
+
endpoint: endpoint.name,
|
|
363
|
+
headerPresent: metadata.headerPresent,
|
|
364
|
+
strictMode: metadata.strictMode
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
reject(new ChecksumError(
|
|
368
|
+
`Response integrity check failed: ${checksumResult.error}`,
|
|
369
|
+
{
|
|
370
|
+
endpoint: endpoint.name,
|
|
371
|
+
headerName: metadata.headerName,
|
|
372
|
+
headerPresent: metadata.headerPresent
|
|
373
|
+
}
|
|
374
|
+
));
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Log successful checksum verification if header was present
|
|
379
|
+
if (checksumResult.checksum) {
|
|
380
|
+
logger.debug(`Checksum verified for ${endpoint.name}: ${checksumResult.checksum.substring(0, 16)}...`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const parsed = JSON.parse(data);
|
|
384
|
+
resolve(Array.isArray(parsed) ? parsed : Object.values(parsed));
|
|
385
|
+
} catch (err) {
|
|
386
|
+
if (err instanceof ChecksumError) {
|
|
387
|
+
reject(err);
|
|
388
|
+
} else {
|
|
389
|
+
reject(new GatewayError(`Invalid JSON response: ${err.message}`));
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
} else if (res.statusCode === 401 || res.statusCode === 403) {
|
|
393
|
+
reject(new AuthError(`Authentication failed for ${endpoint.name}`));
|
|
394
|
+
} else {
|
|
395
|
+
reject(new GatewayError(`HTTP ${res.statusCode}`));
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
req.on('error', (err) => {
|
|
401
|
+
reject(new NetworkError(`Connection error: ${err.message}`));
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
req.on('timeout', () => {
|
|
405
|
+
req.destroy();
|
|
406
|
+
reject(new TimeoutError('Request timeout'));
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
req.setTimeout(GATEWAY.TIMEOUT_MS);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Fetch sessions from local file system
|
|
415
|
+
* @param {GatewayEndpoint} endpoint
|
|
416
|
+
* @returns {Promise<Object[]|null>}
|
|
417
|
+
*/
|
|
418
|
+
async fetchFromLocalFile(endpoint) {
|
|
419
|
+
const sessionsPath = config.PATHS.AGENTS_DIR + '/main/sessions/sessions.json';
|
|
420
|
+
|
|
421
|
+
if (!fs.existsSync(sessionsPath)) {
|
|
422
|
+
return null;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const data = fs.readFileSync(sessionsPath, 'utf8');
|
|
426
|
+
const sessionsObj = JSON.parse(data);
|
|
427
|
+
|
|
428
|
+
if (!sessionsObj || typeof sessionsObj !== 'object') {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return Object.entries(sessionsObj).map(([key, session]) => ({
|
|
433
|
+
key,
|
|
434
|
+
channel: session.channel || 'unknown',
|
|
435
|
+
displayName: session.displayName || key,
|
|
436
|
+
updatedAt: session.updatedAt || session.lastMessageAt || 0,
|
|
437
|
+
sessionId: session.sessionId || key,
|
|
438
|
+
model: session.model || 'unknown',
|
|
439
|
+
contextTokens: session.contextWindow || session.contextTokens || 0,
|
|
440
|
+
totalTokens: session.totalTokens || 0,
|
|
441
|
+
kind: session.kind || 'other',
|
|
442
|
+
deliveryContext: session.deliveryContext || {},
|
|
443
|
+
systemSent: session.systemSent || false,
|
|
444
|
+
abortedLastRun: session.abortedLastRun || false,
|
|
445
|
+
lastChannel: session.lastChannel || session.channel || '',
|
|
446
|
+
lastTo: session.lastTo || '',
|
|
447
|
+
lastAccountId: session.lastAccountId || '',
|
|
448
|
+
transcriptPath: session.transcriptPath || '',
|
|
449
|
+
}));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Enrich session with gateway endpoint info
|
|
454
|
+
* @param {Object} session
|
|
455
|
+
* @param {GatewayEndpoint} endpoint
|
|
456
|
+
* @returns {AggregatedSession}
|
|
457
|
+
*/
|
|
458
|
+
enrichSession(session, endpoint) {
|
|
459
|
+
return {
|
|
460
|
+
...session,
|
|
461
|
+
gatewayEndpoint: endpoint.name,
|
|
462
|
+
gatewayHost: `${endpoint.host}:${endpoint.port}`,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Update endpoint health status
|
|
468
|
+
* @param {string} name - Endpoint name
|
|
469
|
+
* @param {boolean} reachable - Whether endpoint is reachable
|
|
470
|
+
* @param {number|null} latency - Response latency in ms
|
|
471
|
+
* @param {string} [error] - Error message if failed
|
|
472
|
+
* @param {boolean} [checksumVerified] - Whether checksum verification passed
|
|
473
|
+
* @param {boolean} [checksumFailed] - Whether checksum verification failed
|
|
474
|
+
*/
|
|
475
|
+
updateEndpointHealth(name, reachable, latency, error = null, checksumVerified = false, checksumFailed = false) {
|
|
476
|
+
const ep = this.getEndpoint(name);
|
|
477
|
+
if (!ep) return;
|
|
478
|
+
|
|
479
|
+
ep.reachable = reachable;
|
|
480
|
+
ep.lastSeen = reachable ? Date.now() : ep.lastSeen;
|
|
481
|
+
ep.error = error;
|
|
482
|
+
|
|
483
|
+
if (latency !== null) {
|
|
484
|
+
this.endpointLatency.set(name, latency);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (reachable) {
|
|
488
|
+
this.endpointFailCount.set(name, 0);
|
|
489
|
+
} else {
|
|
490
|
+
const currentFails = this.endpointFailCount.get(name) || 0;
|
|
491
|
+
this.endpointFailCount.set(name, currentFails + 1);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Track checksum verification status
|
|
495
|
+
if (checksumVerified) {
|
|
496
|
+
this.endpointChecksumVerified.set(name, true);
|
|
497
|
+
}
|
|
498
|
+
if (checksumFailed) {
|
|
499
|
+
const currentChecksumFails = this.endpointChecksumFailCount.get(name) || 0;
|
|
500
|
+
this.endpointChecksumFailCount.set(name, currentChecksumFails + 1);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Fetch sessions from all enabled endpoints
|
|
506
|
+
* @returns {Promise<{sessions: AggregatedSession[], stats: Object}>}
|
|
507
|
+
*/
|
|
508
|
+
async fetchAllSessions() {
|
|
509
|
+
const enabledEndpoints = this.getEnabledEndpoints();
|
|
510
|
+
|
|
511
|
+
if (enabledEndpoints.length === 0) {
|
|
512
|
+
logger.warn('No enabled gateway endpoints');
|
|
513
|
+
return { sessions: [], stats: { totalEndpoints: 0, reachableEndpoints: 0 } };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Fetch from all endpoints in parallel
|
|
517
|
+
const fetchPromises = enabledEndpoints.map(async (ep) => {
|
|
518
|
+
try {
|
|
519
|
+
const sessions = await this.fetchSessionsFromEndpoint(ep);
|
|
520
|
+
return { endpoint: ep.name, sessions, error: null };
|
|
521
|
+
} catch (err) {
|
|
522
|
+
logger.warn(`Failed to fetch from ${ep.name}: ${err.message}`);
|
|
523
|
+
return { endpoint: ep.name, sessions: [], error: err.message };
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const results = await Promise.all(fetchPromises);
|
|
528
|
+
|
|
529
|
+
// Aggregate sessions and stats
|
|
530
|
+
const allSessions = [];
|
|
531
|
+
let reachableCount = 0;
|
|
532
|
+
|
|
533
|
+
for (const result of results) {
|
|
534
|
+
if (!result.error) {
|
|
535
|
+
reachableCount++;
|
|
536
|
+
allSessions.push(...result.sessions);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const stats = {
|
|
541
|
+
totalEndpoints: enabledEndpoints.length,
|
|
542
|
+
reachableEndpoints: reachableCount,
|
|
543
|
+
unreachableEndpoints: enabledEndpoints.length - reachableCount,
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
logger.debug(`Fetched ${allSessions.length} sessions from ${reachableCount}/${enabledEndpoints.length} endpoints`);
|
|
547
|
+
|
|
548
|
+
return { sessions: allSessions, stats };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Get endpoint health summary
|
|
553
|
+
* @returns {Object[]}
|
|
554
|
+
*/
|
|
555
|
+
getEndpointHealth() {
|
|
556
|
+
return this.endpoints.map(ep => ({
|
|
557
|
+
name: ep.name,
|
|
558
|
+
host: ep.host,
|
|
559
|
+
port: ep.port,
|
|
560
|
+
enabled: ep.enabled,
|
|
561
|
+
reachable: ep.reachable || false,
|
|
562
|
+
lastSeen: ep.lastSeen || null,
|
|
563
|
+
latency: this.endpointLatency.get(ep.name) || null,
|
|
564
|
+
failCount: this.endpointFailCount.get(ep.name) || 0,
|
|
565
|
+
error: ep.error || null,
|
|
566
|
+
checksum: {
|
|
567
|
+
verified: this.endpointChecksumVerified.get(ep.name) || false,
|
|
568
|
+
failCount: this.endpointChecksumFailCount.get(ep.name) || 0,
|
|
569
|
+
enabled: config.CHECKSUM.ENABLED,
|
|
570
|
+
},
|
|
571
|
+
}));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Force a retry for a specific endpoint or all unreachable endpoints
|
|
576
|
+
* @param {string|null} endpointName - Name of endpoint to retry, or null for all unreachable
|
|
577
|
+
* @returns {Promise<Object>} - Result of retry attempts
|
|
578
|
+
*/
|
|
579
|
+
async forceRetry(endpointName = null) {
|
|
580
|
+
const results = [];
|
|
581
|
+
const targets = endpointName
|
|
582
|
+
? this.endpoints.filter(ep => ep.name === endpointName)
|
|
583
|
+
: this.endpoints.filter(ep => ep.enabled && !ep.reachable);
|
|
584
|
+
|
|
585
|
+
if (targets.length === 0) {
|
|
586
|
+
return { attempted: 0, results: [] };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
logger.info(`Force retrying ${targets.length} endpoint(s)`);
|
|
590
|
+
|
|
591
|
+
for (const ep of targets) {
|
|
592
|
+
// Reset fail count for fresh attempt
|
|
593
|
+
this.endpointFailCount.set(ep.name, 0);
|
|
594
|
+
|
|
595
|
+
try {
|
|
596
|
+
const sessions = await this.fetchSessionsFromEndpoint(ep);
|
|
597
|
+
results.push({
|
|
598
|
+
name: ep.name,
|
|
599
|
+
success: ep.reachable === true,
|
|
600
|
+
sessions: sessions.length,
|
|
601
|
+
latency: this.endpointLatency.get(ep.name) || null,
|
|
602
|
+
error: ep.error || null,
|
|
603
|
+
});
|
|
604
|
+
} catch (err) {
|
|
605
|
+
results.push({
|
|
606
|
+
name: ep.name,
|
|
607
|
+
success: false,
|
|
608
|
+
sessions: 0,
|
|
609
|
+
latency: null,
|
|
610
|
+
error: err.message,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const successCount = results.filter(r => r.success).length;
|
|
616
|
+
logger.info(`Force retry complete: ${successCount}/${results.length} endpoints reachable`);
|
|
617
|
+
|
|
618
|
+
return {
|
|
619
|
+
attempted: results.length,
|
|
620
|
+
successful: successCount,
|
|
621
|
+
results,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Get the number of consecutive failures for an endpoint
|
|
627
|
+
* @param {string} name - Endpoint name
|
|
628
|
+
* @returns {number} - Number of consecutive failures
|
|
629
|
+
*/
|
|
630
|
+
getEndpointFailCount(name) {
|
|
631
|
+
return this.endpointFailCount.get(name) || 0;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* Clear the failure count for an endpoint
|
|
636
|
+
* @param {string} name - Endpoint name
|
|
637
|
+
*/
|
|
638
|
+
clearEndpointFailCount(name) {
|
|
639
|
+
this.endpointFailCount.set(name, 0);
|
|
640
|
+
logger.debug(`Cleared fail count for endpoint: ${name}`);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Get the total failure count across all endpoints
|
|
645
|
+
* @returns {number} - Total number of consecutive failures
|
|
646
|
+
*/
|
|
647
|
+
getTotalFailCount() {
|
|
648
|
+
let total = 0;
|
|
649
|
+
for (const count of this.endpointFailCount.values()) {
|
|
650
|
+
total += count;
|
|
651
|
+
}
|
|
652
|
+
return total;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Clear all failure counts for all endpoints
|
|
657
|
+
*/
|
|
658
|
+
clearAllFailCounts() {
|
|
659
|
+
for (const name of this.endpointFailCount.keys()) {
|
|
660
|
+
this.endpointFailCount.set(name, 0);
|
|
661
|
+
}
|
|
662
|
+
logger.debug('Cleared all endpoint failure counts');
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Get settings object for saving
|
|
667
|
+
* @returns {Object}
|
|
668
|
+
*/
|
|
669
|
+
getSettingsForSave() {
|
|
670
|
+
return {
|
|
671
|
+
gatewayEndpoints: this.endpoints.map(ep => ({
|
|
672
|
+
name: ep.name,
|
|
673
|
+
host: ep.host,
|
|
674
|
+
port: ep.port,
|
|
675
|
+
token: ep.token,
|
|
676
|
+
enabled: ep.enabled,
|
|
677
|
+
type: ep.type,
|
|
678
|
+
})),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// Export singleton instance
|
|
684
|
+
export const gatewayManager = new GatewayManager();
|
|
685
|
+
export default gatewayManager;
|