mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 04:38:59 +00:00
322e717514
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
477 lines
17 KiB
TypeScript
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;
|
|
}
|
|
}
|