neoagent 2.5.2-beta.9 → 3.0.1-beta.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.
Files changed (52) hide show
  1. package/README.md +5 -1
  2. package/docs/integrations.md +11 -7
  3. package/flutter_app/lib/main_controller.dart +34 -0
  4. package/flutter_app/lib/main_security.dart +19 -0
  5. package/lib/schema_migrations.js +224 -0
  6. package/package.json +2 -2
  7. package/server/admin/admin.css +29 -0
  8. package/server/admin/admin.js +81 -1
  9. package/server/admin/index.html +97 -9
  10. package/server/db/database.js +57 -1
  11. package/server/public/.last_build_id +1 -1
  12. package/server/public/flutter_bootstrap.js +1 -1
  13. package/server/public/main.dart.js +32788 -32752
  14. package/server/routes/security.js +19 -0
  15. package/server/routes/settings.js +1 -0
  16. package/server/routes/skills.js +9 -5
  17. package/server/services/ai/deliverables/artifact_helpers.js +92 -9
  18. package/server/services/ai/deliverables/selector.js +17 -0
  19. package/server/services/ai/hooks.js +3 -2
  20. package/server/services/ai/learning.js +8 -3
  21. package/server/services/ai/loop/agent_engine_core.js +67 -0
  22. package/server/services/ai/loop/blank_recovery.js +37 -0
  23. package/server/services/ai/loop/conversation_loop.js +391 -252
  24. package/server/services/ai/loop/error_recovery.js +38 -0
  25. package/server/services/ai/loop/messaging_delivery.js +52 -6
  26. package/server/services/ai/loop/progress_classification.js +178 -0
  27. package/server/services/ai/loop/progress_monitor.js +6 -5
  28. package/server/services/ai/loop/tool_dispatch.js +1 -0
  29. package/server/services/ai/loopPolicy.js +15 -6
  30. package/server/services/ai/messagingFallback.js +23 -1
  31. package/server/services/ai/preModelCompaction.js +31 -0
  32. package/server/services/ai/repetitionGuard.js +47 -2
  33. package/server/services/ai/settings.js +8 -0
  34. package/server/services/ai/systemPrompt.js +24 -0
  35. package/server/services/ai/taskAnalysis.js +10 -0
  36. package/server/services/ai/toolEvidence.js +39 -3
  37. package/server/services/ai/toolResult.js +29 -0
  38. package/server/services/ai/toolRunner.js +262 -55
  39. package/server/services/ai/toolSelector.js +20 -3
  40. package/server/services/ai/tools.js +201 -31
  41. package/server/services/cli/shell_worker_pool.js +87 -5
  42. package/server/services/integrations/github/common.js +2 -2
  43. package/server/services/integrations/github/repos.js +123 -55
  44. package/server/services/manager.js +16 -0
  45. package/server/services/memory/manager.js +39 -24
  46. package/server/services/runtime/docker-vm-manager.js +21 -1
  47. package/server/services/runtime/manager.js +6 -2
  48. package/server/services/security/approval_gate_service.js +108 -5
  49. package/server/services/security/tool_categories.js +113 -4
  50. package/server/services/security/tool_security_hook.js +7 -2
  51. package/server/services/skills/runtime.js +2 -0
  52. package/server/services/workspace/manager.js +56 -0
package/README.md CHANGED
@@ -19,7 +19,11 @@
19
19
  </p>
20
20
 
21
21
  <p align="center">
22
- <img src="demo.gif" alt="NeoAgent operator interface" width="100%">
22
+ <a href="https://www.producthunt.com/products/neoagent-2?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-neoagent-2" target="_blank" rel="noopener noreferrer"><img alt="NeoAgent - The next-gen self-hosted AI agent that works beyond chat | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=1171573&theme=light&t=1781447653039"></a>
23
+ </p>
24
+
25
+ <p align="center">
26
+ <img src="demo.gif" alt="NeoAgent demo" width="100%">
23
27
  </p>
