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.
- package/flutter_app/lib/main_controller.dart +34 -0
- package/flutter_app/lib/main_security.dart +19 -0
- package/lib/schema_migrations.js +224 -0
- package/package.json +1 -1
- package/server/admin/admin.css +29 -0
- package/server/admin/admin.js +81 -1
- package/server/admin/index.html +97 -9
- package/server/db/database.js +57 -1
- package/server/public/.last_build_id +1 -1
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/main.dart.js +32788 -32752
- package/server/routes/security.js +19 -0
- package/server/routes/settings.js +1 -0
- package/server/routes/skills.js +9 -5
- package/server/services/ai/hooks.js +3 -2
- package/server/services/ai/learning.js +8 -3
- package/server/services/ai/loop/agent_engine_core.js +67 -0
- package/server/services/ai/loop/conversation_loop.js +49 -13
- package/server/services/ai/settings.js +8 -0
- package/server/services/ai/systemPrompt.js +18 -0
- package/server/services/ai/toolEvidence.js +24 -0
- package/server/services/ai/toolRunner.js +262 -55
- package/server/services/ai/tools.js +5 -5
- package/server/services/cli/shell_worker_pool.js +87 -5
- package/server/services/manager.js +16 -0
- package/server/services/memory/manager.js +39 -24
- package/server/services/runtime/manager.js +6 -2
- package/server/services/security/approval_gate_service.js +108 -5
- package/server/services/security/tool_categories.js +113 -4
- package/server/services/security/tool_security_hook.js +7 -2
- package/server/services/skills/runtime.js +2 -0
|
@@ -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
|
}
|
package/lib/schema_migrations.js
CHANGED
|
@@ -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
package/server/admin/admin.css
CHANGED
|
@@ -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 ────────────────────────────────────────────────────────────────── */
|
package/server/admin/admin.js
CHANGED
|
@@ -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
|
}());
|
package/server/admin/index.html
CHANGED
|
@@ -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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
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:
|
|
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">
|