Files
sencho/backend/src/services/DatabaseService.ts
T
SaelixCode 322e717514 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
2026-03-20 19:57:34 -04:00

477 lines
17 KiB
TypeScript

import Database from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
export interface Agent {
id?: number;
type: 'discord' | 'slack' | 'webhook';
url: string;
enabled: boolean;
}
export interface GlobalSetting {
key: string;
value: string;
}
export interface StackAlert {
id?: number;
stack_name: string;
metric: string;
operator: string;
threshold: number;
duration_mins: number;
cooldown_mins: number;
last_fired_at?: number;
}
export interface Node {
id: number;
name: string;
type: 'local' | 'remote';
compose_dir: string;
is_default: boolean;
status: 'online' | 'offline' | 'unknown';
created_at: number;
api_url?: string;
api_token?: string;
}
export interface NotificationHistory {
id?: number;
level: 'info' | 'warning' | 'error';
message: string;
timestamp: number;
is_read: boolean;
}
export class DatabaseService {
private static instance: DatabaseService;
private db: Database.Database;
private constructor() {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'sencho.db');
this.db = new Database(dbPath);
this.initSchema();
this.migrateJsonConfig(dataDir);
}
public static getInstance(): DatabaseService {
if (!DatabaseService.instance) {
DatabaseService.instance = new DatabaseService();
}
return DatabaseService.instance;
}
public getDb(): Database.Database {
return this.db;
}
private initSchema() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS agents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL,
url TEXT NOT NULL,
enabled INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS global_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS stack_alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stack_name TEXT NOT NULL,
metric TEXT NOT NULL,
operator TEXT NOT NULL,
threshold REAL NOT NULL,
duration_mins INTEGER NOT NULL,
cooldown_mins INTEGER NOT NULL,
last_fired_at INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS notification_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
level TEXT NOT NULL,
message TEXT NOT NULL,
timestamp INTEGER NOT NULL,
is_read INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS container_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
container_id TEXT NOT NULL,
stack_name TEXT NOT NULL,
cpu_percent REAL NOT NULL,
memory_mb REAL NOT NULL,
net_rx_mb REAL NOT NULL,
net_tx_mb REAL NOT NULL,
timestamp INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_metrics_timestamp ON container_metrics(timestamp);
CREATE INDEX IF NOT EXISTS idx_metrics_container ON container_metrics(container_id);
CREATE TABLE IF NOT EXISTS stack_update_status (
stack_name TEXT PRIMARY KEY,
has_update INTEGER DEFAULT 0,
checked_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS nodes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
type TEXT NOT NULL DEFAULT 'local',
compose_dir TEXT NOT NULL DEFAULT '/app/compose',
is_default INTEGER DEFAULT 0,
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)
const maybeAddCol = (table: string, col: string, def: string) => {
try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e) { /* ignore */ }
};
// Distributed API model columns
maybeAddCol('nodes', 'api_url', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'api_token', "TEXT DEFAULT ''");
// Legacy SSH/TLS columns preserved for DB backward-compat (no longer read or written)
maybeAddCol('nodes', 'host', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'port', 'INTEGER DEFAULT 2375');
maybeAddCol('nodes', 'ssh_port', 'INTEGER DEFAULT 22');
maybeAddCol('nodes', 'ssh_user', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'ssh_password', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'ssh_key', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'tls_ca', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'tls_cert', "TEXT DEFAULT ''");
maybeAddCol('nodes', 'tls_key', "TEXT DEFAULT ''");
// Initialize default global settings if they don't exist
const stmt = this.db.prepare('INSERT OR IGNORE INTO global_settings (key, value) VALUES (?, ?)');
stmt.run('host_cpu_limit', '90');
stmt.run('host_ram_limit', '90');
stmt.run('host_disk_limit', '90');
stmt.run('global_crash', '1');
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;
if (nodeCount === 0) {
this.db.prepare(
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at) VALUES (?, ?, ?, ?, ?, ?)'
).run('Local', 'local', process.env.COMPOSE_DIR || '/app/compose', 1, 'online', Date.now());
}
}
private migrateJsonConfig(dataDir: string) {
const configPath = path.join(dataDir, 'sencho.json');
if (fs.existsSync(configPath)) {
try {
const data = fs.readFileSync(configPath, 'utf-8');
const config = JSON.parse(data);
if (config.username && config.passwordHash && config.jwtSecret) {
const stmt = this.db.prepare('INSERT OR IGNORE INTO global_settings (key, value) VALUES (?, ?)');
stmt.run('auth_username', config.username);
stmt.run('auth_password_hash', config.passwordHash);
stmt.run('auth_jwt_secret', config.jwtSecret);
console.log('Successfully migrated sencho.json credentials to SQLite global_settings.');
fs.unlinkSync(configPath);
}
} catch (err) {
console.error('Failed to migrate sencho.json:', err);
}
}
}
// --- Agents ---
public getAgents(): Agent[] {
const stmt = this.db.prepare('SELECT * FROM agents');
return stmt.all().map((row: any) => ({
...row,
enabled: row.enabled === 1
}));
}
public getEnabledAgents(): Agent[] {
const stmt = this.db.prepare('SELECT * FROM agents WHERE enabled = 1');
return stmt.all().map((row: any) => ({
...row,
enabled: row.enabled === 1
}));
}
public upsertAgent(agent: Agent): void {
const existing = this.db.prepare('SELECT id FROM agents WHERE type = ?').get(agent.type) as any;
if (existing) {
const stmt = this.db.prepare('UPDATE agents SET url = ?, enabled = ? WHERE type = ?');
stmt.run(agent.url, agent.enabled ? 1 : 0, agent.type);
} else {
const stmt = this.db.prepare('INSERT INTO agents (type, url, enabled) VALUES (?, ?, ?)');
stmt.run(agent.type, agent.url, agent.enabled ? 1 : 0);
}
}
// --- Global Settings ---
public getGlobalSettings(): Record<string, string> {
const stmt = this.db.prepare('SELECT * FROM global_settings');
const settings: Record<string, string> = {};
stmt.all().forEach((row: any) => {
settings[row.key] = row.value;
});
return settings;
}
public updateGlobalSetting(key: string, value: string): void {
const stmt = this.db.prepare('INSERT OR REPLACE INTO global_settings (key, value) VALUES (?, ?)');
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[] {
let stmt;
if (stackName) {
stmt = this.db.prepare('SELECT * FROM stack_alerts WHERE stack_name = ?');
return stmt.all(stackName) as StackAlert[];
} else {
stmt = this.db.prepare('SELECT * FROM stack_alerts');
return stmt.all() as StackAlert[];
}
}
public addStackAlert(alert: StackAlert): void {
const stmt = this.db.prepare(
'INSERT INTO stack_alerts (stack_name, metric, operator, threshold, duration_mins, cooldown_mins, last_fired_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
stmt.run(
alert.stack_name,
alert.metric,
alert.operator,
alert.threshold,
alert.duration_mins,
alert.cooldown_mins,
alert.last_fired_at || 0
);
}
public deleteStackAlert(id: number): void {
const stmt = this.db.prepare('DELETE FROM stack_alerts WHERE id = ?');
stmt.run(id);
}
public updateStackAlertLastFired(id: number, timestamp: number): void {
const stmt = this.db.prepare('UPDATE stack_alerts SET last_fired_at = ? WHERE id = ?');
stmt.run(timestamp, id);
}
// --- Notification History ---
public getNotificationHistory(limit = 50): NotificationHistory[] {
const stmt = this.db.prepare('SELECT * FROM notification_history ORDER BY timestamp DESC LIMIT ?');
return stmt.all(limit).map((row: any) => ({
...row,
is_read: row.is_read === 1
}));
}
public addNotificationHistory(notification: Omit<NotificationHistory, 'id' | 'is_read'>): void {
const stmt = this.db.prepare('INSERT INTO notification_history (level, message, timestamp, is_read) VALUES (?, ?, ?, 0)');
stmt.run(notification.level, notification.message, notification.timestamp);
this.db.exec(`
DELETE FROM notification_history
WHERE id NOT IN (
SELECT id FROM notification_history ORDER BY timestamp DESC LIMIT 100
)
`);
}
public markAllNotificationsRead(): void {
const stmt = this.db.prepare('UPDATE notification_history SET is_read = 1');
stmt.run();
}
public deleteNotification(id: number): void {
const stmt = this.db.prepare('DELETE FROM notification_history WHERE id = ?');
stmt.run(id);
}
public deleteAllNotifications(): void {
const stmt = this.db.prepare('DELETE FROM notification_history');
stmt.run();
}
// --- Container Metrics ---
public addContainerMetric(metric: Omit<any, 'id'>): void {
const stmt = this.db.prepare(
'INSERT INTO container_metrics (container_id, stack_name, cpu_percent, memory_mb, net_rx_mb, net_tx_mb, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
stmt.run(metric.container_id, metric.stack_name, metric.cpu_percent, metric.memory_mb, metric.net_rx_mb, metric.net_tx_mb, metric.timestamp);
}
public getContainerMetrics(hoursLookback = 24): any[] {
const cutoff = Date.now() - (hoursLookback * 60 * 60 * 1000);
const stmt = this.db.prepare(`
SELECT
container_id,
stack_name,
AVG(cpu_percent) as cpu_percent,
AVG(memory_mb) as memory_mb,
MAX(net_rx_mb) as net_rx_mb,
MAX(net_tx_mb) as net_tx_mb,
(timestamp / 60000) * 60000 as timestamp
FROM container_metrics
WHERE timestamp >= ?
GROUP BY container_id, stack_name, (timestamp / 60000)
ORDER BY timestamp ASC
`);
return stmt.all(cutoff);
}
public cleanupOldMetrics(hoursToKeep = 24): void {
const cutoff = Date.now() - (hoursToKeep * 60 * 60 * 1000);
const stmt = this.db.prepare('DELETE FROM container_metrics WHERE timestamp < ?');
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[] {
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes ORDER BY is_default DESC, name ASC');
return stmt.all().map((row: any) => ({
...row,
is_default: row.is_default === 1
}));
}
public getNode(id: number): Node | undefined {
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE id = ?');
const row = stmt.get(id) as any;
if (!row) return undefined;
return { ...row, is_default: row.is_default === 1 };
}
public getDefaultNode(): Node | undefined {
const stmt = this.db.prepare('SELECT id, name, type, compose_dir, is_default, status, created_at, api_url, api_token FROM nodes WHERE is_default = 1 LIMIT 1');
const row = stmt.get() as any;
if (!row) return undefined;
return { ...row, is_default: row.is_default === 1 };
}
public addNode(node: Omit<Node, 'id' | 'status' | 'created_at'>): number {
if (node.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
const stmt = this.db.prepare(
'INSERT INTO nodes (name, type, compose_dir, is_default, status, created_at, api_url, api_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
);
const result = stmt.run(
node.name,
node.type,
node.compose_dir || '/app/compose',
node.is_default ? 1 : 0,
'unknown',
Date.now(),
node.api_url || '',
node.api_token || ''
);
return result.lastInsertRowid as number;
}
public updateNode(id: number, updates: Partial<Omit<Node, 'id' | 'created_at'>>): void {
const node = this.getNode(id);
if (!node) throw new Error(`Node with id ${id} not found`);
if (updates.is_default) {
this.db.prepare('UPDATE nodes SET is_default = 0').run();
}
const fields: string[] = [];
const values: any[] = [];
if (updates.name !== undefined) { fields.push('name = ?'); values.push(updates.name); }
if (updates.type !== undefined) { fields.push('type = ?'); values.push(updates.type); }
if (updates.compose_dir !== undefined) { fields.push('compose_dir = ?'); values.push(updates.compose_dir); }
if (updates.is_default !== undefined) { fields.push('is_default = ?'); values.push(updates.is_default ? 1 : 0); }
if (updates.status !== undefined) { fields.push('status = ?'); values.push(updates.status); }
if (updates.api_url !== undefined) { fields.push('api_url = ?'); values.push(updates.api_url); }
if (updates.api_token !== undefined) { fields.push('api_token = ?'); values.push(updates.api_token); }
if (fields.length === 0) return;
values.push(id);
this.db.prepare(`UPDATE nodes SET ${fields.join(', ')} WHERE id = ?`).run(...values);
}
public deleteNode(id: number): void {
const node = this.getNode(id);
if (node?.is_default) {
throw new Error('Cannot delete the default node');
}
this.db.prepare('DELETE FROM nodes WHERE id = ?').run(id);
}
public updateNodeStatus(id: number, status: 'online' | 'offline' | 'unknown'): void {
this.db.prepare('UPDATE nodes SET status = ? WHERE id = ?').run(status, id);
}
// --- Stack Update Status ---
public upsertStackUpdateStatus(stackName: string, hasUpdate: boolean, checkedAt: number): void {
this.db.prepare(
'INSERT OR REPLACE INTO stack_update_status (stack_name, has_update, checked_at) VALUES (?, ?, ?)'
).run(stackName, hasUpdate ? 1 : 0, checkedAt);
}
public getStackUpdateStatus(): Record<string, boolean> {
const rows = this.db.prepare('SELECT stack_name, has_update FROM stack_update_status').all() as any[];
const result: Record<string, boolean> = {};
for (const row of rows) {
result[row.stack_name] = row.has_update === 1;
}
return result;
}
}