diff --git a/src/viewer/index.html b/src/viewer/index.html index 551d016..9751807 100644 --- a/src/viewer/index.html +++ b/src/viewer/index.html @@ -1005,7 +1005,7 @@

agentmemory

var state = { activeTab: 'dashboard', - dashboard: { loaded: false, health: null, sessions: [], memories: [], graphStats: null, recentAudit: [], lessons: [], crystals: [] }, + dashboard: { loaded: false, health: null, sessions: [], memories: [], graphStats: null, recentAudit: [], lessons: [], crystals: [], semantic: [], procedural: [], relations: [] }, graph: { loaded: false, nodes: [], edges: [], stats: null, filters: {}, selectedNode: null }, memories: { loaded: false, items: [], search: '', typeFilter: '' }, timeline: { loaded: false, observations: [], sessionId: '', minImportance: 0, page: 0, pageSize: 50 }, @@ -1039,6 +1039,18 @@

agentmemory

if (!s) return ''; return s.length > n ? s.slice(0, n) + '...' : s; } + function asArray(value) { + return Array.isArray(value) ? value : []; + } + function firstArray() { + for (var i = 0; i < arguments.length; i++) { + if (Array.isArray(arguments[i])) return arguments[i]; + } + return []; + } + function asString(value) { + return value === undefined || value === null ? '' : String(value); + } function debounce(fn, ms) { var t; return function() { @@ -1128,15 +1140,15 @@

agentmemory

apiGet('crystals') ]); state.dashboard.health = results[0]; - state.dashboard.sessions = (results[1] && results[1].sessions) || []; - state.dashboard.memories = (results[2] && results[2].memories) || []; + state.dashboard.sessions = asArray(results[1] && results[1].sessions); + state.dashboard.memories = asArray(results[2] && results[2].memories); state.dashboard.graphStats = results[3]; - state.dashboard.recentAudit = (results[4] && results[4].entries) || []; - state.dashboard.semantic = (results[5] && results[5].facts) || (results[5] && results[5].semantic) || []; - state.dashboard.procedural = (results[6] && results[6].procedures) || (results[6] && results[6].procedural) || []; - state.dashboard.lessons = (results[8] && results[8].lessons) || []; - state.dashboard.crystals = (results[9] && results[9].crystals) || []; - state.dashboard.relations = (results[7] && results[7].relations) || []; + state.dashboard.recentAudit = asArray(results[4] && results[4].entries); + state.dashboard.semantic = firstArray(results[5] && results[5].facts, results[5] && results[5].semantic); + state.dashboard.procedural = firstArray(results[6] && results[6].procedures, results[6] && results[6].procedural); + state.dashboard.lessons = asArray(results[8] && results[8].lessons); + state.dashboard.crystals = asArray(results[9] && results[9].crystals); + state.dashboard.relations = asArray(results[7] && results[7].relations); state.dashboard.loaded = true; renderDashboard(); } catch (err) { @@ -1167,17 +1179,20 @@

agentmemory

var snap = h.health || {}; var healthStatus = h.status || 'unknown'; var dotClass = healthStatus === 'healthy' ? 'healthy' : healthStatus === 'degraded' ? 'degraded' : healthStatus === 'critical' ? 'critical' : ''; - var activeSessions = d.sessions.filter(function(s) { return s.status === 'active'; }).length; + var sessions = asArray(d.sessions).filter(function(s) { return s && typeof s === 'object'; }); + var memories = asArray(d.memories).filter(function(m) { return m && typeof m === 'object'; }); + var recentAudit = asArray(d.recentAudit).filter(function(a) { return a && typeof a === 'object'; }); + var activeSessions = sessions.filter(function(s) { return s.status === 'active'; }).length; var gs = d.graphStats || {}; var nodeCount = gs.totalNodes !== undefined ? gs.totalNodes : (gs.nodes !== undefined ? gs.nodes : (gs.nodeCount || 0)); var edgeCount = gs.totalEdges !== undefined ? gs.totalEdges : (gs.edges !== undefined ? gs.edges : (gs.edgeCount || 0)); - var fMetrics = h.functionMetrics || []; + var fMetrics = asArray(h.functionMetrics).filter(function(m) { return m && typeof m === 'object'; }); var cb = h.circuitBreaker || null; - var workers = snap.workers || []; + var workers = asArray(snap.workers).filter(function(w) { return w && typeof w === 'object'; }); var html = ''; // First-run hero: empty dashboard = guided next step - if (d.sessions.length === 0) { + if (sessions.length === 0) { html += '
' + '
First run → magical moment in 10 seconds
' + '
Seed sample data + prove semantic recall works
' + @@ -1187,10 +1202,10 @@

agentmemory

'
'; } html += '
'; - html += '
Sessions
' + d.sessions.length + '
' + activeSessions + ' active
'; - html += '
Memories
' + d.memories.length + '
latest versions
'; - var lessonCount = (d.lessons || []).length; - var crystalCount = (d.crystals || []).length; + html += '
Sessions
' + sessions.length + '
' + activeSessions + ' active
'; + html += '
Memories
' + memories.length + '
latest versions
'; + var lessonCount = asArray(d.lessons).length; + var crystalCount = asArray(d.crystals).length; html += '
Lessons
' + lessonCount + '
confidence-scored
'; html += '
Crystals
' + crystalCount + '
action digests
'; html += '
Graph Nodes
' + nodeCount + '
' + edgeCount + ' edges
'; @@ -1203,10 +1218,10 @@

agentmemory

html += '
Circuit Breaker
' + esc(cb.state) + '
'; html += '
' + (cb.failures || 0) + ' failures
'; } - var totalObs = d.sessions.reduce(function(a, s) { return a + (s.observationCount || 0); }, 0); + var totalObs = sessions.reduce(function(a, s) { return a + (s.observationCount || 0); }, 0); var tokenBudget = parseInt(new URLSearchParams(window.location.search).get('tokenBudget') || '2000', 10) || 2000; var estFull = totalObs * 80; - var estInjected = d.sessions.length * tokenBudget; + var estInjected = sessions.length * tokenBudget; var savings = estFull > 0 ? Math.round((1 - estInjected / Math.max(estFull, 1)) * 100) : 0; if (savings < 0) savings = 0; var tokensSaved = Math.max(0, estFull - estInjected); @@ -1252,17 +1267,19 @@

agentmemory

html += '
'; } - if (snap.alerts && snap.alerts.length > 0) { - html += '
Alerts (' + snap.alerts.length + ')
'; - snap.alerts.forEach(function(al) { + var alerts = asArray(snap.alerts); + if (alerts.length > 0) { + html += '
Alerts (' + alerts.length + ')
'; + alerts.forEach(function(al) { html += '
' + esc(al) + '
'; }); html += '
'; } - if (snap.notes && snap.notes.length > 0) { - html += '
Notes (' + snap.notes.length + ')
'; - snap.notes.forEach(function(n) { + var notes = asArray(snap.notes); + if (notes.length > 0) { + html += '
Notes (' + notes.length + ')
'; + notes.forEach(function(n) { html += '
' + esc(n) + '
'; }); html += '
'; @@ -1271,14 +1288,16 @@

agentmemory

html += '
'; html += '
Recent Sessions
'; - if (d.sessions.length === 0) { + if (sessions.length === 0) { html += '

No sessions yet. Start a coding session with agentmemory hooks enabled.

'; } else { - var recent = d.sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }).slice(0, 5); + var recent = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }).slice(0, 5); html += ''; recent.forEach(function(s) { var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted'; - html += ''; + var sessionId = asString(s.id || s.sessionId); + var projectLabel = s.project ? asString(s.project).split('/').pop() : truncate(sessionId, 8); + html += ''; html += ''; html += ''; html += ''; @@ -1288,10 +1307,10 @@

agentmemory

html += ''; html += '
Recent Activity
'; - if (d.recentAudit.length === 0) { + if (recentAudit.length === 0) { html += '

No activity recorded yet

'; } else { - d.recentAudit.forEach(function(a) { + recentAudit.forEach(function(a) { var badgeClass = OP_BADGES[a.operation] || 'badge-muted'; html += '
'; html += '' + esc(a.operation) + ' '; @@ -1346,9 +1365,9 @@

agentmemory

html += '
'; } - var semFacts = d.semantic || []; - var procItems = d.procedural || []; - var relItems = d.relations || []; + var semFacts = asArray(d.semantic).filter(function(f) { return f && typeof f === 'object'; }); + var procItems = asArray(d.procedural).filter(function(p) { return p && typeof p === 'object'; }); + var relItems = asArray(d.relations).filter(function(r) { return r && typeof r === 'object'; }); html += '
'; html += '
'; @@ -1380,10 +1399,11 @@

agentmemory

html += '
' + esc(p.name || p.title || 'Procedure') + '
'; if (p.trigger || p.triggerCondition) html += '
Trigger: ' + esc(p.trigger || p.triggerCondition) + '
'; if (p.frequency) html += '
Freq: ' + p.frequency + '
'; - if (p.steps && p.steps.length > 0) { + var steps = asArray(p.steps); + if (steps.length > 0) { html += '
    '; - p.steps.slice(0, 4).forEach(function(s) { html += '
  1. ' + esc(typeof s === 'string' ? s : s.description || s.action || JSON.stringify(s)) + '
  2. '; }); - if (p.steps.length > 4) html += '
  3. + ' + (p.steps.length - 4) + ' more...
  4. '; + steps.slice(0, 4).forEach(function(s) { html += '
  5. ' + esc(typeof s === 'string' ? s : s.description || s.action || JSON.stringify(s)) + '
  6. '; }); + if (steps.length > 4) html += '
  7. + ' + (steps.length - 4) + ' more...
  8. '; html += '
'; } html += '
'; diff --git a/test/viewer-dashboard.test.ts b/test/viewer-dashboard.test.ts new file mode 100644 index 0000000..deabcf8 --- /dev/null +++ b/test/viewer-dashboard.test.ts @@ -0,0 +1,168 @@ +import { readFileSync } from "node:fs"; +import { runInNewContext } from "node:vm"; +import { describe, expect, it, vi } from "vitest"; + +type ViewerElement = { + innerHTML: string; + textContent: string; + value: string; + dataset: Record; + classList: { + toggle: ReturnType; + add: ReturnType; + remove: ReturnType; + }; + addEventListener: ReturnType; + removeEventListener: ReturnType; + querySelector: ReturnType; + querySelectorAll: ReturnType; +}; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + +function loadViewerScript(): string { + const html = readFileSync( + new URL("../src/viewer/index.html", import.meta.url), + "utf8", + ); + const match = html.match( + /
ProjectStatusObsStarted
' + esc(s.project ? s.project.split('/').pop() : s.id.slice(0,8)) + '
' + esc(projectLabel || 'unknown') + '' + esc(s.status) + '' + (s.observationCount || 0) + '' + esc(shortTime(s.startedAt)) + '