feat(dashboard): configure services from clickable service cards
This commit is contained in:
+14
-1
@@ -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%)",
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user