diff --git a/docs/plans/state.json b/docs/plans/state.json index 61de61c..1198ab1 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -5732,10 +5732,23 @@ "docs/plans/state.json" ], "test_status": "configuration-only change" + }, + "dashboard-service-card-config-modal": { + "status": "completed", + "date": "2026-02-19", + "updated": "2026-02-19", + "summary": "Made Services cards clickable in Live Ops Dashboard and added an inline configuration modal with quick controls for service enablement and heartbeat settings, plus advanced JSON patch support for per-service config updates via config.patch.", + "files_modified": [ + "src/gateway/ui/pages/dashboard.js", + "src/gateway/handlers/config.ts", + "src/gateway/handlers/handlers.test.ts", + "docs/plans/state.json" + ], + "test_status": "pnpm test:run src/gateway/handlers/handlers.test.ts + pnpm typecheck passing" } }, "overall_progress": { - "total_test_count": 1932, + "total_test_count": 1933, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", diff --git a/src/gateway/handlers/config.ts b/src/gateway/handlers/config.ts index b4d5aad..1a15e6d 100644 --- a/src/gateway/handlers/config.ts +++ b/src/gateway/handlers/config.ts @@ -419,6 +419,132 @@ const PATCHABLE_KEYS: Record boolean config.automation.daily_briefing.model_tier = value; return true; }, + 'automation.gmail.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.automation ??= {} as Config['automation']; + config.automation.gmail ??= {} as NonNullable; + config.automation.gmail.enabled = value; + return true; + }, + 'automation.gcal.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.automation ??= {} as Config['automation']; + config.automation.gcal ??= {} as NonNullable; + config.automation.gcal.enabled = value; + return true; + }, + 'automation.gdocs.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.automation ??= {} as Config['automation']; + config.automation.gdocs ??= {} as NonNullable; + config.automation.gdocs.enabled = value; + return true; + }, + 'automation.gdrive.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.automation ??= {} as Config['automation']; + config.automation.gdrive ??= {} as NonNullable; + config.automation.gdrive.enabled = value; + return true; + }, + 'automation.gtasks.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.automation ??= {} as Config['automation']; + config.automation.gtasks ??= {} as NonNullable; + config.automation.gtasks.enabled = value; + return true; + }, + 'automation.heartbeat.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.automation ??= {} as Config['automation']; + config.automation.heartbeat ??= {} as Config['automation']['heartbeat']; + config.automation.heartbeat.enabled = value; + return true; + }, + 'automation.heartbeat.interval': (config, value) => { + if (typeof value !== 'string' || value.trim().length === 0) {return false;} + config.automation ??= {} as Config['automation']; + config.automation.heartbeat ??= {} as Config['automation']['heartbeat']; + config.automation.heartbeat.interval = value.trim(); + return true; + }, + 'automation.heartbeat.notify_cooldown': (config, value) => { + if (typeof value !== 'string' || value.trim().length === 0) {return false;} + config.automation ??= {} as Config['automation']; + config.automation.heartbeat ??= {} as Config['automation']['heartbeat']; + config.automation.heartbeat.notify_cooldown = value.trim(); + return true; + }, + 'automation.heartbeat.failure_threshold': (config, value) => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > 10) {return false;} + config.automation ??= {} as Config['automation']; + config.automation.heartbeat ??= {} as Config['automation']['heartbeat']; + config.automation.heartbeat.failure_threshold = Math.floor(value); + return true; + }, + 'automation.heartbeat.disk_threshold_mb': (config, value) => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 10) {return false;} + config.automation ??= {} as Config['automation']; + config.automation.heartbeat ??= {} as Config['automation']['heartbeat']; + config.automation.heartbeat.disk_threshold_mb = Math.floor(value); + return true; + }, + 'automation.heartbeat.process_memory_threshold_mb': (config, value) => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 64) {return false;} + config.automation ??= {} as Config['automation']; + config.automation.heartbeat ??= {} as Config['automation']['heartbeat']; + config.automation.heartbeat.process_memory_threshold_mb = Math.floor(value); + return true; + }, + 'automation.heartbeat.backup_failure_threshold': (config, value) => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > 10) {return false;} + config.automation ??= {} as Config['automation']; + config.automation.heartbeat ??= {} as Config['automation']['heartbeat']; + config.automation.heartbeat.backup_failure_threshold = Math.floor(value); + return true; + }, + 'automation.heartbeat.provider_error_rate_threshold': (config, value) => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0 || value > 1) {return false;} + config.automation ??= {} as Config['automation']; + config.automation.heartbeat ??= {} as Config['automation']['heartbeat']; + config.automation.heartbeat.provider_error_rate_threshold = value; + return true; + }, + 'automation.heartbeat.provider_error_min_calls': (config, value) => { + if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {return false;} + config.automation ??= {} as Config['automation']; + config.automation.heartbeat ??= {} as Config['automation']['heartbeat']; + config.automation.heartbeat.provider_error_min_calls = Math.floor(value); + return true; + }, + 'automation.heartbeat.checks': (config, value) => { + const allowed = ['gateway', 'model', 'channels', 'memory', 'disk', 'process_memory', 'backup', 'provider_errors']; + if (!Array.isArray(value) || !value.every((entry) => typeof entry === 'string' && allowed.includes(entry))) { + return false; + } + config.automation ??= {} as Config['automation']; + config.automation.heartbeat ??= {} as Config['automation']['heartbeat']; + config.automation.heartbeat.checks = value as Config['automation']['heartbeat']['checks']; + return true; + }, + 'backup.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.backup ??= {} as Config['backup']; + config.backup.enabled = value; + return true; + }, + 'audio.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.audio ??= {} as Config['audio']; + config.audio.enabled = value; + return true; + }, + 'sandbox.enabled': (config, value) => { + if (typeof value !== 'boolean') {return false;} + config.sandbox ??= {} as Config['sandbox']; + config.sandbox.enabled = value; + return true; + }, 'memory.daily_log.enabled': (config, value) => { if (typeof value !== 'boolean') {return false;} config.memory ??= {} as Config['memory']; diff --git a/src/gateway/handlers/handlers.test.ts b/src/gateway/handlers/handlers.test.ts index 6f90c0a..9919224 100644 --- a/src/gateway/handlers/handlers.test.ts +++ b/src/gateway/handlers/handlers.test.ts @@ -1285,6 +1285,70 @@ describe('config handlers', () => { expect(r.persisted).toBe(false); }); + it('config.patch applies service configuration keys for heartbeat and service toggles', async () => { + const config = makeConfig(); + const handlers = createConfigHandlers({ config: asConfigValue(config) }); + const req: GatewayRequest = { + id: 41, + method: 'config.patch', + params: { + patches: { + 'automation.heartbeat.enabled': true, + 'automation.heartbeat.interval': '1m', + 'automation.heartbeat.notify_cooldown': '10m', + 'automation.heartbeat.failure_threshold': 3, + 'automation.heartbeat.disk_threshold_mb': 250, + 'automation.heartbeat.process_memory_threshold_mb': 2048, + 'automation.heartbeat.backup_failure_threshold': 2, + 'automation.heartbeat.provider_error_rate_threshold': 0.4, + 'automation.heartbeat.provider_error_min_calls': 8, + 'automation.heartbeat.checks': ['gateway', 'model', 'disk'], + 'automation.gmail.enabled': true, + 'automation.gcal.enabled': true, + 'backup.enabled': true, + 'audio.enabled': true, + 'sandbox.enabled': true, + }, + }, + }; + const result = await handlers['config.patch'](req) as GatewayResponse; + const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean }; + + expect(r.applied).toEqual([ + 'automation.heartbeat.enabled', + 'automation.heartbeat.interval', + 'automation.heartbeat.notify_cooldown', + 'automation.heartbeat.failure_threshold', + 'automation.heartbeat.disk_threshold_mb', + 'automation.heartbeat.process_memory_threshold_mb', + 'automation.heartbeat.backup_failure_threshold', + 'automation.heartbeat.provider_error_rate_threshold', + 'automation.heartbeat.provider_error_min_calls', + 'automation.heartbeat.checks', + 'automation.gmail.enabled', + 'automation.gcal.enabled', + 'backup.enabled', + 'audio.enabled', + 'sandbox.enabled', + ]); + expect(r.rejected).toEqual([]); + expect(getPath(config, 'automation', 'heartbeat', 'enabled')).toBe(true); + expect(getPath(config, 'automation', 'heartbeat', 'interval')).toBe('1m'); + expect(getPath(config, 'automation', 'heartbeat', 'notify_cooldown')).toBe('10m'); + expect(getPath(config, 'automation', 'heartbeat', 'failure_threshold')).toBe(3); + expect(getPath(config, 'automation', 'heartbeat', 'disk_threshold_mb')).toBe(250); + expect(getPath(config, 'automation', 'heartbeat', 'process_memory_threshold_mb')).toBe(2048); + expect(getPath(config, 'automation', 'heartbeat', 'backup_failure_threshold')).toBe(2); + expect(getPath(config, 'automation', 'heartbeat', 'provider_error_rate_threshold')).toBe(0.4); + expect(getPath(config, 'automation', 'heartbeat', 'provider_error_min_calls')).toBe(8); + expect(getPath(config, 'automation', 'heartbeat', 'checks')).toEqual(['gateway', 'model', 'disk']); + expect(getPath(config, 'automation', 'gmail', 'enabled')).toBe(true); + expect(getPath(config, 'automation', 'gcal', 'enabled')).toBe(true); + expect(getPath(config, 'backup', 'enabled')).toBe(true); + expect(getPath(config, 'audio', 'enabled')).toBe(true); + expect(getPath(config, 'sandbox', 'enabled')).toBe(true); + }); + it('config.patch persists changes when persistence callback is provided', async () => { const config = makeConfig(); const persist = vi.fn(); diff --git a/src/gateway/ui/pages/dashboard.js b/src/gateway/ui/pages/dashboard.js index 0949fdd..aee7155 100644 --- a/src/gateway/ui/pages/dashboard.js +++ b/src/gateway/ui/pages/dashboard.js @@ -14,9 +14,30 @@ let _assistantSaveState = null; let _lastAssistantConfig = null; let _assistantManualOverrides = new Set(); let _assistantModelDefaultsDraft = null; +let _lastServices = []; +let _serviceConfigState = { + open: false, + serviceName: null, + status: null, + tone: 'neutral', + advancedPatch: '', +}; const MODEL_DEFAULT_TASK_KEYS = ['compaction', 'memory_extraction', 'classification', 'tool_summarisation', 'complex_reasoning']; const MODEL_DEFAULT_TIER_KEYS = ['default', 'fast', 'complex', 'local']; +const HEARTBEAT_CHECK_KEYS = ['gateway', 'model', 'channels', 'memory', 'disk', 'process_memory', 'backup', 'provider_errors']; +const SERVICE_TOGGLE_PATCH_PATHS = { + heartbeat: 'automation.heartbeat.enabled', + daily_briefing: 'automation.daily_briefing.enabled', + gmail: 'automation.gmail.enabled', + gcal: 'automation.gcal.enabled', + gdocs: 'automation.gdocs.enabled', + gdrive: 'automation.gdrive.enabled', + gtasks: 'automation.gtasks.enabled', + backup: 'backup.enabled', + audio_transcription: 'audio.enabled', + sandbox: 'sandbox.enabled', +}; function formatUptime(seconds) { const d = Math.floor(seconds / 86400); @@ -237,9 +258,11 @@ function renderSkeleton(el) {

Services

+
Click a service card to configure.
Loading...
+
`; } @@ -1052,12 +1075,14 @@ function updateAssistantHealth(configData) { // Force immediate refresh of slow sections after applying. const refreshed = await fetchSlow(_dashboardClient); if (refreshed) { + if (refreshed.config) { + _lastAssistantConfig = refreshed.config; + } updateServices(refreshed.services); updateSessionAnalytics(refreshed.sessionAnalytics); updateContextHealth(refreshed.contextUsage); // Only re-render assistant controls from a confirmed config snapshot. if (refreshed.config) { - _lastAssistantConfig = refreshed.config; updateAssistantHealth(_lastAssistantConfig); } else if (_lastAssistantConfig) { updateAssistantHealth(_lastAssistantConfig); @@ -1123,9 +1148,11 @@ function updateServices(servicesData) { if (!el) {return;} const services = servicesData?.services ?? []; + _lastServices = services; if (services.length === 0) { el.innerHTML = '
No services configured
'; + renderServiceConfigModal(); return; } @@ -1142,20 +1169,221 @@ function updateServices(servicesData) { ? 'text-green-500' : svc.status === 'configured' ? 'text-blue-500' - : svc.status === 'error' + : svc.status === 'error' ? 'text-red-500' : 'text-zinc-500'; const itemCount = svc.itemCount ? ` (${svc.itemCount})` : ''; - return `
+ return `
`; + `; }).join(''); + + el.querySelectorAll('.service-card').forEach((card) => { + card.addEventListener('click', () => { + const serviceName = card.getAttribute('data-service-name'); + if (!serviceName) {return;} + _serviceConfigState.open = true; + _serviceConfigState.serviceName = serviceName; + _serviceConfigState.status = null; + _serviceConfigState.tone = 'neutral'; + renderServiceConfigModal(); + }); + }); + + renderServiceConfigModal(); +} + +function getConfigValue(path, fallbackValue) { + const value = getByPath(_lastAssistantConfig, path); + return value === undefined ? fallbackValue : value; +} + +function renderServiceConfigModal() { + const root = document.getElementById('ops-service-config-modal-root'); + if (!root) {return;} + if (!_serviceConfigState.open || !_serviceConfigState.serviceName) { + root.innerHTML = ''; + return; + } + + const service = _lastServices.find((svc) => svc.name === _serviceConfigState.serviceName); + if (!service) { + _serviceConfigState.open = false; + root.innerHTML = ''; + return; + } + + const quickTogglePath = SERVICE_TOGGLE_PATCH_PATHS[service.name]; + const hasQuickToggle = Boolean(quickTogglePath); + const quickToggleValue = hasQuickToggle ? Boolean(getConfigValue(quickTogglePath, false)) : false; + const heartbeatSection = service.name === 'heartbeat' + ? ` +
+ + + + +
+
+
Checks
+
+ ${HEARTBEAT_CHECK_KEYS.map((check) => { + const selected = Array.isArray(getConfigValue('automation.heartbeat.checks', HEARTBEAT_CHECK_KEYS)) + && getConfigValue('automation.heartbeat.checks', HEARTBEAT_CHECK_KEYS).includes(check); + return ` + + `; + }).join('')} +
+
+ ` + : ''; + + const toneClass = _serviceConfigState.tone === 'success' + ? 'text-green-500' + : _serviceConfigState.tone === 'error' + ? 'text-red-500' + : 'text-zinc-500'; + + root.innerHTML = ` +
+
+
+
+
+
Configure ${escapeHtml(service.name)}
+
${escapeHtml(service.description ?? '')}
+
+ +
+
+
Quick Settings
+ ${hasQuickToggle ? ` + + ` : '
No quick toggle available for this service.
'} + ${heartbeatSection} +
+
+
Advanced Patch (optional JSON)
+ +
+
+
${escapeHtml(_serviceConfigState.status ?? '')}
+
+ + +
+
+
+
+ `; + + const closeModal = () => { + _serviceConfigState.open = false; + renderServiceConfigModal(); + }; + root.querySelector('#svc-config-close')?.addEventListener('click', closeModal); + root.querySelector('#svc-config-cancel')?.addEventListener('click', closeModal); + root.querySelector('#svc-config-overlay')?.addEventListener('click', closeModal); + + root.querySelector('#svc-config-save')?.addEventListener('click', async () => { + if (!_dashboardClient || !_serviceConfigState.serviceName) {return;} + const patches = {}; + + if (quickTogglePath) { + patches[quickTogglePath] = Boolean(root.querySelector('#svc-quick-enabled')?.checked); + } + if (_serviceConfigState.serviceName === 'heartbeat') { + patches['automation.heartbeat.interval'] = (root.querySelector('#svc-heartbeat-interval')?.value ?? '5m').trim(); + patches['automation.heartbeat.notify_cooldown'] = (root.querySelector('#svc-heartbeat-notify-cooldown')?.value ?? '30m').trim(); + patches['automation.heartbeat.failure_threshold'] = Number(root.querySelector('#svc-heartbeat-failure-threshold')?.value ?? 2); + patches['automation.heartbeat.disk_threshold_mb'] = Number(root.querySelector('#svc-heartbeat-disk-threshold')?.value ?? 100); + patches['automation.heartbeat.checks'] = HEARTBEAT_CHECK_KEYS.filter( + (check) => Boolean(root.querySelector(`[data-heartbeat-check="${check}"]`)?.checked), + ); + } + + const advancedRaw = (root.querySelector('#svc-advanced-patch')?.value ?? '').trim(); + _serviceConfigState.advancedPatch = advancedRaw; + if (advancedRaw.length > 0) { + try { + const parsed = JSON.parse(advancedRaw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Advanced patch must be a JSON object'); + } + Object.assign(patches, parsed); + } catch (error) { + _serviceConfigState.status = error instanceof Error ? error.message : String(error); + _serviceConfigState.tone = 'error'; + renderServiceConfigModal(); + return; + } + } + + if (Object.keys(patches).length === 0) { + _serviceConfigState.status = 'No changes to save.'; + _serviceConfigState.tone = 'neutral'; + renderServiceConfigModal(); + return; + } + + try { + const result = await _dashboardClient.call('config.patch', { patches }); + const applied = Array.isArray(result?.applied) ? result.applied : []; + const rejected = Array.isArray(result?.rejected) ? result.rejected : []; + if (applied.length === 0) { + _serviceConfigState.status = rejected.length > 0 + ? `No changes applied. Rejected: ${rejected.join(', ')}` + : 'No changes were applied.'; + _serviceConfigState.tone = 'error'; + renderServiceConfigModal(); + return; + } + _serviceConfigState.status = `Saved ${applied.length} setting(s).${rejected.length > 0 ? ` Rejected: ${rejected.join(', ')}` : ''}`; + _serviceConfigState.tone = rejected.length > 0 ? 'error' : 'success'; + + const refreshed = await fetchSlow(_dashboardClient); + if (refreshed) { + updateServices(refreshed.services); + updateSessionAnalytics(refreshed.sessionAnalytics); + updateContextHealth(refreshed.contextUsage); + if (refreshed.config) { + _lastAssistantConfig = refreshed.config; + updateAssistantHealth(_lastAssistantConfig); + } + } + renderServiceConfigModal(); + } catch (error) { + _serviceConfigState.status = `Save failed: ${error instanceof Error ? error.message : String(error)}`; + _serviceConfigState.tone = 'error'; + renderServiceConfigModal(); + } + }); } // ── Data fetching ─────────────────────────────────────────────── @@ -1224,6 +1452,9 @@ async function loadDashboard(el, client) { updateEvents(fast.eventsData); updateActiveRequests(fast.requestsData); } + if (slow?.config) { + _lastAssistantConfig = slow.config; + } if (slow?.services) { updateServices(slow.services); } @@ -1234,7 +1465,6 @@ async function loadDashboard(el, client) { updateContextHealth(slow.contextUsage); } if (slow?.config) { - _lastAssistantConfig = slow.config; updateAssistantHealth(_lastAssistantConfig); } @@ -1257,6 +1487,9 @@ async function loadDashboard(el, client) { _lastHealth = data.health; updateCounters(_lastMetrics, data.health); } + if (data.config) { + _lastAssistantConfig = data.config; + } if (data.services) { updateServices(data.services); } @@ -1267,7 +1500,6 @@ async function loadDashboard(el, client) { updateContextHealth(data.contextUsage); } if (data.config) { - _lastAssistantConfig = data.config; updateAssistantHealth(_lastAssistantConfig); } }, 10000);