neoagent 3.0.0 → 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.
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "3.0.0",
3
+ "version": "3.0.1-beta.0",
4
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",
@@ -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
  }());
@@ -6,6 +6,12 @@
6
6
  <title>NeoAgent Admin</title>
7
7
  <link rel="icon" href="/admin/logo.svg" type="image/svg+xml">
8
8
  <link rel="stylesheet" href="/admin/admin.css">
9
+ <script>
10
+ // Set theme before first paint to avoid a flash of the wrong palette.
11
+ try {
12
+ document.documentElement.setAttribute('data-theme', localStorage.getItem('admin-theme') || 'dark');
13
+ } catch (_) { document.documentElement.setAttribute('data-theme', 'dark'); }
14
+ </script>
9
15
  <style>
10
16
  /* ── Layout ────────────────────────────────────────────────────── */
11
17
  body {
@@ -62,7 +68,7 @@
62
68
  text-decoration: none;
63
69
  }
64
70
 
65
- .nav-item:hover { background: rgba(224,240,224,0.05); color: var(--text); }
71
+ .nav-item:hover { background: var(--row-hover); color: var(--text); }
66
72
 
67
73
  .nav-item.active {
68
74
  background: rgba(225, 176, 82, 0.12);
@@ -78,6 +84,51 @@
78
84
 
79
85
  .nav-item.active svg { opacity: 1; }
80
86
 
87
+ .nav-badge {
88
+ margin-left: auto;
89
+ min-width: 18px;
90
+ padding: 1px 6px;
91
+ border-radius: 999px;
92
+ background: var(--danger);
93
+ color: #fff;
94
+ font-size: 11px;
95
+ font-weight: 600;
96
+ line-height: 1.4;
97
+ text-align: center;
98
+ }
99
+
100
+ .issue-row {
101
+ display: grid;
102
+ grid-template-columns: 1fr auto;
103
+ gap: 12px;
104
+ align-items: start;
105
+ padding: 10px 14px;
106
+ border-bottom: 1px solid var(--border);
107
+ }
108
+ .issue-row:last-child { border-bottom: none; }
109
+ .issue-msg {
110
+ font-family: var(--font-mono);
111
+ font-size: 12.5px;
112
+ color: var(--danger);
113
+ white-space: pre-wrap;
114
+ word-break: break-word;
115
+ }
116
+ .issue-meta {
117
+ font-size: 11px;
118
+ color: var(--text-muted);
119
+ text-align: right;
120
+ white-space: nowrap;
121
+ }
122
+ .issue-count {
123
+ display: inline-block;
124
+ margin-bottom: 3px;
125
+ padding: 1px 7px;
126
+ border-radius: 999px;
127
+ background: var(--accent-muted);
128
+ color: var(--accent);
129
+ font-weight: 600;
130
+ }
131
+
81
132
  .sidebar-footer {
82
133
  padding: 10px;
83
134
  border-top: 1px solid var(--border);
@@ -202,12 +253,12 @@
202
253
  .log-row {
203
254
  display: grid;
204
255
  grid-template-columns: 80px 46px 1fr;
205
- border-bottom: 1px solid rgba(224,240,224,0.04);
256
+ border-bottom: 1px solid var(--border);
206
257
  }
207
258
 
208
259
  .log-row:last-child { border-bottom: none; }
209
260
 
210
- .log-row:hover { background: rgba(224,240,224,0.03); }
261
+ .log-row:hover { background: var(--row-hover); }
211
262
 
212
263
  .log-cell {
213
264
  padding: 6px 10px;
@@ -426,12 +477,12 @@
426
477
 
427
478
  .analytics-table td {
428
479
  padding: 9px 8px;
429
- border-bottom: 1px solid rgba(224,240,224,0.04);
480
+ border-bottom: 1px solid var(--border);
430
481
  color: var(--text-secondary);
431
482
  }
432
483
 
433
484
  .analytics-table tr:last-child td { border-bottom: none; }
434
- .analytics-table tr:hover td { background: rgba(224,240,224,0.025); }
485
+ .analytics-table tr:hover td { background: var(--row-hover); }
435
486
 
436
487
  .cell-truncate {
437
488
  max-width: 280px;
@@ -504,12 +555,12 @@
504
555
 
505
556
  .users-table td {
506
557
  padding: 10px 8px;
507
- border-bottom: 1px solid rgba(224,240,224,0.04);
558
+ border-bottom: 1px solid var(--border);
508
559
  vertical-align: middle;
509
560
  }
510
561
 
511
562
  .users-table tr:last-child td { border-bottom: none; }
512
- .users-table tr:hover td { background: rgba(224,240,224,0.025); }
563
+ .users-table tr:hover td { background: var(--row-hover); }
513
564
 
514
565
  .gdpr-note {
515
566
  font-size: 11px;
@@ -593,7 +644,7 @@
593
644
 
594
645
  .sql-result-table td {
595
646
  padding: 6px 12px;
596
- border-bottom: 1px solid rgba(224,240,224,0.04);
647
+ border-bottom: 1px solid var(--border);
597
648
  color: var(--text-secondary);
598
649
  white-space: nowrap;
599
650
  max-width: 320px;
@@ -602,7 +653,7 @@
602
653
  }
603
654
 
604
655
  .sql-result-table tr:last-child td { border-bottom: none; }
605
- .sql-result-table tr:hover td { background: rgba(224,240,224,0.03); }
656
+ .sql-result-table tr:hover td { background: var(--row-hover); }
606
657
 
607
658
  .sql-error {
608
659
  padding: 12px 14px;
@@ -821,6 +872,14 @@
821
872
  Logs
822
873
  </button>
823
874
 
875
+ <button class="nav-item" data-page="issues" onclick="showPage('issues',this)">
876
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
877
+ <path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
878
+ </svg>
879
+ Issues
880
+ <span class="nav-badge" id="issues-badge" hidden>0</span>
881
+ </button>
882
+
824
883
  <button class="nav-item" data-page="updates" onclick="showPage('updates',this)">
825
884
  <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
826
885
  <path fill-rule="evenodd" d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z" clip-rule="evenodd"/>
@@ -883,6 +942,12 @@
883
942
  </nav>
884
943
 
885
944
  <div class="sidebar-footer">
945
+ <button class="nav-item" id="theme-toggle" onclick="toggleTheme()" aria-label="Toggle light and dark theme">
946
+ <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
947
+ <path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"/>
948
+ </svg>
949
+ <span id="theme-toggle-label">Light mode</span>
950
+ </button>
886
951
  <button class="nav-item btn-danger" onclick="signOut()">
887
952
  <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
888
953
  <path fill-rule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clip-rule="evenodd"/>
@@ -938,6 +1003,29 @@
938
1003
  </div>
939
1004
  </section>
940
1005
 
1006
+ <!-- Issues -->
1007
+ <section id="page-issues" class="page" aria-label="Issues">
1008
+ <div class="page-header">
1009
+ <div>
1010
+ <h1 class="page-title">Issues</h1>
1011
+ <p class="page-subtitle">Critical errors from the log stream that should be addressed, grouped by message</p>
1012
+ </div>
1013
+ <span class="refresh-hint" id="issues-ts" aria-live="polite"></span>
1014
+ </div>
1015
+ <div class="content">
1016
+ <div class="card">
1017
+ <div class="toolbar">
1018
+ <span class="toolbar-count" id="issue-count" aria-live="polite">0 issues</span>
1019
+ <span class="spacer"></span>
1020
+ <button class="btn btn-ghost" onclick="loadIssues()">Refresh</button>
1021
+ </div>
1022
+ <div class="log-table" id="issue-table" role="log" aria-label="Critical issues">
1023
+ <div class="empty">No critical issues</div>
1024
+ </div>
1025
+ </div>
1026
+ </div>
1027
+ </section>
1028
+
941
1029
  <!-- Updates -->
942
1030
  <section id="page-updates" class="page" aria-label="Updates">
943
1031
  <div class="page-header">