feat(dashboard): add assistant health panel with quick actions
This commit is contained in:
@@ -491,7 +491,7 @@ Flynn includes a built-in web control dashboard served by the WebSocket gateway.
|
|||||||
| **Chat** | Session selector, streaming tool events, markdown rendering with syntax highlighting |
|
| **Chat** | Session selector, streaming tool events, markdown rendering with syntax highlighting |
|
||||||
| **Sessions** | Browse all sessions, view message history, delete sessions |
|
| **Sessions** | Browse all sessions, view message history, delete sessions |
|
||||||
| **Usage** | Token usage summary cards, per-session breakdown table, auto-refresh |
|
| **Usage** | Token usage summary cards, per-session breakdown table, auto-refresh |
|
||||||
| **Settings** | Edit hook patterns (confirm/log/silent), view tools, channels, and redacted config |
|
| **Settings** | Edit hook patterns (confirm/log/silent), Personal Assistant Mode toggles, view tools/services, and redacted config |
|
||||||
|
|
||||||
The dashboard is a vanilla JS SPA with no build step — hash-based routing, ES modules, and the existing WebSocket JSON-RPC protocol.
|
The dashboard is a vanilla JS SPA with no build step — hash-based routing, ES modules, and the existing WebSocket JSON-RPC protocol.
|
||||||
|
|
||||||
|
|||||||
@@ -5467,6 +5467,19 @@
|
|||||||
"docs/plans/state.json"
|
"docs/plans/state.json"
|
||||||
],
|
],
|
||||||
"test_status": "pnpm test:run src/gateway/handlers/handlers.test.ts + pnpm typecheck passing"
|
"test_status": "pnpm test:run src/gateway/handlers/handlers.test.ts + pnpm typecheck passing"
|
||||||
|
},
|
||||||
|
"dashboard-assistant-health-quick-actions": {
|
||||||
|
"status": "completed",
|
||||||
|
"date": "2026-02-18",
|
||||||
|
"updated": "2026-02-18",
|
||||||
|
"summary": "Extended Live Ops Dashboard with an Assistant Health section that surfaces product-feel runtime state (announce mode, daily briefing, memory cadence, TTS) and adds one-click quick actions that apply safe runtime config.patch updates and refresh status immediately.",
|
||||||
|
"files_modified": [
|
||||||
|
"src/gateway/ui/pages/dashboard.js",
|
||||||
|
"src/gateway/ui/style.css",
|
||||||
|
"README.md",
|
||||||
|
"docs/plans/state.json"
|
||||||
|
],
|
||||||
|
"test_status": "pnpm typecheck passing"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"overall_progress": {
|
"overall_progress": {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
let _fastTimer = null;
|
let _fastTimer = null;
|
||||||
let _slowTimer = null;
|
let _slowTimer = null;
|
||||||
|
let _dashboardClient = null;
|
||||||
|
|
||||||
function formatUptime(seconds) {
|
function formatUptime(seconds) {
|
||||||
const d = Math.floor(seconds / 86400);
|
const d = Math.floor(seconds / 86400);
|
||||||
@@ -80,6 +81,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">Assistant Health</h2>
|
||||||
|
<div id="ops-assistant-health">
|
||||||
|
<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>
|
||||||
@@ -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) {
|
function _updateChannels(channelsData) {
|
||||||
const el = document.getElementById('ops-channels');
|
const el = document.getElementById('ops-channels');
|
||||||
if (!el) {return;}
|
if (!el) {return;}
|
||||||
@@ -479,13 +607,14 @@ async function fetchFast(client) {
|
|||||||
|
|
||||||
async function fetchSlow(client) {
|
async function fetchSlow(client) {
|
||||||
try {
|
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.health'),
|
||||||
client.call('system.services'),
|
client.call('system.services'),
|
||||||
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
|
client.call('system.sessionAnalytics', { days: 14, topLimit: 5 }),
|
||||||
client.call('system.contextUsage'),
|
client.call('system.contextUsage'),
|
||||||
|
client.call('config.get'),
|
||||||
]);
|
]);
|
||||||
return { health, services, sessionAnalytics, contextUsage };
|
return { health, services, sessionAnalytics, contextUsage, config };
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -497,6 +626,7 @@ let _lastHealth = null;
|
|||||||
let _lastMetrics = null;
|
let _lastMetrics = null;
|
||||||
|
|
||||||
async function loadDashboard(el, client) {
|
async function loadDashboard(el, client) {
|
||||||
|
_dashboardClient = client;
|
||||||
renderSkeleton(el);
|
renderSkeleton(el);
|
||||||
|
|
||||||
// Fetch everything initially
|
// Fetch everything initially
|
||||||
@@ -518,6 +648,7 @@ async function loadDashboard(el, client) {
|
|||||||
updateServices(slow.services);
|
updateServices(slow.services);
|
||||||
updateSessionAnalytics(slow.sessionAnalytics);
|
updateSessionAnalytics(slow.sessionAnalytics);
|
||||||
updateContextHealth(slow.contextUsage);
|
updateContextHealth(slow.contextUsage);
|
||||||
|
updateAssistantHealth(slow.config);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fast refresh: 3 seconds for metrics, events, requests
|
// Fast refresh: 3 seconds for metrics, events, requests
|
||||||
@@ -541,6 +672,7 @@ async function loadDashboard(el, client) {
|
|||||||
updateServices(data.services);
|
updateServices(data.services);
|
||||||
updateSessionAnalytics(data.sessionAnalytics);
|
updateSessionAnalytics(data.sessionAnalytics);
|
||||||
updateContextHealth(data.contextUsage);
|
updateContextHealth(data.contextUsage);
|
||||||
|
updateAssistantHealth(data.config);
|
||||||
}
|
}
|
||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
@@ -561,5 +693,6 @@ export const DashboardPage = {
|
|||||||
}
|
}
|
||||||
_lastHealth = null;
|
_lastHealth = null;
|
||||||
_lastMetrics = null;
|
_lastMetrics = null;
|
||||||
|
_dashboardClient = null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1544,6 +1544,33 @@ tr:hover td {
|
|||||||
color: var(--text-primary);
|
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 ─────────────────────────────────────── */
|
/* ── Responsive: Mobile ─────────────────────────────────────── */
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user