feat(settings): harden settings API and overhaul SettingsModal

Security:
- Strip auth credential keys (auth_username, auth_password_hash,
  auth_jwt_secret) from GET /api/settings response
- Add allowlist guard to POST /api/settings — rejects unknown or
  auth-namespace keys with a 400

Backend:
- Add PATCH /api/settings bulk endpoint with Zod schema validation
  (type coercion, range checks, URL format) and atomic SQLite transaction
- Add system_state table — moves last_janitor_alert_timestamp out of
  global_settings; adds getSystemState/setSystemState on DatabaseService
- Add metrics_retention_hours and log_retention_days configurable settings;
  MonitorService reads both dynamically each evaluation cycle
- Add cleanupOldNotifications(days) to DatabaseService, called each cycle

Frontend:
- Replace single isLoading flag with per-operation states
  (isSavingSystem, isSavingDeveloper, isSavingPassword, isSavingRegistry,
  isSavingAgent/isTestingAgent per agent type)
- Add skeleton loader that blocks interaction until fetchSettings resolves
- Explicit key-picking in fetchSettings — auth keys cannot enter state
- Unsaved-changes amber dot on System Limits and Developer sidebar items
- Separate saveSystemSettings / saveDeveloperSettings — no cross-tab clobber
- Developer tab gains Data Retention section (metrics hours, log days)
- All settings saves use new PATCH /api/settings endpoint
This commit is contained in:
SaelixCode
2026-03-20 19:57:34 -04:00
parent ae4540bf46
commit 322e717514
7 changed files with 580 additions and 295 deletions
+23
View File
@@ -136,6 +136,11 @@ export class DatabaseService {
status TEXT NOT NULL DEFAULT 'unknown',
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS system_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
// Apply migrations safely (ignore if columns already exist)
@@ -167,6 +172,8 @@ export class DatabaseService {
stmt.run('docker_janitor_gb', '5');
stmt.run('global_logs_refresh', '5');
stmt.run('developer_mode', '0');
stmt.run('metrics_retention_hours', '24');
stmt.run('log_retention_days', '30');
// Seed the default local node if none exists
const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0;
@@ -244,6 +251,17 @@ export class DatabaseService {
stmt.run(key, value);
}
// --- System State (operational/runtime values — not user-defined config) ---
public getSystemState(key: string): string | null {
const row = this.db.prepare('SELECT value FROM system_state WHERE key = ?').get(key) as { value: string } | undefined;
return row?.value ?? null;
}
public setSystemState(key: string, value: string): void {
this.db.prepare('INSERT OR REPLACE INTO system_state (key, value) VALUES (?, ?)').run(key, value);
}
// --- Stack Alerts ---
public getStackAlerts(stackName?: string): StackAlert[] {
@@ -353,6 +371,11 @@ export class DatabaseService {
stmt.run(cutoff);
}
public cleanupOldNotifications(daysToKeep = 30): void {
const cutoff = Date.now() - (daysToKeep * 24 * 60 * 60 * 1000);
this.db.prepare('DELETE FROM notification_history WHERE timestamp < ?').run(cutoff);
}
// --- Nodes ---
public getNodes(): Node[] {
+11 -4
View File
@@ -187,13 +187,14 @@ export class MonitorService {
// Only trigger once every while? To avoid spamming, we just check if it's over limit
// Let's ensure we only spam once per limit breach. We can use a local static variable.
const LAST_JANITOR_ALERT_KEY = 'last_janitor_alert_timestamp';
const lastAlert = parseInt(settings[LAST_JANITOR_ALERT_KEY] || '0', 10);
const lastAlertRaw = DatabaseService.getInstance().getSystemState(LAST_JANITOR_ALERT_KEY);
const lastAlert = parseInt(lastAlertRaw || '0', 10);
const janitorCooldown = 24 * 60 * 60 * 1000; // 24 hours cooldown for janitor
if (reclaimGb >= janitorLimitGb) {
if (Date.now() - lastAlert > janitorCooldown) {
await notifier.dispatchAlert('info', `Your system has accumulated ${reclaimGb.toFixed(1)} GB of unused Docker data. Consider using the Janitor tool.`);
DatabaseService.getInstance().updateGlobalSetting(LAST_JANITOR_ALERT_KEY, Date.now().toString());
DatabaseService.getInstance().setSystemState(LAST_JANITOR_ALERT_KEY, Date.now().toString());
}
}
}
@@ -298,8 +299,14 @@ export class MonitorService {
}
try {
db.cleanupOldMetrics(24);
} catch (e) { }
const settings = db.getGlobalSettings();
const retentionHours = parseInt(settings['metrics_retention_hours'] || '24', 10);
db.cleanupOldMetrics(isNaN(retentionHours) ? 24 : retentionHours);
const retentionDays = parseInt(settings['log_retention_days'] || '30', 10);
db.cleanupOldNotifications(isNaN(retentionDays) ? 30 : retentionDays);
} catch (e) {
console.error('MonitorService: failed to cleanup old data', e);
}
}
private evaluateCondition(actual: number, operator: string, threshold: number): boolean {