@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +53 -1
  2. package/dist/config/dashboardConfig.d.ts +11 -4
  3. package/dist/config/dashboardConfig.js +1 -4
  4. package/dist/dashboard/client/admin.html +57 -27
  5. package/dist/dashboard/client/css/admin.css +54 -0
  6. package/dist/dashboard/client/js/admin.config.js +3 -6
  7. package/dist/dashboard/client/js/admin.embeddings.js +63 -4
  8. package/dist/dashboard/client/js/admin.events.js +256 -0
  9. package/dist/dashboard/client/js/admin.maintenance.js +75 -32
  10. package/dist/dashboard/client/js/admin.sessions.js +1 -1
  11. package/dist/dashboard/server/AdminPanel.js +83 -6
  12. package/dist/dashboard/server/AdminPanelConfig.d.ts +12 -2
  13. package/dist/dashboard/server/AdminPanelConfig.js +48 -19
  14. package/dist/dashboard/server/ApiRoutes.d.ts +5 -4
  15. package/dist/dashboard/server/ApiRoutes.js +40 -35
  16. package/dist/dashboard/server/DashboardServer.js +13 -0
  17. package/dist/dashboard/server/routes/admin.routes.js +143 -17
  18. package/dist/dashboard/server/routes/embeddings.routes.js +91 -1
  19. package/dist/dashboard/server/routes/index.js +11 -9
  20. package/dist/server/sdkServer.js +12 -4
  21. package/dist/services/embeddingService.d.ts +2 -0
  22. package/dist/services/embeddingService.js +16 -4
  23. package/dist/services/embeddingTrigger.d.ts +33 -0
  24. package/dist/services/embeddingTrigger.js +86 -0
  25. package/dist/services/eventBuffer.d.ts +45 -0
  26. package/dist/services/eventBuffer.js +110 -0
  27. package/dist/services/handlers/instructions.import.js +71 -13
  28. package/dist/services/handlers.dashboardConfig.js +82 -2
  29. package/dist/services/indexContext.d.ts +18 -0
  30. package/dist/services/indexContext.js +138 -30
  31. package/dist/services/logger.js +9 -0
  32. package/dist/services/manifestManager.js +11 -1
  33. package/dist/services/seedBootstrap.js +5 -1
  34. package/dist/services/storage/factory.d.ts +2 -0
  35. package/dist/services/storage/factory.js +12 -1
  36. package/dist/services/tracing.js +3 -1
  37. package/package.json +12 -2
  38. package/schemas/index-server.code-schema.json +7424 -1588
  39. package/schemas/manifest.json +3 -3
  40. 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
- windowMs: 60000,
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
- this.config = { ...this.config, ...updates };
49
- this.applyConfigChanges(updates);
50
- return { success: true, message: 'Configuration updated successfully' };
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
- applyConfigChanges(updates) {
60
- if (updates.serverSettings) {
61
- let runtimeReloadNeeded = false;
62
- if (updates.serverSettings.enableVerboseLogging !== undefined) {
63
- process.env.INDEX_SERVER_VERBOSE_LOGGING = updates.serverSettings.enableVerboseLogging ? '1' : '0';
64
- runtimeReloadNeeded = true;
65
- }
66
- if (updates.serverSettings.enableMutation !== undefined) {
67
- process.env.INDEX_SERVER_MUTATION = updates.serverSettings.enableMutation ? '1' : '0';
68
- runtimeReloadNeeded = true;
69
- }
70
- if (runtimeReloadNeeded) {
71
- (0, runtimeConfig_1.reloadRuntimeConfig)();
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
- rateLimit?: {
12
- windowMs: number;
13
- max: number;
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 rateLimitOpts = options.rateLimit ?? { windowMs: httpCfg.rateLimitWindowMs, max: httpCfg.rateLimitMax };
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
- const rateLimitEnabled = httpCfg.rateLimitEnabled;
75
- if (rateLimitEnabled && rateLimitOpts.max > 0 && rateLimitOpts.windowMs > 0) {
76
- // Global rate limit for all routes (reads + mutations)
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: rateLimitOpts.windowMs,
79
- max: rateLimitOpts.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(rateLimitOpts.windowMs / 1000));
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 (optional field featureFlags { name:boolean })
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, featureFlags_js_1.updateFlags)(updates.featureFlags);
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
- if (Buffer.isBuffer(req.body) && req.body.length > 0) {
435
- const sourceName = req.header('x-backup-filename') || req.header('x-file-name') || undefined;
436
- const result = adminPanel.importZipBackup(req.body, sourceName);
437
- if (result.success) {
438
- return res.json({ success: true, message: result.message, backupId: result.backupId, files: result.files, timestamp: Date.now() });
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
- const bundle = req.body;
443
- if (!bundle || typeof bundle !== 'object' || !bundle.files) {
444
- return res.status(400).json({ success: false, error: 'Request body must contain a "files" object', timestamp: Date.now() });
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
- const result = adminPanel.importBackup(bundle);
447
- if (result.success) {
448
- res.json({ success: true, message: result.message, backupId: result.backupId, files: result.files, timestamp: Date.now() });
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
- else {
451
- res.status(400).json({ success: false, error: result.message, timestamp: Date.now() });
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: err.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 window/max so behavior
111
- // is consistent across the dashboard. Disabled if rateLimitEnabled is false.
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: Math.max(1, httpCfg.rateLimitWindowMs),
115
- max: Math.max(1, httpCfg.rateLimitMax),
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: () => !httpCfg.rateLimitEnabled,
121
+ skip: () => perMinute <= 0,
120
122
  handler: (_req, res) => {
121
- const retryAfter = Number(res.getHeader('Retry-After') || Math.ceil(httpCfg.rateLimitWindowMs / 1000));
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 limits sourced from runtimeConfig
226
- // (INDEX_SERVER_RATE_LIMIT_*) — see ApiRoutes.createApiRoutes.
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) => {