feat(dashboard): add assistant health panel with quick actions

This commit is contained in:
William Valentin
2026-02-18 12:06:21 -08:00
parent 43b9324c14
commit ef3f401278
4 changed files with 176 additions and 3 deletions
+135 -2
View File
@@ -7,6 +7,7 @@
let _fastTimer = null;
let _slowTimer = null;
let _dashboardClient = null;
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
@@ -80,6 +81,11 @@ function renderSkeleton(el) {
<div class="text-muted text-sm">Loading...</div>
</div>
<h2 class="section-title">Assistant Health</h2>
<div id="ops-assistant-health">
<div class="text-muted text-sm">Loading...</div>
</div>
<h2 class="section-title">Event Stream</h2>
<div class="event-stream" id="ops-events">
<div class="event-row event-level-info">Loading events...</div>
@@ -411,6 +417,128 @@ function updateContextHealth(contextData) {
`;
}
async function applyAssistantPatch(patches, statusEl) {
if (!_dashboardClient) {return;}
if (statusEl) {
statusEl.textContent = 'Saving...';
statusEl.className = 'text-sm text-muted';
}
try {
const result = await _dashboardClient.call('config.patch', { patches });
const rejected = result?.rejected ?? [];
const persistError = result?.persistError;
const applied = result?.applied ?? [];
const persisted = result?.persisted === true;
if (statusEl) {
if (persistError) {
statusEl.textContent = `Save failed: ${persistError}`;
statusEl.className = 'text-sm text-error';
} else if (rejected.length > 0) {
statusEl.textContent = `Rejected: ${rejected.join(', ')}`;
statusEl.className = 'text-sm text-error';
} else if (!persisted) {
statusEl.textContent = `Runtime saved (${applied.length} updated)`;
statusEl.className = 'text-sm text-muted';
} else {
statusEl.textContent = `Saved (${applied.length} updated)`;
statusEl.className = 'text-sm text-success';
}
}
} catch (error) {
if (statusEl) {
statusEl.textContent = `Save error: ${error instanceof Error ? error.message : String(error)}`;
statusEl.className = 'text-sm text-error';
}
}
}
function updateAssistantHealth(configData) {
const el = document.getElementById('ops-assistant-health');
if (!el) {return;}
const automation = configData?.automation ?? {};
const memory = configData?.memory ?? {};
const tts = configData?.tts ?? {};
const deliveryMode = automation.delivery_mode ?? 'shared_session';
const announce = deliveryMode === 'announce';
const dailyBriefing = Boolean(automation.daily_briefing?.enabled);
const memoryDaily = Boolean(memory.daily_log?.enabled);
const memoryProactive = Boolean(memory.proactive_extract?.enabled);
const proactiveThreshold = Number(memory.proactive_extract?.min_tool_calls ?? 1);
const ttsEnabled = Boolean(tts.enabled);
const chip = (label, value) => `
<div class="assistant-chip">
<span class="assistant-chip-label">${escapeHtml(label)}</span>
<span class="assistant-chip-value ${value ? 'text-success' : 'text-muted'}">${value ? 'ON' : 'OFF'}</span>
</div>
`;
el.innerHTML = `
<div class="assistant-health-grid">
${chip('Announce Mode', announce)}
${chip('Daily Briefing', dailyBriefing)}
${chip('Memory Daily Log', memoryDaily)}
${chip('Proactive Extract', memoryProactive)}
${chip('TTS Replies', ttsEnabled)}
<div class="assistant-chip">
<span class="assistant-chip-label">Extract Threshold</span>
<span class="assistant-chip-value">${Number.isFinite(proactiveThreshold) ? proactiveThreshold : 1}</span>
</div>
</div>
<div class="assistant-actions">
<button class="btn btn-secondary assistant-action-btn" data-action="toggle-announce">
${announce ? 'Disable Announce Mode' : 'Enable Announce Mode'}
</button>
<button class="btn btn-secondary assistant-action-btn" data-action="toggle-daily-briefing">
${dailyBriefing ? 'Disable Daily Briefing' : 'Enable Daily Briefing'}
</button>
<button class="btn btn-secondary assistant-action-btn" data-action="toggle-memory-daily">
${memoryDaily ? 'Disable Daily Log' : 'Enable Daily Log'}
</button>
<button class="btn btn-secondary assistant-action-btn" data-action="toggle-memory-proactive">
${memoryProactive ? 'Disable Proactive Extract' : 'Enable Proactive Extract'}
</button>
<button class="btn btn-secondary assistant-action-btn" data-action="toggle-tts">
${ttsEnabled ? 'Disable TTS' : 'Enable TTS'}
</button>
</div>
<div id="ops-assistant-status" class="text-sm text-muted"></div>
`;
const statusEl = el.querySelector('#ops-assistant-status');
const buttons = el.querySelectorAll('.assistant-action-btn');
buttons.forEach((button) => {
button.addEventListener('click', async () => {
const action = button.getAttribute('data-action');
let patches = null;
if (action === 'toggle-announce') {
patches = { 'automation.delivery_mode': announce ? 'shared_session' : 'announce' };
} else if (action === 'toggle-daily-briefing') {
patches = { 'automation.daily_briefing.enabled': !dailyBriefing };
} else if (action === 'toggle-memory-daily') {
patches = { 'memory.daily_log.enabled': !memoryDaily };
} else if (action === 'toggle-memory-proactive') {
patches = { 'memory.proactive_extract.enabled': !memoryProactive };
} else if (action === 'toggle-tts') {
patches = { 'tts.enabled': !ttsEnabled };
}
if (!patches) {return;}
await applyAssistantPatch(patches, statusEl);
// Force immediate refresh of slow sections after applying.
const refreshed = await fetchSlow(_dashboardClient);
if (refreshed) {
updateServices(refreshed.services);
updateSessionAnalytics(refreshed.sessionAnalytics);
updateContextHealth(refreshed.contextUsage);
updateAssistantHealth(refreshed.config);
}
});
});
}
function _updateChannels(channelsData) {
const el = document.getElementById('ops-channels');
if (!el) {return;}
@@ -479,13 +607,14 @@ async function fetchFast(client) {
async function fetchSlow(client) {
try {
const [health, services, sessionAnalytics, contextUsage] = await Promise.all([
const [health, services, sessionAnalytics, contextUsage, config] = await Promise.all([
client.call('system.health'),
client.call('system.services'),
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
client.call('system.contextUsage'),
client.call('config.get'),
]);
return { health, services, sessionAnalytics, contextUsage };
return { health, services, sessionAnalytics, contextUsage, config };
} catch {
return null;
}
@@ -497,6 +626,7 @@ let _lastHealth = null;
let _lastMetrics = null;
async function loadDashboard(el, client) {
_dashboardClient = client;
renderSkeleton(el);
// Fetch everything initially
@@ -518,6 +648,7 @@ async function loadDashboard(el, client) {
updateServices(slow.services);
updateSessionAnalytics(slow.sessionAnalytics);
updateContextHealth(slow.contextUsage);
updateAssistantHealth(slow.config);
}
// Fast refresh: 3 seconds for metrics, events, requests
@@ -541,6 +672,7 @@ async function loadDashboard(el, client) {
updateServices(data.services);
updateSessionAnalytics(data.sessionAnalytics);
updateContextHealth(data.contextUsage);
updateAssistantHealth(data.config);
}
}, 10000);
}
@@ -561,5 +693,6 @@ export const DashboardPage = {
}
_lastHealth = null;
_lastMetrics = null;
_dashboardClient = null;
},
};
+27
View File
@@ -1544,6 +1544,33 @@ tr:hover td {
color: var(--text-primary);
}
.assistant-health-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.assistant-chip {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: var(--font-size-sm);
}
.assistant-chip-label {
color: var(--text-secondary);
}
.assistant-chip-value {
font-weight: 700;
color: var(--text-primary);
}
/* ── Responsive: Mobile ─────────────────────────────────────── */
@media (max-width: 768px) {