24
28
 
25
29
  ## 🚀 Install
@@ -36,15 +36,19 @@ or delete data.
36
36
 
37
37
  ### Setup
38
38
 
39
- Google, Microsoft, GitHub, Notion, Slack, Figma, and Spotify require OAuth
40
- application credentials on the server. The default callback is:
41
39
 
42
- ```text
43
- PUBLIC_URL/api/integrations/oauth/callback
44
- ```
40
+ Google, Microsoft, GitHub, Notion, Slack, Figma, and Spotify require OAuth application credentials on the server. The default callback is:
41
+ `PUBLIC_URL/api/integrations/oauth/callback`
42
+
43
+ **GitHub OAuth Configuration:**
44
+ 1. Navigate to your GitHub Developer Settings and create a new **OAuth App**.
45
+ 2. Set the Authorization callback URL to: `PUBLIC_URL/api/integrations/oauth/callback`.
46
+ 3. Generate a **Client ID** and **Client Secret**.
47
+ 4. Add these to your server environment configuration:
48
+ - `GITHUB_CLIENT_ID=your_id_here`
49
+ - `GITHUB_CLIENT_SECRET=your_secret_here`
45
50
 
46
- Home Assistant and Trello can be configured for a user from the application.
47
- See [Configuration](configuration.md#official-integrations).
51
+ Home Assistant and Trello can be configured for a user from the application. See Configuration.
48
52
 
49
53
  ## Messaging channels
50
54
 
@@ -367,6 +367,15 @@ class NeoAgentController extends ChangeNotifier {
367
367
  notifyListeners();
368
368
  }
369
369
 
370
+ void clearPendingApprovalForRun(String runId) {
371
+ if (pendingApproval?.runId != runId) return;
372
+ _SecurityNotificationService.cancelApprovalNotification(
373
+ pendingApproval?.approvalId ?? '',
374
+ );
375
+ pendingApproval = null;
376
+ notifyListeners();
377
+ }
378
+
370
379
  bool get desktopFloatingToolbarPopupRequested =>
371
380
  _desktopFloatingToolbarPopupRequested;
372
381
 
@@ -7349,6 +7358,7 @@ class NeoAgentController extends ChangeNotifier {
7349
7358
  socket.on('run:stopped', (dynamic data) {
7350
7359
  final payload = _jsonMap(data);
7351
7360
  final runId = payload['runId']?.toString() ?? '';
7361
+ clearPendingApprovalForRun(runId);
7352
7362
  if (_voiceRunIds.remove(runId)) {
7353
7363
  return;
7354
7364
  }
@@ -7369,6 +7379,30 @@ class NeoAgentController extends ChangeNotifier {
7369
7379
  unawaited(refreshRunsOnly());
7370
7380
  notifyListeners();
7371
7381
  });
7382
+ socket.on('run:interrupted', (dynamic data) {
7383
+ final payload = _jsonMap(data);
7384
+ final runId = payload['runId']?.toString() ?? '';
7385
+ clearPendingApprovalForRun(runId);
7386
+ if (_voiceRunIds.remove(runId)) {
7387
+ return;
7388
+ }
7389
+ if (_backgroundRunIds.remove(runId)) {
7390
+ unawaited(refreshRunsOnly());
7391
+ unawaited(refreshMemory());
7392
+ notifyListeners();
7393
+ return;
7394
+ }
7395
+ streamingAssistant = '';
7396
+ isSendingMessage = false;
7397
+ if (activeRun?.runId == runId) {
7398
+ activeRun = activeRun!.copyWith(
7399
+ phase: 'Interrupted',
7400
+ pendingSteeringCount: 0,
7401
+ );
7402
+ }
7403
+ unawaited(refreshRunsOnly());
7404
+ notifyListeners();
7405
+ });
7372
7406
  socket.on('run:error', (dynamic data) {
7373
7407
  final payload = _jsonMap(data);
7374
7408
  final runId = payload['runId']?.toString();
@@ -103,6 +103,13 @@ const _kCategoryInfo = <String, _CategoryInfo>{
103
103
  color: Color(0xFFFB8C00),
104
104
  riskLevel: 'medium',
105
105
  ),
106
+ 'external': _CategoryInfo(
107
+ label: 'External & MCP Tools',
108
+ subtitle: 'Tools not built into NeoAgent, including connected MCP servers and custom tool providers.',
109
+ icon: Icons.hub_rounded,
110
+ color: Color(0xFF6D4C41),
111
+ riskLevel: 'high',
112
+ ),
106
113
  };
107
114
 
108
115
  _CategoryInfo _categoryInfo(String category) {
@@ -732,6 +739,18 @@ class _ToolApprovalSheetState extends State<ToolApprovalSheet>
732
739
  toolArgs: widget.request.toolArgs,
733
740
  );
734
741
  widget.controller.clearPendingApproval();
742
+ } on BackendException catch (error) {
743
+ if (error.statusCode == 410) {
744
+ widget.controller.clearPendingApproval();
745
+ if (mounted) {
746
+ ScaffoldMessenger.of(context).showSnackBar(
747
+ SnackBar(
748
+ content: Text(error.message),
749
+ backgroundColor: _warning,
750
+ ),
751
+ );
752
+ }
753
+ }
735
754
  } catch (_) {
736
755
  // timeout will fire server-side; safe to dismiss
737
756
  }
@@ -191,6 +191,187 @@ function migrateMemoryEmbeddingMetadata(db) {
191
191
  }
192
192
  }
193
193
 
194
+ function migrateMemoryExactHashUniqueness(db) {
195
+ const columns = new Set(
196
+ db.prepare('PRAGMA table_info(memories)').all().map((column) => column.name),
197
+ );
198
+ if (!columns.has('memory_hash')) return;
199
+
200
+ const groups = db.prepare(
201
+ `SELECT
202
+ user_id,
203
+ COALESCE(agent_id, '') AS agent_key,
204
+ memory_hash,
205
+ COALESCE(scope_type, 'agent') AS scope_type_key,
206
+ COALESCE(scope_id, '') AS scope_id_key,
207
+ COUNT(*) AS duplicate_count
208
+ FROM memories
209
+ WHERE archived = 0
210
+ AND memory_hash IS NOT NULL
211
+ AND trim(memory_hash) <> ''
212
+ GROUP BY
213
+ user_id,
214
+ COALESCE(agent_id, ''),
215
+ memory_hash,
216
+ COALESCE(scope_type, 'agent'),
217
+ COALESCE(scope_id, '')
218
+ HAVING COUNT(*) > 1`
219
+ ).all();
220
+
221
+ const selectGroupRows = db.prepare(
222
+ `SELECT id, importance, confidence, access_count
223
+ FROM memories
224
+ WHERE user_id = ?
225
+ AND COALESCE(agent_id, '') = ?
226
+ AND memory_hash = ?
227
+ AND COALESCE(scope_type, 'agent') = ?
228
+ AND COALESCE(scope_id, '') = ?
229
+ AND archived = 0
230
+ ORDER BY updated_at DESC, created_at DESC, id DESC`
231
+ );
232
+
233
+ const archiveDuplicates = db.transaction(() => {
234
+ for (const group of groups) {
235
+ const rows = selectGroupRows.all(
236
+ group.user_id,
237
+ group.agent_key,
238
+ group.memory_hash,
239
+ group.scope_type_key,
240
+ group.scope_id_key,
241
+ );
242
+ if (rows.length < 2) continue;
243
+
244
+ const winner = rows[0];
245
+ const mergedImportance = Math.max(...rows.map((row) => Number(row.importance || 0)));
246
+ const mergedConfidence = Math.max(...rows.map((row) => Number(row.confidence || 0)));
247
+ const mergedAccessCount = rows.reduce(
248
+ (sum, row) => sum + Math.max(0, Number(row.access_count || 0)),
249
+ 0,
250
+ );
251
+
252
+ db.prepare(
253
+ `UPDATE memories
254
+ SET importance = MAX(importance, ?),
255
+ confidence = MAX(confidence, ?),
256
+ access_count = ?,
257
+ updated_at = datetime('now')
258
+ WHERE id = ?`
259
+ ).run(
260
+ mergedImportance,
261
+ Math.min(1, mergedConfidence),
262
+ mergedAccessCount,
263
+ winner.id,
264
+ );
265
+
266
+ const duplicateIds = rows.slice(1).map((row) => row.id);
267
+ if (!duplicateIds.length) continue;
268
+ const placeholders = duplicateIds.map(() => '?').join(', ');
269
+ db.prepare(
270
+ `UPDATE memories
271
+ SET archived = 1,
272
+ updated_at = datetime('now')
273
+ WHERE id IN (${placeholders})`
274
+ ).run(...duplicateIds);
275
+ }
276
+ });
277
+ archiveDuplicates();
278
+
279
+ db.exec(`
280
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_memories_hash_unique
281
+ ON memories(
282
+ user_id,
283
+ COALESCE(agent_id, ''),
284
+ memory_hash,
285
+ COALESCE(scope_type, 'agent'),
286
+ COALESCE(scope_id, '')
287
+ )
288
+ WHERE archived = 0 AND memory_hash IS NOT NULL
289
+ `);
290
+ }
291
+
292
+ function migrateSkillOwnership(db) {
293
+ const tableInfo = db.prepare("PRAGMA table_info(skills)").all();
294
+ if (!tableInfo.length) return;
295
+
296
+ const skillsSql = db.prepare(
297
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name='skills'",
298
+ ).get()?.sql || '';
299
+ const hasUserIdColumn = tableInfo.some((column) => column.name === 'user_id');
300
+ const hasLegacyNameUniqueConstraint = /\bname\s+TEXT\s+UNIQUE\b/i.test(skillsSql);
301
+
302
+ if (!hasUserIdColumn || hasLegacyNameUniqueConstraint) {
303
+ db.exec(`
304
+ DROP TABLE IF EXISTS skills_v2;
305
+ CREATE TABLE IF NOT EXISTS skills_v2 (
306
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
307
+ user_id INTEGER NOT NULL DEFAULT 0,
308
+ name TEXT NOT NULL,
309
+ description TEXT,
310
+ file_path TEXT NOT NULL,
311
+ metadata TEXT DEFAULT '{}',
312
+ enabled INTEGER DEFAULT 1,
313
+ auto_created INTEGER DEFAULT 0,
314
+ created_at TEXT DEFAULT (datetime('now')),
315
+ updated_at TEXT DEFAULT (datetime('now'))
316
+ );
317
+ `);
318
+
319
+ if (hasUserIdColumn) {
320
+ db.exec(`
321
+ INSERT INTO skills_v2 (
322
+ id, user_id, name, description, file_path, metadata,
323
+ enabled, auto_created, created_at, updated_at
324
+ )
325
+ SELECT
326
+ id,
327
+ COALESCE(user_id, 0),
328
+ name,
329
+ description,
330
+ file_path,
331
+ COALESCE(metadata, '{}'),
332
+ COALESCE(enabled, 1),
333
+ COALESCE(auto_created, 0),
334
+ created_at,
335
+ updated_at
336
+ FROM skills
337
+ `);
338
+ } else {
339
+ db.exec(`
340
+ INSERT INTO skills_v2 (
341
+ id, user_id, name, description, file_path, metadata,
342
+ enabled, auto_created, created_at, updated_at
343
+ )
344
+ SELECT
345
+ id,
346
+ 0,
347
+ name,
348
+ description,
349
+ file_path,
350
+ COALESCE(metadata, '{}'),
351
+ COALESCE(enabled, 1),
352
+ COALESCE(auto_created, 0),
353
+ created_at,
354
+ updated_at
355
+ FROM skills
356
+ `);
357
+ }
358
+
359
+ db.exec(`
360
+ DROP TABLE skills;
361
+ ALTER TABLE skills_v2 RENAME TO skills;
362
+ `);
363
+ } else {
364
+ db.exec('UPDATE skills SET user_id = 0 WHERE user_id IS NULL');
365
+ }
366
+
367
+ db.exec(`
368
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_skills_user_name
369
+ ON skills(user_id, name);
370
+ CREATE INDEX IF NOT EXISTS idx_skills_user_enabled
371
+ ON skills(user_id, enabled, name);
372
+ `);
373
+ }
374
+
194
375
  function migrateToolPermissions(db) {
195
376
  db.exec(`
196
377
  CREATE TABLE IF NOT EXISTS tool_policies (
@@ -217,6 +398,43 @@ function migrateToolPermissions(db) {
217
398
  `);
218
399
  }
219
400
 
401
+ function migrateApprovalPersistence(db) {
402
+ db.exec(`
403
+ CREATE TABLE IF NOT EXISTS pending_approvals (
404
+ id TEXT PRIMARY KEY,
405
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
406
+ run_id TEXT REFERENCES agent_runs(id) ON DELETE CASCADE,
407
+ tool_name TEXT NOT NULL,
408
+ tool_args_json TEXT,
409
+ category TEXT,
410
+ status TEXT NOT NULL CHECK(status IN ('pending','approved','denied','timeout','expired')),
411
+ scope TEXT CHECK(scope IN ('once','session','always')),
412
+ expires_at TEXT NOT NULL,
413
+ decided_at TEXT,
414
+ created_at TEXT DEFAULT (datetime('now')),
415
+ updated_at TEXT DEFAULT (datetime('now'))
416
+ );
417
+
418
+ CREATE INDEX IF NOT EXISTS idx_pending_approvals_user
419
+ ON pending_approvals(user_id, status, created_at DESC);
420
+ CREATE INDEX IF NOT EXISTS idx_pending_approvals_run
421
+ ON pending_approvals(run_id, status, expires_at);
422
+
423
+ CREATE TABLE IF NOT EXISTS approval_session_grants (
424
+ user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
425
+ run_id TEXT NOT NULL,
426
+ tool_name TEXT NOT NULL,
427
+ expires_at TEXT NOT NULL,
428
+ created_at TEXT DEFAULT (datetime('now')),
429
+ updated_at TEXT DEFAULT (datetime('now')),
430
+ PRIMARY KEY (user_id, run_id, tool_name)
431
+ );
432
+
433
+ CREATE INDEX IF NOT EXISTS idx_approval_session_grants_expiry
434
+ ON approval_session_grants(expires_at);
435
+ `);
436
+ }
437
+
220
438
  function migrateToolPoliciesAllowAlways(db) {
221
439
  // SQLite doesn't support ALTER COLUMN — recreate the table to add 'allow_always'
222
440
  // to the CHECK constraint on existing installations.
@@ -246,7 +464,10 @@ function runSchemaMigrations(db) {
246
464
  migrateMemoryRelations(db);
247
465
  migrateMemoryRetrievalEvents(db);
248
466
  migrateMemoryEmbeddingMetadata(db);
467
+ migrateMemoryExactHashUniqueness(db);
468
+ migrateSkillOwnership(db);
249
469
  migrateToolPermissions(db);
470
+ migrateApprovalPersistence(db);
250
471
  migrateToolPoliciesAllowAlways(db);
251
472
  }
252
473
 
@@ -256,7 +477,10 @@ module.exports = {
256
477
  migrateMemoryRelations,
257
478
  migrateMemoryRetrievalEvents,
258
479
  migrateMemoryEmbeddingMetadata,
480
+ migrateMemoryExactHashUniqueness,
481
+ migrateSkillOwnership,
259
482
  migrateToolPermissions,
483
+ migrateApprovalPersistence,
260
484
  migrateToolPoliciesAllowAlways,
261
485
  runSchemaMigrations,
262
486
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "2.5.2-beta.9",
4
- "description": "Proactive personal AI agent with no limits",
3
+ "version": "3.0.1-beta.0",
4
+ "description": "Self-hosted AI agent for long-running tasks, automation, messaging, device control, and local memory",
5
5
  "license": "AGPL-3.0-only",
6
6
  "main": "server/index.js",
7
7
  "engines": {
@@ -36,6 +36,35 @@
36
36
 
37
37
  --font-sans: 'Geist', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
38
38
  --font-mono: 'Geist Mono', 'JetBrains Mono', 'Fira Code', 'Menlo', monospace;
39
+
40
+ --row-hover: rgba(224, 240, 224, 0.04);
41
+ }
42
+
43
+ /* ── Light theme override ───────────────────────────────────────────────── */
44
+ [data-theme="light"] {
45
+ --bg: #f3f6f1;
46
+ --bg-sidebar: #ffffff;
47
+ --bg-card: #ffffff;
48
+ --bg-secondary: #eaeee6;
49
+ --bg-input: #ffffff;
50
+ --border: rgba(20, 40, 24, 0.12);
51
+ --border-light: rgba(20, 40, 24, 0.20);
52
+
53
+ --accent: #b8861f;
54
+ --accent-hover: #9a6f14;
55
+ --accent-alt: #3f8a47;
56
+ --accent-muted: rgba(184, 134, 31, 0.14);
57
+
58
+ --text: #1a211b;
59
+ --text-secondary:#4a544a;
60
+ --text-muted: #717b6f;
61
+
62
+ --success: #2f8a3c;
63
+ --warning: #b07a18;
64
+ --danger: #c0492f;
65
+ --info: #2f7d72;
66
+
67
+ --row-hover: rgba(20, 40, 24, 0.05);
39
68
  }
40
69
 
41
70
  /* ── Base ────────────────────────────────────────────────────────────────── */
@@ -16,10 +16,31 @@ function showPage(page, btn) {
16
16
  if (btn) btn.classList.add('active');
17
17
  currentPage = page;
18
18
 
19
- const loaders = { overview: loadHealth, logs: loadLogs, updates: loadVersion, config: loadConfig, providers: loadProviders, models: loadModels, analytics: loadAnalytics, users: loadUsers, sql: loadSql, access: loadAccess };
19
+ const loaders = { overview: loadHealth, logs: loadLogs, issues: loadIssues, updates: loadVersion, config: loadConfig, providers: loadProviders, models: loadModels, analytics: loadAnalytics, users: loadUsers, sql: loadSql, access: loadAccess };
20
20
  loaders[page]?.();
21
21
  }
22
22
 
23
+ // ── Theme ──────────────────────────────────────────────────────────────────
24
+
25
+ function applyTheme(theme) {
26
+ const isLight = theme === 'light';
27
+ document.documentElement.setAttribute('data-theme', isLight ? 'light' : 'dark');
28
+ const label = document.getElementById('theme-toggle-label');
29
+ if (label) label.textContent = isLight ? 'Dark mode' : 'Light mode';
30
+ }
31
+
32
+ function toggleTheme() {
33
+ const next = document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light';
34
+ try { localStorage.setItem('admin-theme', next); } catch {}
35
+ applyTheme(next);
36
+ }
37
+
38
+ function initTheme() {
39
+ let stored = 'dark';
40
+ try { stored = localStorage.getItem('admin-theme') || 'dark'; } catch {}
41
+ applyTheme(stored);
42
+ }
43
+
23
44
  async function signOut() {
24
45
  await fetch('/admin/api/logout', { method: 'POST' }).catch(() => {});
25
46
  window.location.replace('/admin/login');
@@ -130,6 +151,62 @@ function copyLogs() {
130
151
  navigator.clipboard.writeText(text).catch(() => {});
131
152
  }
132
153
 
154
+ // ── Issues ─────────────────────────────────────────────────────────────────
155
+
156
+ // Distil the raw log stream down to critical, actionable issues: error-level
157
+ // entries grouped by message so a repeated failure shows up once with a count.
158
+ function groupIssues(logs) {
159
+ const groups = new Map();
160
+ for (const entry of logs) {
161
+ if ((entry.type || 'log') !== 'error') continue;
162
+ const message = String(entry.message || '').trim();
163
+ if (!message) continue;
164
+ const existing = groups.get(message);
165
+ if (existing) {
166
+ existing.count += 1;
167
+ if (entry.timestamp && entry.timestamp > existing.last) existing.last = entry.timestamp;
168
+ } else {
169
+ groups.set(message, { message, count: 1, last: entry.timestamp || '' });
170
+ }
171
+ }
172
+ return Array.from(groups.values()).sort((a, b) => String(b.last).localeCompare(String(a.last)));
173
+ }
174
+
175
+ function renderIssues(issues) {
176
+ const table = document.getElementById('issue-table');
177
+ const count = document.getElementById('issue-count');
178
+ const badge = document.getElementById('issues-badge');
179
+ if (count) count.textContent = `${issues.length} issue${issues.length === 1 ? '' : 's'}`;
180
+ if (badge) {
181
+ if (issues.length) { badge.hidden = false; badge.textContent = String(issues.length); }
182
+ else { badge.hidden = true; }
183
+ }
184
+ if (!table) return;
185
+ if (!issues.length) { table.innerHTML = '<div class="empty">No critical issues 🎉</div>'; return; }
186
+ table.innerHTML = issues.map((i) => `
187
+ <div class="issue-row">
188
+ <div class="issue-msg">${esc(i.message)}</div>
189
+ <div class="issue-meta">
190
+ ${i.count > 1 ? `<span class="issue-count">×${i.count}</span><br>` : ''}
191
+ ${esc(fmtTime(i.last))}
192
+ </div>
193
+ </div>`).join('');
194
+ }
195
+
196
+ async function loadIssues() {
197
+ try {
198
+ const data = await api('/admin/api/logs').then((r) => r.json());
199
+ localLogs = data.logs || localLogs;
200
+ renderIssues(groupIssues(localLogs));
201
+ setTs('issues-ts', 'Updated');
202
+ } catch (err) {
203
+ if (err.message !== 'unauthorized') {
204
+ const table = document.getElementById('issue-table');
205
+ if (table) table.innerHTML = '<div class="empty">Failed to load issues</div>';
206
+ }
207
+ }
208
+ }
209
+
133
210
  // ── Updates ────────────────────────────────────────────────────────────────
134
211
 
135
212
  async function loadVersion() {
@@ -626,12 +703,14 @@ async function saveEnabledModels(btn) {
626
703
  function startPolling() {
627
704
  setInterval(() => { if (currentPage === 'overview') loadHealth(); }, 30_000);
628
705
  setInterval(() => { if (currentPage === 'logs' && !logsCleared) loadLogs(); }, 5_000);
706
+ setInterval(() => { if (currentPage === 'issues') loadIssues(); }, 10_000);
629
707
  setInterval(() => { if (currentPage === 'updates') loadVersion(); }, 10_000);
630
708
  }
631
709
 
632
710
  // ── Init ───────────────────────────────────────────────────────────────────
633
711
 
634
712
  (async function init() {
713
+ initTheme();
635
714
  try {
636
715
  const res = await fetch('/admin/api/version');
637
716
  if (res.status === 401) { window.location.replace('/admin/login'); return; }
@@ -639,5 +718,6 @@ function startPolling() {
639
718
  // server unreachable — still render; API calls will fail gracefully
640
719
  }
641
720
  loadHealth();
721
+ loadIssues();
642
722
  startPolling();
643
723
  }());