@jagilber-org/index-server 1.26.11 → 1.27.2
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/CHANGELOG.md +53 -1
- package/dist/config/dashboardConfig.d.ts +11 -4
- package/dist/config/dashboardConfig.js +1 -4
- package/dist/dashboard/client/admin.html +57 -27
- package/dist/dashboard/client/css/admin.css +54 -0
- package/dist/dashboard/client/js/admin.config.js +3 -6
- package/dist/dashboard/client/js/admin.embeddings.js +63 -4
- package/dist/dashboard/client/js/admin.events.js +256 -0
- package/dist/dashboard/client/js/admin.maintenance.js +75 -32
- package/dist/dashboard/client/js/admin.sessions.js +1 -1
- package/dist/dashboard/server/AdminPanel.js +83 -6
- package/dist/dashboard/server/AdminPanelConfig.d.ts +12 -2
- package/dist/dashboard/server/AdminPanelConfig.js +48 -19
- package/dist/dashboard/server/ApiRoutes.d.ts +5 -4
- package/dist/dashboard/server/ApiRoutes.js +40 -35
- package/dist/dashboard/server/DashboardServer.js +13 -0
- package/dist/dashboard/server/routes/admin.routes.js +143 -17
- package/dist/dashboard/server/routes/embeddings.routes.js +91 -1
- package/dist/dashboard/server/routes/index.js +11 -9
- package/dist/server/sdkServer.js +12 -4
- package/dist/services/embeddingService.d.ts +2 -0
- package/dist/services/embeddingService.js +16 -4
- package/dist/services/embeddingTrigger.d.ts +33 -0
- package/dist/services/embeddingTrigger.js +86 -0
- package/dist/services/eventBuffer.d.ts +45 -0
- package/dist/services/eventBuffer.js +110 -0
- package/dist/services/handlers/instructions.import.js +71 -13
- package/dist/services/handlers.dashboardConfig.js +82 -2
- package/dist/services/indexContext.d.ts +18 -0
- package/dist/services/indexContext.js +138 -30
- package/dist/services/logger.js +9 -0
- package/dist/services/manifestManager.js +11 -1
- package/dist/services/seedBootstrap.js +5 -1
- package/dist/services/storage/factory.d.ts +2 -0
- package/dist/services/storage/factory.js +12 -1
- package/dist/services/tracing.js +3 -1
- package/package.json +12 -2
- package/schemas/index-server.code-schema.json +7424 -1588
- package/schemas/manifest.json +3 -3
- package/server.json +3 -3
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Owns the AdminConfig data structure and provides CRUD methods for
|
|
6
6
|
* reading and updating admin panel configuration.
|
|
7
|
+
*
|
|
8
|
+
* Updates here mutate `process.env.INDEX_SERVER_*` and call `reloadRuntimeConfig()`
|
|
9
|
+
* so the dashboard "Server Configuration" panel reflects (and applies to) the
|
|
10
|
+
* single runtimeConfig source of truth.
|
|
7
11
|
*/
|
|
8
12
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
13
|
exports.AdminPanelConfig = void 0;
|
|
@@ -23,8 +27,7 @@ class AdminPanelConfig {
|
|
|
23
27
|
enableVerboseLogging: !!serverHttp?.verboseLogging,
|
|
24
28
|
enableMutation: runtimeConfig.mutation.enabled,
|
|
25
29
|
rateLimit: {
|
|
26
|
-
|
|
27
|
-
maxRequests: 100
|
|
30
|
+
perMinute: serverHttp?.rateLimitPerMinute ?? 0
|
|
28
31
|
}
|
|
29
32
|
},
|
|
30
33
|
indexSettings: {
|
|
@@ -40,14 +43,18 @@ class AdminPanelConfig {
|
|
|
40
43
|
}
|
|
41
44
|
};
|
|
42
45
|
}
|
|
46
|
+
/** Re-read from runtime config so callers always see the current authoritative values. */
|
|
43
47
|
getAdminConfig() {
|
|
48
|
+
this.config = this.loadDefaultConfig();
|
|
44
49
|
return JSON.parse(JSON.stringify(this.config));
|
|
45
50
|
}
|
|
46
51
|
updateAdminConfig(updates) {
|
|
47
52
|
try {
|
|
48
|
-
|
|
49
|
-
this.applyConfigChanges(updates);
|
|
50
|
-
|
|
53
|
+
const applied = [];
|
|
54
|
+
this.applyConfigChanges(updates, applied);
|
|
55
|
+
// Refresh in-memory snapshot from runtime config (post-reload).
|
|
56
|
+
this.config = this.loadDefaultConfig();
|
|
57
|
+
return { success: true, message: 'Configuration updated successfully', appliedFields: applied };
|
|
51
58
|
}
|
|
52
59
|
catch (error) {
|
|
53
60
|
return {
|
|
@@ -56,21 +63,43 @@ class AdminPanelConfig {
|
|
|
56
63
|
};
|
|
57
64
|
}
|
|
58
65
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Apply incoming serverSettings to `process.env.INDEX_SERVER_*` and reload runtime config
|
|
68
|
+
* so the dashboard form actually drives behavior. Bind every editable field to its env var
|
|
69
|
+
* (per constitution S-4: "All environment configuration must flow through runtimeConfig.ts").
|
|
70
|
+
*/
|
|
71
|
+
applyConfigChanges(updates, applied = []) {
|
|
72
|
+
if (!updates.serverSettings)
|
|
73
|
+
return;
|
|
74
|
+
const s = updates.serverSettings;
|
|
75
|
+
let needsReload = false;
|
|
76
|
+
if (s.enableVerboseLogging !== undefined) {
|
|
77
|
+
process.env.INDEX_SERVER_VERBOSE_LOGGING = s.enableVerboseLogging ? '1' : '0';
|
|
78
|
+
applied.push('verboseLogging');
|
|
79
|
+
needsReload = true;
|
|
80
|
+
}
|
|
81
|
+
if (s.enableMutation !== undefined) {
|
|
82
|
+
process.env.INDEX_SERVER_MUTATION = s.enableMutation ? '1' : '0';
|
|
83
|
+
applied.push('mutation');
|
|
84
|
+
needsReload = true;
|
|
85
|
+
}
|
|
86
|
+
if (s.maxConnections !== undefined && Number.isFinite(s.maxConnections) && s.maxConnections > 0) {
|
|
87
|
+
process.env.INDEX_SERVER_MAX_CONNECTIONS = String(Math.floor(s.maxConnections));
|
|
88
|
+
applied.push('maxConnections');
|
|
89
|
+
needsReload = true;
|
|
90
|
+
}
|
|
91
|
+
if (s.requestTimeout !== undefined && Number.isFinite(s.requestTimeout) && s.requestTimeout > 0) {
|
|
92
|
+
process.env.INDEX_SERVER_REQUEST_TIMEOUT = String(Math.floor(s.requestTimeout));
|
|
93
|
+
applied.push('requestTimeout');
|
|
94
|
+
needsReload = true;
|
|
95
|
+
}
|
|
96
|
+
if (s.rateLimit && s.rateLimit.perMinute !== undefined && Number.isFinite(s.rateLimit.perMinute) && s.rateLimit.perMinute >= 0) {
|
|
97
|
+
process.env.INDEX_SERVER_RATE_LIMIT = String(Math.floor(s.rateLimit.perMinute));
|
|
98
|
+
applied.push('rateLimitPerMinute');
|
|
99
|
+
needsReload = true;
|
|
73
100
|
}
|
|
101
|
+
if (needsReload)
|
|
102
|
+
(0, runtimeConfig_1.reloadRuntimeConfig)();
|
|
74
103
|
}
|
|
75
104
|
/** Session timeout in milliseconds — consumed by state management. */
|
|
76
105
|
get sessionTimeout() {
|
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
import { Router } from 'express';
|
|
9
9
|
export interface ApiRoutesOptions {
|
|
10
10
|
enableCors?: boolean;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Optional override for the rate-limit (requests per 60s window).
|
|
13
|
+
* `0` disables. When omitted, falls back to `httpCfg.rateLimitPerMinute`.
|
|
14
|
+
*/
|
|
15
|
+
rateLimitPerMinute?: number;
|
|
15
16
|
}
|
|
16
17
|
export declare function createApiRoutes(options?: ApiRoutesOptions): Router;
|
|
17
18
|
export default createApiRoutes;
|
|
@@ -49,11 +49,41 @@ const runtimeConfig_js_1 = require("../../config/runtimeConfig.js");
|
|
|
49
49
|
const index_js_1 = require("./routes/index.js");
|
|
50
50
|
const ensureLoadedMiddleware_js_1 = require("./middleware/ensureLoadedMiddleware.js");
|
|
51
51
|
const logger_js_1 = require("../../services/logger.js");
|
|
52
|
+
/**
|
|
53
|
+
* Path prefixes whose endpoints are *unconditionally* exempt from the dashboard
|
|
54
|
+
* HTTP rate limiter. These are bulk-shaped, operator-driven operations
|
|
55
|
+
* (backup, restore, import/export, normalize) where the size of a single
|
|
56
|
+
* request — not the *frequency* of requests — is the natural cost driver.
|
|
57
|
+
*
|
|
58
|
+
* Matching is done with `req.path.startsWith(prefix)` against the path *as
|
|
59
|
+
* mounted on the /api router* (so prefixes do not include the leading "/api").
|
|
60
|
+
*
|
|
61
|
+
* Note: the MCP tool surface (index_import / index_export / promote_from_repo
|
|
62
|
+
* / restore handlers) is already outside this HTTP limiter, so no entries are
|
|
63
|
+
* needed for those.
|
|
64
|
+
*/
|
|
65
|
+
const BULK_EXEMPT_PREFIXES = [
|
|
66
|
+
'/admin/maintenance/normalize',
|
|
67
|
+
'/admin/maintenance/backup', // covers backup, backup/import, backup/:id, backup/:id/export
|
|
68
|
+
'/admin/maintenance/backups', // covers backups, backups/prune
|
|
69
|
+
'/admin/maintenance/restore',
|
|
70
|
+
'/charts/export',
|
|
71
|
+
'/sqlite/backup', // covers /sqlite/backup and /sqlite/backups
|
|
72
|
+
'/sqlite/restore',
|
|
73
|
+
'/sqlite/export',
|
|
74
|
+
];
|
|
75
|
+
function isBulkExempt(reqPath) {
|
|
76
|
+
for (const prefix of BULK_EXEMPT_PREFIXES) {
|
|
77
|
+
if (reqPath === prefix || reqPath.startsWith(prefix + '/'))
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
52
82
|
function createApiRoutes(options = {}) {
|
|
53
83
|
const router = (0, express_1.Router)();
|
|
54
84
|
const metricsCollector = (0, MetricsCollector_js_1.getMetricsCollector)();
|
|
55
85
|
const httpCfg = (0, runtimeConfig_js_1.getRuntimeConfig)().dashboard.http;
|
|
56
|
-
const
|
|
86
|
+
const perMinute = options.rateLimitPerMinute ?? httpCfg.rateLimitPerMinute;
|
|
57
87
|
// CORS middleware (if enabled)
|
|
58
88
|
// Security: only allow loopback origins (localhost, 127.0.0.1, [::1]) to prevent
|
|
59
89
|
// cross-origin attacks. No wildcard (*) origins; credentials are not exposed.
|
|
@@ -71,53 +101,28 @@ function createApiRoutes(options = {}) {
|
|
|
71
101
|
}
|
|
72
102
|
// JSON middleware
|
|
73
103
|
router.use(express_1.default.json());
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
104
|
+
// Rate limit (single tier, fixed 60s window). Disabled when perMinute === 0.
|
|
105
|
+
// Bulk routes (backup/restore/import/export/normalize) are unconditionally
|
|
106
|
+
// exempt via BULK_EXEMPT_PREFIXES — see `skip` below.
|
|
107
|
+
if (perMinute > 0) {
|
|
108
|
+
const windowMs = 60_000;
|
|
77
109
|
router.use((0, express_rate_limit_1.default)({
|
|
78
|
-
windowMs
|
|
79
|
-
max:
|
|
110
|
+
windowMs,
|
|
111
|
+
max: perMinute,
|
|
80
112
|
standardHeaders: true,
|
|
81
113
|
legacyHeaders: true,
|
|
82
114
|
validate: { ip: false },
|
|
83
|
-
skip: (req) => req.method === 'OPTIONS',
|
|
115
|
+
skip: (req) => req.method === 'OPTIONS' || isBulkExempt(req.path),
|
|
84
116
|
keyGenerator: (req) => {
|
|
85
117
|
const clientIp = req.ip || req.socket.remoteAddress;
|
|
86
118
|
return clientIp ? (0, express_rate_limit_1.ipKeyGenerator)(clientIp) : 'unknown';
|
|
87
119
|
},
|
|
88
120
|
handler: (_req, res) => {
|
|
89
|
-
const retryAfter = Number(res.getHeader('Retry-After') || Math.ceil(
|
|
121
|
+
const retryAfter = Number(res.getHeader('Retry-After') || Math.ceil(windowMs / 1000));
|
|
90
122
|
res.status(429).json({
|
|
91
123
|
error: 'Too Many Requests',
|
|
92
124
|
message: `Rate limit exceeded. Try again in ${retryAfter} second(s).`,
|
|
93
125
|
retryAfterSeconds: retryAfter,
|
|
94
|
-
tier: 'global',
|
|
95
|
-
timestamp: Date.now(),
|
|
96
|
-
});
|
|
97
|
-
},
|
|
98
|
-
}));
|
|
99
|
-
// Stricter rate limit for mutation endpoints (POST/PUT/PATCH/DELETE).
|
|
100
|
-
// Defaults to runtimeConfig httpCfg.rateLimitMutationMax (env-configurable),
|
|
101
|
-
// bounded by the global per-window cap so mutation-tier never exceeds global.
|
|
102
|
-
const mutationMax = Math.max(1, Math.min(httpCfg.rateLimitMutationMax, rateLimitOpts.max));
|
|
103
|
-
router.use((0, express_rate_limit_1.default)({
|
|
104
|
-
windowMs: rateLimitOpts.windowMs,
|
|
105
|
-
max: mutationMax,
|
|
106
|
-
standardHeaders: true,
|
|
107
|
-
legacyHeaders: false,
|
|
108
|
-
validate: { ip: false },
|
|
109
|
-
skip: (req) => req.method === 'OPTIONS' || req.method === 'GET' || req.method === 'HEAD',
|
|
110
|
-
keyGenerator: (req) => {
|
|
111
|
-
const clientIp = req.ip || req.socket.remoteAddress;
|
|
112
|
-
return `mutation:${clientIp ? (0, express_rate_limit_1.ipKeyGenerator)(clientIp) : 'unknown'}`;
|
|
113
|
-
},
|
|
114
|
-
handler: (_req, res) => {
|
|
115
|
-
const retryAfter = Number(res.getHeader('Retry-After') || Math.ceil(rateLimitOpts.windowMs / 1000));
|
|
116
|
-
res.status(429).json({
|
|
117
|
-
error: 'Too Many Requests',
|
|
118
|
-
message: `Mutation rate limit exceeded. Try again in ${retryAfter} second(s).`,
|
|
119
|
-
retryAfterSeconds: retryAfter,
|
|
120
|
-
tier: 'mutation',
|
|
121
126
|
timestamp: Date.now(),
|
|
122
127
|
});
|
|
123
128
|
},
|
|
@@ -146,6 +146,19 @@ class DashboardServer {
|
|
|
146
146
|
}
|
|
147
147
|
next();
|
|
148
148
|
});
|
|
149
|
+
// Structured request logger — every dashboard HTTP hit lands in mcp-server.log.
|
|
150
|
+
// Critical for diagnosing import/restore failures where stderr output is lost
|
|
151
|
+
// when the MCP server runs under stdio transport without an attached TTY.
|
|
152
|
+
this.app.use((req, res, next) => {
|
|
153
|
+
const start = Date.now();
|
|
154
|
+
const ctype = req.header('content-type') || '';
|
|
155
|
+
const clen = req.header('content-length') || '';
|
|
156
|
+
(0, logger_js_1.logInfo)('[http] request', { method: req.method, url: req.originalUrl || req.url, ctype, clen });
|
|
157
|
+
res.on('finish', () => {
|
|
158
|
+
(0, logger_js_1.logInfo)('[http] response', { method: req.method, url: req.originalUrl || req.url, status: res.statusCode, ms: Date.now() - start });
|
|
159
|
+
});
|
|
160
|
+
next();
|
|
161
|
+
});
|
|
149
162
|
this.app.use(express_1.default.json());
|
|
150
163
|
this.app.use(express_1.default.static(path_1.default.join(__dirname, '..', 'client'), {
|
|
151
164
|
etag: true,
|
|
@@ -23,6 +23,8 @@ const AdminPanel_js_1 = require("../AdminPanel.js");
|
|
|
23
23
|
const WebSocketManager_js_1 = require("../WebSocketManager.js");
|
|
24
24
|
const featureFlags_js_1 = require("../../../services/featureFlags.js");
|
|
25
25
|
const handlers_dashboardConfig_js_1 = require("../../../services/handlers.dashboardConfig.js");
|
|
26
|
+
const runtimeConfig_js_1 = require("../../../config/runtimeConfig.js");
|
|
27
|
+
const eventBuffer_js_1 = require("../../../services/eventBuffer.js");
|
|
26
28
|
const registry_js_1 = require("../../../server/registry.js");
|
|
27
29
|
const adminAuth_js_1 = require("./adminAuth.js");
|
|
28
30
|
const logger_js_1 = require("../../../services/logger.js");
|
|
@@ -91,10 +93,35 @@ function createAdminRoutes(metricsCollector) {
|
|
|
91
93
|
try {
|
|
92
94
|
const updates = req.body;
|
|
93
95
|
const result = adminPanel.updateAdminConfig(updates);
|
|
94
|
-
// Feature flag persistence
|
|
96
|
+
// Feature flag persistence: split incoming flags into
|
|
97
|
+
// (a) namespace flags (e.g. response_envelope_v1) — persisted to flags.json
|
|
98
|
+
// (b) INDEX_SERVER_* boolean toggles — routed to process.env + reloadRuntimeConfig()
|
|
99
|
+
// so dashboard toggles actually take effect at runtime (issue #282 / fix #4).
|
|
95
100
|
if (updates.featureFlags && typeof updates.featureFlags === 'object') {
|
|
96
101
|
try {
|
|
97
|
-
(0,
|
|
102
|
+
const knownEnv = new Set((0, handlers_dashboardConfig_js_1.getFlagRegistrySnapshot)().filter(f => f.type === 'boolean').map(f => f.name));
|
|
103
|
+
const namespaceFlags = {};
|
|
104
|
+
let envChanged = 0;
|
|
105
|
+
const appliedEnv = [];
|
|
106
|
+
for (const [k, v] of Object.entries(updates.featureFlags)) {
|
|
107
|
+
if (typeof v !== 'boolean')
|
|
108
|
+
continue;
|
|
109
|
+
const upper = String(k).toUpperCase();
|
|
110
|
+
if (upper.startsWith('INDEX_SERVER_') && knownEnv.has(upper)) {
|
|
111
|
+
process.env[upper] = v ? '1' : '0';
|
|
112
|
+
appliedEnv.push(upper);
|
|
113
|
+
envChanged++;
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
namespaceFlags[k] = v;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (envChanged > 0) {
|
|
120
|
+
(0, runtimeConfig_js_1.reloadRuntimeConfig)();
|
|
121
|
+
(0, logger_js_1.logInfo)(`[admin] applied ${envChanged} INDEX_SERVER_* toggle(s): ${appliedEnv.join(', ')}`);
|
|
122
|
+
}
|
|
123
|
+
if (Object.keys(namespaceFlags).length > 0)
|
|
124
|
+
(0, featureFlags_js_1.updateFlags)(namespaceFlags);
|
|
98
125
|
}
|
|
99
126
|
catch (e) {
|
|
100
127
|
(0, logger_js_1.logWarn)('[API] feature flag update failed:', e instanceof Error ? e.message : e);
|
|
@@ -340,7 +367,9 @@ function createAdminRoutes(metricsCollector) {
|
|
|
340
367
|
router.post('/admin/maintenance/restore', (req, res) => {
|
|
341
368
|
try {
|
|
342
369
|
const { backupId } = req.body || {};
|
|
370
|
+
(0, logger_js_1.logInfo)('[admin] restore requested', { backupId });
|
|
343
371
|
const result = adminPanel.restoreBackup(backupId);
|
|
372
|
+
(0, logger_js_1.logInfo)('[admin] restore result', { backupId, success: result.success, restored: result.restored ?? 0, message: result.message });
|
|
344
373
|
if (result.success) {
|
|
345
374
|
res.json({ success: true, message: result.message, restored: result.restored, timestamp: Date.now() });
|
|
346
375
|
}
|
|
@@ -428,28 +457,75 @@ function createAdminRoutes(metricsCollector) {
|
|
|
428
457
|
/**
|
|
429
458
|
* POST /api/admin/maintenance/backup/import - Import backup from uploaded JSON bundle or zip archive
|
|
430
459
|
* body: { manifest?: object, files: { [filename]: content } } or raw zip bytes
|
|
460
|
+
* query: ?restore=1 - if set, immediately restore the imported backup (one-click "Restore from File")
|
|
431
461
|
*/
|
|
432
462
|
router.post('/admin/maintenance/backup/import', (0, express_1.raw)({ type: ['application/zip', 'application/octet-stream'], limit: '100mb' }), (req, res) => {
|
|
463
|
+
// Narrow req.query.restore safely — Express may return string | string[] | ParsedQs | ParsedQs[].
|
|
464
|
+
// Only treat a literal scalar string '1' / 'true' (case-insensitive) as opt-in; arrays/objects are rejected.
|
|
465
|
+
const restoreParam = req.query?.restore;
|
|
466
|
+
const wantRestore = typeof restoreParam === 'string' && (restoreParam === '1' || restoreParam.toLowerCase() === 'true');
|
|
467
|
+
const ctype = req.header('content-type') || '';
|
|
468
|
+
const rawBody = req.body;
|
|
469
|
+
// Refine req.body through typed locals so downstream uses don't rely on `as Buffer` casts.
|
|
470
|
+
// CodeQL js/type-confusion-through-parameter-tampering does NOT recognize Buffer.isBuffer
|
|
471
|
+
// as a sanitizer; use `instanceof Buffer` (native JS predicate, modeled by CodeQL).
|
|
472
|
+
// Array.isArray IS recognized. Avoid `.length` interpolation on tainted values.
|
|
473
|
+
const bodyBuffer = rawBody instanceof Buffer ? rawBody : null;
|
|
474
|
+
const bodyArray = Array.isArray(rawBody) ? rawBody : null;
|
|
475
|
+
const bodyObject = rawBody !== null
|
|
476
|
+
&& typeof rawBody === 'object'
|
|
477
|
+
&& !Array.isArray(rawBody)
|
|
478
|
+
&& !(rawBody instanceof Buffer)
|
|
479
|
+
? rawBody
|
|
480
|
+
: null;
|
|
481
|
+
const bodyKind = bodyBuffer !== null
|
|
482
|
+
? 'buffer'
|
|
483
|
+
: bodyArray !== null
|
|
484
|
+
? 'array'
|
|
485
|
+
: bodyObject !== null
|
|
486
|
+
? 'json'
|
|
487
|
+
: typeof rawBody;
|
|
488
|
+
(0, logger_js_1.logInfo)('[admin] backup/import received', { ctype, body: bodyKind, restore: wantRestore });
|
|
433
489
|
try {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
const
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
}
|
|
440
|
-
return res.status(400).json({ success: false, error: result.message, timestamp: Date.now() });
|
|
490
|
+
let importResult;
|
|
491
|
+
if (bodyBuffer !== null && bodyBuffer.length > 0) {
|
|
492
|
+
const filenameHeader = req.header('x-backup-filename') || req.header('x-file-name');
|
|
493
|
+
const sourceName = typeof filenameHeader === 'string' ? filenameHeader : undefined;
|
|
494
|
+
importResult = adminPanel.importZipBackup(bodyBuffer, sourceName);
|
|
441
495
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
496
|
+
else {
|
|
497
|
+
if (bodyObject === null) {
|
|
498
|
+
(0, logger_js_1.logWarn)('[admin] backup/import rejected', { reason: 'body-not-json-object', ctype });
|
|
499
|
+
return res.status(400).json({ success: false, error: 'Request body must be a JSON object containing a "files" object', timestamp: Date.now() });
|
|
500
|
+
}
|
|
501
|
+
const bundle = bodyObject;
|
|
502
|
+
const files = bundle.files;
|
|
503
|
+
if (!files || typeof files !== 'object' || Array.isArray(files)) {
|
|
504
|
+
(0, logger_js_1.logWarn)('[admin] backup/import rejected', { reason: 'missing-or-invalid-files-object' });
|
|
505
|
+
return res.status(400).json({ success: false, error: 'Request body must contain a "files" object', timestamp: Date.now() });
|
|
506
|
+
}
|
|
507
|
+
importResult = adminPanel.importBackup({
|
|
508
|
+
manifest: typeof bundle.manifest === 'object' && bundle.manifest !== null && !Array.isArray(bundle.manifest)
|
|
509
|
+
? bundle.manifest
|
|
510
|
+
: undefined,
|
|
511
|
+
files: files,
|
|
512
|
+
});
|
|
445
513
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
res.json({ success:
|
|
514
|
+
if (!importResult.success) {
|
|
515
|
+
(0, logger_js_1.logWarn)('[admin] backup/import failed', { message: importResult.message });
|
|
516
|
+
return res.status(400).json({ success: false, error: importResult.message, timestamp: Date.now() });
|
|
449
517
|
}
|
|
450
|
-
|
|
451
|
-
|
|
518
|
+
(0, logger_js_1.logInfo)('[admin] backup/import ok', { backupId: importResult.backupId, files: importResult.files });
|
|
519
|
+
if (wantRestore && importResult.backupId) {
|
|
520
|
+
(0, logger_js_1.logInfo)('[admin] backup/import auto-restore start', { backupId: importResult.backupId });
|
|
521
|
+
const restoreResult = adminPanel.restoreBackup(importResult.backupId);
|
|
522
|
+
(0, logger_js_1.logInfo)('[admin] backup/import auto-restore result', { backupId: importResult.backupId, success: restoreResult.success, restored: restoreResult.restored ?? 0, message: restoreResult.message });
|
|
523
|
+
if (!restoreResult.success) {
|
|
524
|
+
return res.status(500).json({ success: false, error: `Imported as ${importResult.backupId} but restore failed: ${restoreResult.message}`, backupId: importResult.backupId, files: importResult.files, restored: 0, timestamp: Date.now() });
|
|
525
|
+
}
|
|
526
|
+
return res.json({ success: true, message: `Imported and restored ${importResult.backupId} (${restoreResult.restored ?? 0} files)`, backupId: importResult.backupId, files: importResult.files, restored: restoreResult.restored ?? 0, restored_applied: true, timestamp: Date.now() });
|
|
452
527
|
}
|
|
528
|
+
return res.json({ success: true, message: importResult.message, backupId: importResult.backupId, files: importResult.files, timestamp: Date.now() });
|
|
453
529
|
}
|
|
454
530
|
catch (error) {
|
|
455
531
|
(0, logger_js_1.logError)('[API] Import backup error:', error);
|
|
@@ -578,5 +654,55 @@ function createAdminRoutes(metricsCollector) {
|
|
|
578
654
|
});
|
|
579
655
|
}
|
|
580
656
|
});
|
|
657
|
+
/**
|
|
658
|
+
* GET /api/admin/events - Recent WARN/ERROR events from the in-memory ring buffer
|
|
659
|
+
* Query: ?since=<id>&level=WARN|ERROR&limit=<n>
|
|
660
|
+
*/
|
|
661
|
+
router.get('/admin/events', (req, res) => {
|
|
662
|
+
try {
|
|
663
|
+
const since = req.query.since !== undefined ? parseInt(String(req.query.since), 10) : undefined;
|
|
664
|
+
const limit = req.query.limit !== undefined ? parseInt(String(req.query.limit), 10) : undefined;
|
|
665
|
+
const levelRaw = String(req.query.level || '').toUpperCase();
|
|
666
|
+
const level = (levelRaw === 'WARN' || levelRaw === 'ERROR') ? levelRaw : undefined;
|
|
667
|
+
const events = (0, eventBuffer_js_1.listEvents)({
|
|
668
|
+
sinceId: Number.isFinite(since) ? since : undefined,
|
|
669
|
+
limit: Number.isFinite(limit) ? limit : undefined,
|
|
670
|
+
level,
|
|
671
|
+
});
|
|
672
|
+
const counts = (0, eventBuffer_js_1.eventCounts)(0);
|
|
673
|
+
res.json({ success: true, events, counts, timestamp: Date.now() });
|
|
674
|
+
}
|
|
675
|
+
catch (error) {
|
|
676
|
+
(0, logger_js_1.logError)('[API] Get events error:', error);
|
|
677
|
+
res.status(500).json({ success: false, error: 'Failed to read events' });
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
/**
|
|
681
|
+
* GET /api/admin/events/counts - Lightweight counts for nav-bubble polling
|
|
682
|
+
*/
|
|
683
|
+
router.get('/admin/events/counts', (req, res) => {
|
|
684
|
+
try {
|
|
685
|
+
const since = req.query.since !== undefined ? parseInt(String(req.query.since), 10) : 0;
|
|
686
|
+
const counts = (0, eventBuffer_js_1.eventCounts)(Number.isFinite(since) ? since : 0);
|
|
687
|
+
res.json({ success: true, counts, timestamp: Date.now() });
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
(0, logger_js_1.logError)('[API] Get event counts error:', error);
|
|
691
|
+
res.status(500).json({ success: false, error: 'Failed to read event counts' });
|
|
692
|
+
}
|
|
693
|
+
});
|
|
694
|
+
/**
|
|
695
|
+
* DELETE /api/admin/events - Clear the buffer (also acts as "mark all read")
|
|
696
|
+
*/
|
|
697
|
+
router.delete('/admin/events', (_req, res) => {
|
|
698
|
+
try {
|
|
699
|
+
(0, eventBuffer_js_1.clearEvents)();
|
|
700
|
+
res.json({ success: true, message: 'Events cleared', timestamp: Date.now() });
|
|
701
|
+
}
|
|
702
|
+
catch (error) {
|
|
703
|
+
(0, logger_js_1.logError)('[API] Clear events error:', error);
|
|
704
|
+
res.status(500).json({ success: false, error: 'Failed to clear events' });
|
|
705
|
+
}
|
|
706
|
+
});
|
|
581
707
|
return router;
|
|
582
708
|
}
|
|
@@ -211,6 +211,85 @@ function createEmbeddingsRoutes(embeddingPathOverride, embeddingStore) {
|
|
|
211
211
|
});
|
|
212
212
|
}
|
|
213
213
|
});
|
|
214
|
+
// GET /embeddings/status — surface model + cache state for the Embeddings tab
|
|
215
|
+
router.get('/embeddings/status', (_req, res) => {
|
|
216
|
+
try {
|
|
217
|
+
const config = (0, runtimeConfig_js_1.getRuntimeConfig)();
|
|
218
|
+
const sem = config.semantic;
|
|
219
|
+
if (!sem.enabled) {
|
|
220
|
+
return res.json({
|
|
221
|
+
success: true,
|
|
222
|
+
enabled: false,
|
|
223
|
+
ready: false,
|
|
224
|
+
state: 'disabled',
|
|
225
|
+
message: 'Semantic embeddings are disabled. Set INDEX_SERVER_SEMANTIC_ENABLED=1 and restart to enable.',
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const readiness = (0, embeddingService_js_1.checkModelReadiness)(sem.model, sem.cacheDir, sem.localOnly);
|
|
229
|
+
let embeddingsFileExists = false;
|
|
230
|
+
let embeddingsCount = 0;
|
|
231
|
+
let embeddingsModelName;
|
|
232
|
+
try {
|
|
233
|
+
if (embeddingStore) {
|
|
234
|
+
const loaded = embeddingStore.load();
|
|
235
|
+
if (loaded) {
|
|
236
|
+
embeddingsFileExists = true;
|
|
237
|
+
embeddingsCount = Object.keys(loaded.embeddings || {}).length;
|
|
238
|
+
embeddingsModelName = loaded.modelName ?? undefined;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
const file = embeddingPathOverride ?? sem.embeddingPath;
|
|
243
|
+
if (file && node_fs_1.default.existsSync(file)) {
|
|
244
|
+
embeddingsFileExists = true;
|
|
245
|
+
const raw = node_fs_1.default.readFileSync(file, 'utf8');
|
|
246
|
+
const parsed = JSON.parse(raw);
|
|
247
|
+
embeddingsCount = parsed.embeddings ? Object.keys(parsed.embeddings).length : 0;
|
|
248
|
+
embeddingsModelName = parsed.modelName ?? undefined;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
catch { /* tolerate parse / read failures — surface as not present */ }
|
|
253
|
+
// State machine for the dashboard banner.
|
|
254
|
+
let state;
|
|
255
|
+
if (!readiness.ready) {
|
|
256
|
+
state = 'missing'; // localOnly=true and model not cached
|
|
257
|
+
}
|
|
258
|
+
else if (!readiness.cached) {
|
|
259
|
+
state = 'will-download';
|
|
260
|
+
}
|
|
261
|
+
else if (!embeddingsFileExists || embeddingsCount === 0) {
|
|
262
|
+
state = 'no-embeddings';
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
state = 'ready';
|
|
266
|
+
}
|
|
267
|
+
return res.json({
|
|
268
|
+
success: true,
|
|
269
|
+
enabled: true,
|
|
270
|
+
ready: readiness.ready && embeddingsFileExists && embeddingsCount > 0,
|
|
271
|
+
state,
|
|
272
|
+
model: sem.model,
|
|
273
|
+
device: sem.device,
|
|
274
|
+
cacheDir: sem.cacheDir,
|
|
275
|
+
localOnly: sem.localOnly,
|
|
276
|
+
modelCached: readiness.cached,
|
|
277
|
+
modelPath: readiness.modelPath,
|
|
278
|
+
embeddingPath: sem.embeddingPath,
|
|
279
|
+
embeddingsFileExists,
|
|
280
|
+
embeddingsCount,
|
|
281
|
+
embeddingsModelName,
|
|
282
|
+
message: readiness.message,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
return res.status(500).json({
|
|
287
|
+
success: false,
|
|
288
|
+
error: 'Failed to compute embeddings status',
|
|
289
|
+
message: err.message,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
});
|
|
214
293
|
// POST /embeddings/compute — trigger embedding computation for all instructions
|
|
215
294
|
router.post('/embeddings/compute', adminAuth_js_1.dashboardAdminAuth, async (_req, res) => {
|
|
216
295
|
try {
|
|
@@ -244,10 +323,21 @@ function createEmbeddingsRoutes(embeddingPathOverride, embeddingStore) {
|
|
|
244
323
|
});
|
|
245
324
|
}
|
|
246
325
|
catch (err) {
|
|
326
|
+
const msg = err.message ?? String(err);
|
|
327
|
+
// Detect the canonical "model not cached locally" signature from
|
|
328
|
+
// @huggingface/transformers and translate to an actionable hint so
|
|
329
|
+
// the dashboard can surface a useful banner.
|
|
330
|
+
const isLocalOnlyMiss = /local_files_only=true/i.test(msg) ||
|
|
331
|
+
/allowRemoteModels=false/i.test(msg) ||
|
|
332
|
+
/file was not found locally/i.test(msg);
|
|
247
333
|
return res.status(500).json({
|
|
248
334
|
success: false,
|
|
249
335
|
error: 'Failed to compute embeddings',
|
|
250
|
-
message:
|
|
336
|
+
message: msg,
|
|
337
|
+
hint: isLocalOnlyMiss
|
|
338
|
+
? 'The embedding model is not cached locally and remote downloads are disabled. ' +
|
|
339
|
+
'Set INDEX_SERVER_SEMANTIC_LOCAL_ONLY=0 and restart, or pre-stage the model in INDEX_SERVER_SEMANTIC_CACHE_DIR.'
|
|
340
|
+
: undefined,
|
|
251
341
|
});
|
|
252
342
|
}
|
|
253
343
|
});
|
|
@@ -106,19 +106,21 @@ function renderPanelMarkdownHtml(name, markdown) {
|
|
|
106
106
|
* Called once during DashboardServer construction after middleware is set up.
|
|
107
107
|
*/
|
|
108
108
|
function mountDashboardRoutes(app, ctx) {
|
|
109
|
-
// Build a per-route rate limiter using the same dashboard.http.rateLimit
|
|
110
|
-
// configuration as the /api router. Re-uses the same
|
|
111
|
-
// is consistent across the dashboard. Disabled
|
|
109
|
+
// Build a per-route rate limiter using the same dashboard.http.rateLimit
|
|
110
|
+
// configuration as the /api router. Re-uses the same per-minute cap so
|
|
111
|
+
// behavior is consistent across the dashboard. Disabled when
|
|
112
|
+
// rateLimitPerMinute === 0 (the default).
|
|
112
113
|
const httpCfg = (0, runtimeConfig_js_1.getRuntimeConfig)().dashboard.http;
|
|
114
|
+
const perMinute = httpCfg.rateLimitPerMinute;
|
|
113
115
|
const dashboardLimiter = (0, express_rate_limit_1.default)({
|
|
114
|
-
windowMs:
|
|
115
|
-
max:
|
|
116
|
+
windowMs: 60_000,
|
|
117
|
+
max: perMinute > 0 ? perMinute : 1,
|
|
116
118
|
standardHeaders: true,
|
|
117
119
|
legacyHeaders: false,
|
|
118
120
|
validate: { ip: false },
|
|
119
|
-
skip: () =>
|
|
121
|
+
skip: () => perMinute <= 0,
|
|
120
122
|
handler: (_req, res) => {
|
|
121
|
-
const retryAfter = Number(res.getHeader('Retry-After') ||
|
|
123
|
+
const retryAfter = Number(res.getHeader('Retry-After') || 60);
|
|
122
124
|
res.status(429).json({
|
|
123
125
|
error: 'Too Many Requests',
|
|
124
126
|
message: `Rate limit exceeded. Try again in ${retryAfter} second(s).`,
|
|
@@ -222,8 +224,8 @@ function mountDashboardRoutes(app, ctx) {
|
|
|
222
224
|
res.status(404).send('Screenshot not found');
|
|
223
225
|
}
|
|
224
226
|
});
|
|
225
|
-
// API sub-routes (mounted at /api). Rate
|
|
226
|
-
// (
|
|
227
|
+
// API sub-routes (mounted at /api). Rate limit sourced from runtimeConfig
|
|
228
|
+
// (INDEX_SERVER_RATE_LIMIT) — see ApiRoutes.createApiRoutes.
|
|
227
229
|
app.use('/api', (0, ApiRoutes_js_1.createApiRoutes)({ enableCors: ctx.enableCors }));
|
|
228
230
|
// Back-compat: legacy tests expect /tools.json at dashboard root
|
|
229
231
|
app.get('/tools.json', (_req, res) => {
|