feat(dashboard): configure services from clickable service cards

This commit is contained in:
William Valentin
2026-02-19 10:50:16 -08:00
parent 3b507d503f
commit 7a2176c15c
4 changed files with 442 additions and 7 deletions
+14 -1
View File
@@ -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%)",
+126
View File
@@ -419,6 +419,132 @@ const PATCHABLE_KEYS: Record<string, (config: Config, value: unknown) => 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']>;
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']>;
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']>;
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']>;
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']>;
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'];
+64
View File
@@ -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();
+238 -6
View File
@@ -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) {
</div>
<h2 class="text-lg font-semibold text-zinc-50 mb-4 mt-8 pb-2 border-b border-zinc-800">Services</h2>
<div class="text-xs text-zinc-500 mb-2">Click a service card to configure.</div>
<div id="ops-services" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<div class="text-sm text-zinc-500">Loading...</div>
</div>
<div id="ops-service-config-modal-root"></div>
`;
}
@@ -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 = '<div class="text-sm text-zinc-500">No services configured</div>';
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 `<div class="bg-zinc-900 border border-zinc-800 rounded-lg p-3 flex flex-col gap-1 border-l-4 ${borderColor}">
return `<button class="service-card text-left bg-zinc-900 border border-zinc-800 rounded-lg p-3 flex flex-col gap-1 border-l-4 ${borderColor} hover:border-zinc-700 transition-colors" data-service-name="${escapeHtml(svc.name)}">
<div class="flex items-center gap-2">
<span class="text-sm shrink-0">${typeIcon}</span>
<span class="text-sm font-semibold text-zinc-50">${escapeHtml(svc.name)}${itemCount}</span>
</div>
<span class="text-xs uppercase ${statusColor}">${escapeHtml(svc.status)}</span>
<span class="text-xs text-zinc-500">${escapeHtml(svc.description)}</span>
<span class="text-xs text-zinc-600">Click to configure</span>
${svc.error ? `<span class="text-xs text-red-400">Error: ${escapeHtml(String(svc.error))}</span>` : ''}
</div>`;
</button>`;
}).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'
? `
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-500">Interval (e.g. 5m)</span>
<input id="svc-heartbeat-interval" type="text" value="${escapeHtml(String(getConfigValue('automation.heartbeat.interval', '5m')))}" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-500">Notify cooldown</span>
<input id="svc-heartbeat-notify-cooldown" type="text" value="${escapeHtml(String(getConfigValue('automation.heartbeat.notify_cooldown', '30m')))}" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-500">Failure threshold</span>
<input id="svc-heartbeat-failure-threshold" type="number" min="1" max="10" value="${escapeHtml(String(getConfigValue('automation.heartbeat.failure_threshold', 2)))}" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-zinc-500">Disk threshold (MB)</span>
<input id="svc-heartbeat-disk-threshold" type="number" min="10" value="${escapeHtml(String(getConfigValue('automation.heartbeat.disk_threshold_mb', 100)))}" class="bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm focus:border-blue-500 outline-none" />
</label>
</div>
<div class="mt-2">
<div class="text-xs text-zinc-500 mb-1">Checks</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-1">
${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 `
<label class="flex items-center gap-2 text-xs text-zinc-300">
<input type="checkbox" data-heartbeat-check="${check}" ${selected ? 'checked' : ''} />
<span>${escapeHtml(check)}</span>
</label>
`;
}).join('')}
</div>
</div>
`
: '';
const toneClass = _serviceConfigState.tone === 'success'
? 'text-green-500'
: _serviceConfigState.tone === 'error'
? 'text-red-500'
: 'text-zinc-500';
root.innerHTML = `
<div id="svc-config-overlay" class="fixed inset-0 bg-black/60 z-40"></div>
<div class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div class="w-full max-w-3xl bg-zinc-900 border border-zinc-700 rounded-lg p-4 shadow-xl">
<div class="flex items-start justify-between gap-4 mb-3">
<div>
<div class="text-lg font-semibold text-zinc-50">Configure ${escapeHtml(service.name)}</div>
<div class="text-xs text-zinc-400">${escapeHtml(service.description ?? '')}</div>
</div>
<button id="svc-config-close" class="px-2 py-1 text-xs border border-zinc-700 rounded text-zinc-300 hover:bg-zinc-800">Close</button>
</div>
<div class="mb-3 p-3 border border-zinc-800 rounded bg-zinc-950/60">
<div class="text-xs uppercase text-zinc-500 mb-2">Quick Settings</div>
${hasQuickToggle ? `
<label class="flex items-center gap-2 mb-2">
<input id="svc-quick-enabled" type="checkbox" ${quickToggleValue ? 'checked' : ''} />
<span class="text-sm text-zinc-200">Enabled</span>
</label>
` : '<div class="text-xs text-zinc-500 mb-2">No quick toggle available for this service.</div>'}
${heartbeatSection}
</div>
<div class="mb-3 p-3 border border-zinc-800 rounded bg-zinc-950/60">
<div class="text-xs uppercase text-zinc-500 mb-2">Advanced Patch (optional JSON)</div>
<textarea id="svc-advanced-patch" rows="5" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-2 py-1.5 text-sm font-mono focus:border-blue-500 outline-none" placeholder='{"automation.heartbeat.enabled": true}'>${escapeHtml(_serviceConfigState.advancedPatch ?? '')}</textarea>
</div>
<div class="flex items-center justify-between gap-2">
<div id="svc-config-status" class="text-xs ${toneClass}">${escapeHtml(_serviceConfigState.status ?? '')}</div>
<div class="flex items-center gap-2">
<button id="svc-config-cancel" class="px-3 py-1.5 text-sm border border-zinc-700 rounded text-zinc-300 hover:bg-zinc-800">Cancel</button>
<button id="svc-config-save" class="px-3 py-1.5 text-sm border border-zinc-700 rounded bg-zinc-800 text-zinc-100 hover:bg-zinc-700">Save</button>
</div>
</div>
</div>
</div>
`;
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);