feat(gateway): add dashboard session analytics panel
This commit is contained in:
@@ -50,6 +50,17 @@
|
|||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/automation/presets.test.ts src/automation/cron.test.ts src/automation/heartbeat.test.ts src/backup/scheduler.test.ts src/backup/status.test.ts src/tools/builtin/minio-share.test.ts src/tools/policy.test.ts src/config/schema.test.ts src/daemon/channels.test.ts src/gateway/handlers/services.test.ts src/session/store.test.ts src/tools/executor.test.ts src/gateway/handlers/handlers.test.ts + pnpm typecheck passing"
|
"test_status": "pnpm test:run src/automation/presets.test.ts src/automation/cron.test.ts src/automation/heartbeat.test.ts src/backup/scheduler.test.ts src/backup/status.test.ts src/tools/builtin/minio-share.test.ts src/tools/policy.test.ts src/config/schema.test.ts src/daemon/channels.test.ts src/gateway/handlers/services.test.ts src/session/store.test.ts src/tools/executor.test.ts src/gateway/handlers/handlers.test.ts + pnpm typecheck passing"
|
||||||
},
|
},
|
||||||
|
"dashboard-session-analytics-panel": {
|
||||||
|
"status": "completed",
|
||||||
|
"date": "2026-02-16",
|
||||||
|
"updated": "2026-02-16",
|
||||||
|
"summary": "Extended the web UI dashboard with a Session Analytics panel backed by `system.sessionAnalytics`, including window summary cards, top tools/topics, top sessions, and daily trend tables refreshed alongside health/services.",
|
||||||
|
"files_modified": [
|
||||||
|
"src/gateway/ui/pages/dashboard.js",
|
||||||
|
"docs/plans/state.json"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm eslint src/gateway/ui/pages/dashboard.js + pnpm typecheck passing; full pnpm lint currently fails due pre-existing unrelated repo lint errors"
|
||||||
|
},
|
||||||
"backup-session-summary-audit-trail": {
|
"backup-session-summary-audit-trail": {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"date": "2026-02-16",
|
"date": "2026-02-16",
|
||||||
|
|||||||
@@ -33,6 +33,21 @@ function formatTime(timestamp) {
|
|||||||
return d.toLocaleTimeString('en-GB', { hour12: false });
|
return d.toLocaleTimeString('en-GB', { hour12: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDay(day) {
|
||||||
|
const parsed = new Date(`${day}T00:00:00`);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {return day;}
|
||||||
|
return parsed.toLocaleDateString('en-GB', { day: '2-digit', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
return (value ?? 0).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSessionDurationFromMessages(avgMessagesPerSession) {
|
||||||
|
if (!avgMessagesPerSession || avgMessagesPerSession <= 0) {return '—';}
|
||||||
|
return `${avgMessagesPerSession.toFixed(1)} msgs/session`;
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = str;
|
div.textContent = str;
|
||||||
@@ -55,6 +70,11 @@ function renderSkeleton(el) {
|
|||||||
<div class="text-muted text-sm">Loading...</div>
|
<div class="text-muted text-sm">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h2 class="section-title">Session Analytics</h2>
|
||||||
|
<div id="ops-session-analytics">
|
||||||
|
<div class="text-muted text-sm">Loading...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 class="section-title">Event Stream</h2>
|
<h2 class="section-title">Event Stream</h2>
|
||||||
<div class="event-stream" id="ops-events">
|
<div class="event-stream" id="ops-events">
|
||||||
<div class="event-row event-level-info">Loading events...</div>
|
<div class="event-row event-level-info">Loading events...</div>
|
||||||
@@ -217,6 +237,115 @@ function updateActiveRequests(requestsData) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateSessionAnalytics(analyticsData) {
|
||||||
|
const el = document.getElementById('ops-session-analytics');
|
||||||
|
if (!el) {return;}
|
||||||
|
|
||||||
|
const daily = analyticsData?.daily ?? [];
|
||||||
|
const topSessions = analyticsData?.topSessions ?? [];
|
||||||
|
const topTools = analyticsData?.topTools ?? [];
|
||||||
|
const topTopics = analyticsData?.topTopics ?? [];
|
||||||
|
|
||||||
|
const totalSessions = analyticsData?.totalSessions ?? 0;
|
||||||
|
const totalMessages = analyticsData?.totalMessages ?? 0;
|
||||||
|
const avgMessagesPerSession = analyticsData?.averageMessagesPerSession ?? 0;
|
||||||
|
|
||||||
|
const summaryHtml = `
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Sessions (Window)</div>
|
||||||
|
<div class="stat-value">${formatNumber(totalSessions)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Messages (Window)</div>
|
||||||
|
<div class="stat-value">${formatNumber(totalMessages)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">Avg Session</div>
|
||||||
|
<div class="stat-value">${formatSessionDurationFromMessages(avgMessagesPerSession)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const topToolsHtml = topTools.length > 0
|
||||||
|
? `<ul class="tool-list">${topTools.map((tool) =>
|
||||||
|
`<li><span class="text-accent">${escapeHtml(tool.toolName)}</span> <span class="text-muted">(${formatNumber(tool.executions)})</span></li>`,
|
||||||
|
).join('')}</ul>`
|
||||||
|
: '<div class="text-muted text-sm">No tool usage captured in this window</div>';
|
||||||
|
|
||||||
|
const topTopicsHtml = topTopics.length > 0
|
||||||
|
? `<ul class="tool-list">${topTopics.map((topic) =>
|
||||||
|
`<li><span class="text-accent">${escapeHtml(topic.topic)}</span> <span class="text-muted">(${formatNumber(topic.occurrences)})</span></li>`,
|
||||||
|
).join('')}</ul>`
|
||||||
|
: '<div class="text-muted text-sm">No indexed topics captured in this window</div>';
|
||||||
|
|
||||||
|
const topSessionsHtml = topSessions.length > 0
|
||||||
|
? `<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Session</th>
|
||||||
|
<th>Messages</th>
|
||||||
|
<th>Last Active</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${topSessions.map((session) => `
|
||||||
|
<tr>
|
||||||
|
<td>${escapeHtml(session.sessionId)}</td>
|
||||||
|
<td>${formatNumber(session.messages)}</td>
|
||||||
|
<td>${timeAgo(session.lastActivity * 1000)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`
|
||||||
|
: '<div class="text-muted text-sm">No session activity in this window</div>';
|
||||||
|
|
||||||
|
const dailyHtml = daily.length > 0
|
||||||
|
? `<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Day</th>
|
||||||
|
<th>Sessions</th>
|
||||||
|
<th>Messages</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${daily.slice(0, 7).map((row) => `
|
||||||
|
<tr>
|
||||||
|
<td>${formatDay(row.day)}</td>
|
||||||
|
<td>${formatNumber(row.sessions)}</td>
|
||||||
|
<td>${formatNumber(row.messages)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>`
|
||||||
|
: '<div class="text-muted text-sm">No daily activity in this window</div>';
|
||||||
|
|
||||||
|
el.innerHTML = `
|
||||||
|
${summaryHtml}
|
||||||
|
<div class="services-grid">
|
||||||
|
<div class="service-card">
|
||||||
|
<span class="service-name">Top Tools</span>
|
||||||
|
${topToolsHtml}
|
||||||
|
</div>
|
||||||
|
<div class="service-card">
|
||||||
|
<span class="service-name">Top Topics</span>
|
||||||
|
${topTopicsHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="services-grid">
|
||||||
|
<div class="service-card">
|
||||||
|
<span class="service-name">Top Sessions</span>
|
||||||
|
${topSessionsHtml}
|
||||||
|
</div>
|
||||||
|
<div class="service-card">
|
||||||
|
<span class="service-name">Daily Trend (Last 7 Rows)</span>
|
||||||
|
${dailyHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
function _updateChannels(channelsData) {
|
function _updateChannels(channelsData) {
|
||||||
const el = document.getElementById('ops-channels');
|
const el = document.getElementById('ops-channels');
|
||||||
if (!el) {return;}
|
if (!el) {return;}
|
||||||
@@ -285,11 +414,12 @@ async function fetchFast(client) {
|
|||||||
|
|
||||||
async function fetchSlow(client) {
|
async function fetchSlow(client) {
|
||||||
try {
|
try {
|
||||||
const [health, services] = await Promise.all([
|
const [health, services, sessionAnalytics] = await Promise.all([
|
||||||
client.call('system.health'),
|
client.call('system.health'),
|
||||||
client.call('system.services'),
|
client.call('system.services'),
|
||||||
|
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
|
||||||
]);
|
]);
|
||||||
return { health, services };
|
return { health, services, sessionAnalytics };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -320,6 +450,7 @@ async function loadDashboard(el, client) {
|
|||||||
}
|
}
|
||||||
if (slow) {
|
if (slow) {
|
||||||
updateServices(slow.services);
|
updateServices(slow.services);
|
||||||
|
updateSessionAnalytics(slow.sessionAnalytics);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast refresh: 3 seconds for metrics, events, requests
|
// Fast refresh: 3 seconds for metrics, events, requests
|
||||||
@@ -341,6 +472,7 @@ async function loadDashboard(el, client) {
|
|||||||
_lastHealth = data.health;
|
_lastHealth = data.health;
|
||||||
updateCounters(_lastMetrics, data.health);
|
updateCounters(_lastMetrics, data.health);
|
||||||
updateServices(data.services);
|
updateServices(data.services);
|
||||||
|
updateSessionAnalytics(data.sessionAnalytics);
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user