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
+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();