mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
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:
@@ -5,6 +5,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
- **Security:** `GET /api/settings` no longer leaks `auth_username`, `auth_password_hash`, or `auth_jwt_secret` to the frontend — these keys are stripped from the response before sending.
|
||||
- **Security:** `POST /api/settings` now enforces a strict allowlist of writable keys — attempts to write auth credential keys (`auth_jwt_secret`, `auth_password_hash`, etc.) or arbitrary unknown keys are rejected with a 400 error.
|
||||
- **Added:** `PATCH /api/settings` bulk-update endpoint — accepts a partial settings object, validates all values via a Zod schema (type checking, range enforcement, URL format validation), and persists changes atomically in a single SQLite transaction. Replaces the N+1 per-key POST loop.
|
||||
- **Added:** `system_state` SQLite table — separates runtime operational state (e.g. janitor alert cooldown timestamp) from user-defined configuration in `global_settings`. `MonitorService` now writes `last_janitor_alert_timestamp` to `system_state` instead of `global_settings`, eliminating false positives in future audit logging.
|
||||
- **Added:** `metrics_retention_hours` (default: 24h) and `log_retention_days` (default: 30d) configurable settings — `MonitorService` now reads these dynamically each cycle instead of using hardcoded values. Notification history is pruned on the same cycle as container metrics.
|
||||
- **Refactor:** `SettingsModal` frontend overhauled — per-operation loading states replace the single shared `isLoading` flag (saving system settings no longer disables notification test buttons). Settings are fetched before UI is interactive (skeleton loader blocks premature saves). Only known patchable keys are hydrated into component state (auth keys can never enter React state). Unsaved-changes dot indicator on sidebar nav items. All saves use the new `PATCH /api/settings` endpoint. Developer tab gains a "Data Retention" section for metrics and log retention controls.
|
||||
- **Added:** App Store category filter — LSIO templates are now grouped into categories (Automation, Downloaders, Media, Monitoring, Networking, Security, Development, Productivity, Utilities, Other) via a static lookup map in `TemplateService`. A horizontal pill bar below the search field lets users filter by category; clicking a category badge on a template card also activates the filter. Category badges highlight when their category is the active filter. App count updates reactively.
|
||||
- **Added:** App Store registry settings — new "App Store" section in Settings lets users supply a custom Portainer v2 JSON template URL to override the default LinuxServer.io registry. "Save & Refresh" persists the URL and immediately busts the 24-hour template cache via `POST /api/templates/refresh-cache`. Portainer v2 registries pass their native `categories` field through unchanged.
|
||||
- **Added:** `source` field on the `Template` interface — set to `'linuxserver'` for LSIO apps and `'custom'` for Portainer v2 registries, enabling future per-source filtering.
|
||||
|
||||
Generated
+11
-1
@@ -28,7 +28,8 @@
|
||||
"node-pty": "^1.1.0",
|
||||
"systeminformation": "^5.31.1",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2"
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
@@ -3134,6 +3135,15 @@
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.3.6",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"node-pty": "^1.1.0",
|
||||
"systeminformation": "^5.31.1",
|
||||
"ws": "^8.19.0",
|
||||
"yaml": "^2.8.2"
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
+68
-1
@@ -1246,11 +1246,48 @@ app.post('/api/agents', async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Keys that contain auth credentials — never exposed to the frontend or writable via settings API
|
||||
const PRIVATE_SETTINGS_KEYS = new Set(['auth_username', 'auth_password_hash', 'auth_jwt_secret']);
|
||||
|
||||
// Strict allowlist of keys writable via the settings API (prevents overwriting auth credentials)
|
||||
const ALLOWED_SETTING_KEYS = new Set([
|
||||
'host_cpu_limit',
|
||||
'host_ram_limit',
|
||||
'host_disk_limit',
|
||||
'docker_janitor_gb',
|
||||
'global_crash',
|
||||
'global_logs_refresh',
|
||||
'developer_mode',
|
||||
'template_registry_url',
|
||||
'metrics_retention_hours',
|
||||
'log_retention_days',
|
||||
]);
|
||||
|
||||
// Zod schema for bulk PATCH — all keys optional, present keys fully validated
|
||||
import { z } from 'zod';
|
||||
const SettingsPatchSchema = z.object({
|
||||
host_cpu_limit: z.coerce.number().int().min(1).max(100).transform(String),
|
||||
host_ram_limit: z.coerce.number().int().min(1).max(100).transform(String),
|
||||
host_disk_limit: z.coerce.number().int().min(1).max(100).transform(String),
|
||||
docker_janitor_gb: z.coerce.number().min(0).transform(String),
|
||||
global_crash: z.enum(['0', '1']),
|
||||
global_logs_refresh: z.enum(['1', '3', '5', '10']),
|
||||
developer_mode: z.enum(['0', '1']),
|
||||
template_registry_url: z.string().max(2048).refine(v => v === '' || /^https?:\/\/.+/.test(v), { message: 'Must be a valid URL or empty' }),
|
||||
metrics_retention_hours: z.coerce.number().int().min(1).max(8760).transform(String),
|
||||
log_retention_days: z.coerce.number().int().min(1).max(365).transform(String),
|
||||
}).partial();
|
||||
|
||||
app.get('/api/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const settings = DatabaseService.getInstance().getGlobalSettings();
|
||||
// Strip auth credentials — these are managed exclusively by /api/auth/* endpoints
|
||||
for (const key of PRIVATE_SETTINGS_KEYS) {
|
||||
delete settings[key];
|
||||
}
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
}
|
||||
});
|
||||
@@ -1258,13 +1295,43 @@ app.get('/api/settings', async (req: Request, res: Response) => {
|
||||
app.post('/api/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { key, value } = req.body;
|
||||
DatabaseService.getInstance().updateGlobalSetting(key, value);
|
||||
if (!key || typeof key !== 'string' || !ALLOWED_SETTING_KEYS.has(key)) {
|
||||
res.status(400).json({ error: `Invalid or disallowed setting key: ${key}` });
|
||||
return;
|
||||
}
|
||||
if (value === undefined || value === null) {
|
||||
res.status(400).json({ error: 'Setting value is required' });
|
||||
return;
|
||||
}
|
||||
DatabaseService.getInstance().updateGlobalSetting(key, String(value));
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to update setting:', error);
|
||||
res.status(500).json({ error: 'Failed to update setting' });
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/api/settings', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const parsed = SettingsPatchSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: 'Validation failed', details: parsed.error.flatten().fieldErrors });
|
||||
return;
|
||||
}
|
||||
const db = DatabaseService.getInstance();
|
||||
const updateMany = db.getDb().transaction((entries: [string, string][]) => {
|
||||
for (const [k, v] of entries) {
|
||||
db.updateGlobalSetting(k, v);
|
||||
}
|
||||
});
|
||||
updateMany(Object.entries(parsed.data) as [string, string][]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to bulk update settings:', error);
|
||||
res.status(500).json({ error: 'Failed to update settings' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/alerts', async (req: Request, res: Response) => {
|
||||
try {
|
||||
let stackName = req.query.stackName as string | undefined;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,10 +9,12 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server, Package, RefreshCw } from 'lucide-react';
|
||||
import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server, Package, RefreshCw, Database, Info } from 'lucide-react';
|
||||
import { NodeManager } from './NodeManager';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
|
||||
@@ -22,6 +24,22 @@ interface Agent {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Keys that the settings PATCH endpoint accepts
|
||||
interface PatchableSettings {
|
||||
host_cpu_limit?: string;
|
||||
host_ram_limit?: string;
|
||||
host_disk_limit?: string;
|
||||
docker_janitor_gb?: string;
|
||||
global_crash?: '0' | '1';
|
||||
global_logs_refresh?: '1' | '3' | '5' | '10';
|
||||
developer_mode?: '0' | '1';
|
||||
template_registry_url?: string;
|
||||
metrics_retention_hours?: string;
|
||||
log_retention_days?: string;
|
||||
}
|
||||
|
||||
type SectionId = 'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -29,10 +47,23 @@ interface SettingsModalProps {
|
||||
setIsDarkMode: (mode: boolean) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
host_cpu_limit: '90',
|
||||
host_ram_limit: '90',
|
||||
host_disk_limit: '90',
|
||||
global_crash: '1',
|
||||
docker_janitor_gb: '5',
|
||||
global_logs_refresh: '5',
|
||||
developer_mode: '0',
|
||||
template_registry_url: '',
|
||||
metrics_retention_hours: '24',
|
||||
log_retention_days: '30',
|
||||
};
|
||||
|
||||
export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) {
|
||||
const { activeNode } = useNodes();
|
||||
const isRemote = activeNode?.type === 'remote';
|
||||
const [activeSection, setActiveSection] = useState<'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore'>('account');
|
||||
const [activeSection, setActiveSection] = useState<SectionId>('account');
|
||||
|
||||
// When switching to a remote node, reset to a node-scoped section if on a global-only one
|
||||
useEffect(() => {
|
||||
@@ -44,45 +75,59 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
// Auth State
|
||||
const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
||||
|
||||
// Notifications State
|
||||
// Notification agents state
|
||||
const [agents, setAgents] = useState<Record<string, Agent>>({
|
||||
discord: { type: 'discord', url: '', enabled: false },
|
||||
slack: { type: 'slack', url: '', enabled: false },
|
||||
webhook: { type: 'webhook', url: '', enabled: false },
|
||||
});
|
||||
|
||||
// System Settings State
|
||||
const [settings, setSettings] = useState<Record<string, string>>({
|
||||
host_cpu_limit: '90',
|
||||
host_ram_limit: '90',
|
||||
host_disk_limit: '90',
|
||||
global_crash: '1',
|
||||
docker_janitor_gb: '5',
|
||||
global_logs_refresh: '5',
|
||||
developer_mode: '0'
|
||||
});
|
||||
// Settings state — all user-configurable keys (no auth keys)
|
||||
const [settings, setSettings] = useState<PatchableSettings>({ ...DEFAULT_SETTINGS });
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [registryUrl, setRegistryUrl] = useState('');
|
||||
// Track server state to detect unsaved changes without causing re-renders
|
||||
const serverSettingsRef = useRef<PatchableSettings>({ ...DEFAULT_SETTINGS });
|
||||
|
||||
// Per-operation loading states
|
||||
const [isSettingsLoading, setIsSettingsLoading] = useState(false);
|
||||
const [isSavingSystem, setIsSavingSystem] = useState(false);
|
||||
const [isSavingDeveloper, setIsSavingDeveloper] = useState(false);
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
const [isSavingRegistry, setIsSavingRegistry] = useState(false);
|
||||
const [isSavingAgent, setIsSavingAgent] = useState<Record<string, boolean>>({});
|
||||
const [isTestingAgent, setIsTestingAgent] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Unsaved changes indicators per section (compared against server ref)
|
||||
const hasSystemChanges =
|
||||
settings.host_cpu_limit !== serverSettingsRef.current.host_cpu_limit ||
|
||||
settings.host_ram_limit !== serverSettingsRef.current.host_ram_limit ||
|
||||
settings.host_disk_limit !== serverSettingsRef.current.host_disk_limit ||
|
||||
settings.docker_janitor_gb !== serverSettingsRef.current.docker_janitor_gb ||
|
||||
settings.global_crash !== serverSettingsRef.current.global_crash;
|
||||
|
||||
const hasDeveloperChanges =
|
||||
settings.developer_mode !== serverSettingsRef.current.developer_mode ||
|
||||
settings.global_logs_refresh !== serverSettingsRef.current.global_logs_refresh ||
|
||||
settings.metrics_retention_hours !== serverSettingsRef.current.metrics_retention_hours ||
|
||||
settings.log_retention_days !== serverSettingsRef.current.log_retention_days;
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchAgents();
|
||||
fetchSettings();
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const fetchAgents = async () => {
|
||||
try {
|
||||
const res = await apiFetch('/agents');
|
||||
if (res.ok) {
|
||||
const data: Agent[] = await res.json();
|
||||
const newAgents = { ...agents };
|
||||
data.forEach(a => {
|
||||
newAgents[a.type] = a;
|
||||
setAgents(prev => {
|
||||
const next = { ...prev };
|
||||
data.forEach(a => { next[a.type] = a; });
|
||||
return next;
|
||||
});
|
||||
setAgents(newAgents);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch agents', e);
|
||||
@@ -90,64 +135,127 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
};
|
||||
|
||||
const fetchSettings = async () => {
|
||||
setIsSettingsLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/settings');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSettings(prev => ({ ...prev, ...data }));
|
||||
if (data.template_registry_url) {
|
||||
setRegistryUrl(data.template_registry_url);
|
||||
}
|
||||
const data: Record<string, string> = await res.json();
|
||||
// Explicitly pick only known patchable keys — never allow auth keys into component state
|
||||
const safe: PatchableSettings = {
|
||||
host_cpu_limit: data.host_cpu_limit ?? DEFAULT_SETTINGS.host_cpu_limit,
|
||||
host_ram_limit: data.host_ram_limit ?? DEFAULT_SETTINGS.host_ram_limit,
|
||||
host_disk_limit: data.host_disk_limit ?? DEFAULT_SETTINGS.host_disk_limit,
|
||||
docker_janitor_gb: data.docker_janitor_gb ?? DEFAULT_SETTINGS.docker_janitor_gb,
|
||||
global_crash: (data.global_crash as '0' | '1') ?? DEFAULT_SETTINGS.global_crash,
|
||||
global_logs_refresh: (data.global_logs_refresh as '1' | '3' | '5' | '10') ?? DEFAULT_SETTINGS.global_logs_refresh,
|
||||
developer_mode: (data.developer_mode as '0' | '1') ?? DEFAULT_SETTINGS.developer_mode,
|
||||
template_registry_url: data.template_registry_url ?? '',
|
||||
metrics_retention_hours: data.metrics_retention_hours ?? DEFAULT_SETTINGS.metrics_retention_hours,
|
||||
log_retention_days: data.log_retention_days ?? DEFAULT_SETTINGS.log_retention_days,
|
||||
};
|
||||
setSettings(safe);
|
||||
serverSettingsRef.current = { ...safe };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch settings', e);
|
||||
} finally {
|
||||
setIsSettingsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSettingChange = <K extends keyof PatchableSettings>(key: K, value: PatchableSettings[K]) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const patchSettings = async (payload: PatchableSettings, setLoading: (v: boolean) => void): Promise<boolean> => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiFetch('/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Failed to save settings.');
|
||||
return false;
|
||||
}
|
||||
serverSettingsRef.current = { ...serverSettingsRef.current, ...payload };
|
||||
return true;
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Something went wrong.');
|
||||
return false;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSystemSettings = async () => {
|
||||
const ok = await patchSettings({
|
||||
host_cpu_limit: settings.host_cpu_limit,
|
||||
host_ram_limit: settings.host_ram_limit,
|
||||
host_disk_limit: settings.host_disk_limit,
|
||||
docker_janitor_gb: settings.docker_janitor_gb,
|
||||
global_crash: settings.global_crash,
|
||||
}, setIsSavingSystem);
|
||||
if (ok) toast.success('System limits saved.');
|
||||
};
|
||||
|
||||
const saveDeveloperSettings = async () => {
|
||||
const ok = await patchSettings({
|
||||
developer_mode: settings.developer_mode,
|
||||
global_logs_refresh: settings.global_logs_refresh,
|
||||
metrics_retention_hours: settings.metrics_retention_hours,
|
||||
log_retention_days: settings.log_retention_days,
|
||||
}, setIsSavingDeveloper);
|
||||
if (ok) toast.success('Developer settings saved.');
|
||||
};
|
||||
|
||||
const saveRegistrySettings = async () => {
|
||||
setIsSavingRegistry(true);
|
||||
try {
|
||||
await apiFetch('/settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: 'template_registry_url', value: registryUrl.trim() })
|
||||
const res = await apiFetch('/settings', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ template_registry_url: settings.template_registry_url ?? '' }),
|
||||
});
|
||||
// Bust the template cache so the next App Store load uses the new URL
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Failed to save registry settings.');
|
||||
return;
|
||||
}
|
||||
serverSettingsRef.current = { ...serverSettingsRef.current, template_registry_url: settings.template_registry_url };
|
||||
await apiFetch('/templates/refresh-cache', { method: 'POST' });
|
||||
toast.success('Registry saved. App Store will reload from the new source.');
|
||||
} catch (e) {
|
||||
toast.error('Failed to save registry settings.');
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Failed to save registry settings.');
|
||||
} finally {
|
||||
setIsSavingRegistry(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAgentChange = (type: string, field: keyof Agent, value: any) => {
|
||||
const handleAgentChange = (type: string, field: keyof Agent, value: Agent[keyof Agent]) => {
|
||||
setAgents(prev => ({
|
||||
...prev,
|
||||
[type]: { ...prev[type], [field]: value }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSettingChange = (key: string, value: string) => {
|
||||
setSettings(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const saveAgent = async (type: string) => {
|
||||
setIsLoading(true);
|
||||
setIsSavingAgent(prev => ({ ...prev, [type]: true }));
|
||||
try {
|
||||
const res = await apiFetch('/agents', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(agents[type])
|
||||
});
|
||||
if (res.ok) {
|
||||
toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} settings saved successfully.`);
|
||||
toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} settings saved.`);
|
||||
} else {
|
||||
toast.error(`Failed to save ${type} settings.`);
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.error || err?.message || 'Something went wrong.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Network error.');
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsSavingAgent(prev => ({ ...prev, [type]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -156,7 +264,7 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
toast.error('Please enter a webhook URL first.');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setIsTestingAgent(prev => ({ ...prev, [type]: true }));
|
||||
try {
|
||||
const res = await apiFetch('/notifications/test', {
|
||||
method: 'POST',
|
||||
@@ -165,64 +273,46 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
if (res.ok) {
|
||||
toast.success('Test notification sent!');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
toast.error(err.details || 'Test failed.');
|
||||
const err = await res.json().catch(() => ({}));
|
||||
toast.error(err?.details || err?.error || 'Test failed.');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Network error.');
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Network error.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await apiFetch('/settings', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key, value })
|
||||
});
|
||||
}
|
||||
toast.success('System limits & watchdog settings saved.');
|
||||
} catch (e) {
|
||||
toast.error('Failed to save settings.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsTestingAgent(prev => ({ ...prev, [type]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = async () => {
|
||||
if (!authData.oldPassword || !authData.newPassword || !authData.confirmPassword) {
|
||||
toast.error("All fields are required");
|
||||
toast.error('All fields are required');
|
||||
return;
|
||||
}
|
||||
if (authData.newPassword !== authData.confirmPassword) {
|
||||
toast.error("New passwords do not match");
|
||||
toast.error('New passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
if (authData.newPassword.length < 6) {
|
||||
toast.error('New password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
setIsSavingPassword(true);
|
||||
try {
|
||||
const res = await apiFetch('/auth/password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
oldPassword: authData.oldPassword,
|
||||
newPassword: authData.newPassword
|
||||
})
|
||||
body: JSON.stringify({ oldPassword: authData.oldPassword, newPassword: authData.newPassword })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toast.success('Password updated successfully');
|
||||
setAuthData({ oldPassword: '', newPassword: '', confirmPassword: '' });
|
||||
} else {
|
||||
const data = await res.json();
|
||||
toast.error(data.error || 'Failed to update password');
|
||||
const data = await res.json().catch(() => ({}));
|
||||
toast.error(data?.error || 'Failed to update password');
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error('Network error during password change');
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || 'Network error during password change');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsSavingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,94 +336,87 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-2 justify-end pt-4">
|
||||
<Button variant="outline" onClick={() => testAgent(type)} disabled={isLoading}>Test</Button>
|
||||
<Button onClick={() => saveAgent(type)} disabled={isLoading}>Save</Button>
|
||||
<Button variant="outline" onClick={() => testAgent(type)} disabled={isTestingAgent[type]}>
|
||||
{isTestingAgent[type] ? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Testing...</> : 'Test'}
|
||||
</Button>
|
||||
<Button onClick={() => saveAgent(type)} disabled={isSavingAgent[type]}>
|
||||
{isSavingAgent[type] ? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</> : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SettingsSkeleton = () => (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<div className="space-y-4 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const NavButton = ({ section, icon, label, showDot }: { section: SectionId; icon: React.ReactNode; label: string; showDot?: boolean }) => (
|
||||
<Button
|
||||
variant={activeSection === section ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium relative"
|
||||
onClick={() => setActiveSection(section)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
{showDot && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full bg-amber-400" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-[900px] h-[650px] flex p-0 font-sans shadow-lg bg-background border-border overflow-hidden gap-0">
|
||||
{/* Sidebar */}
|
||||
<div className="w-[200px] bg-muted/20 border-r border-border flex flex-col p-4 shrink-0">
|
||||
<div className="font-semibold text-lg mb-1 text-foreground tracking-tight">Settings Hub</div>
|
||||
{isRemote && (
|
||||
{isRemote ? (
|
||||
<div className="text-xs text-muted-foreground mb-5 truncate">{activeNode!.name}</div>
|
||||
) : (
|
||||
<div className="mb-5" />
|
||||
)}
|
||||
{!isRemote && <div className="mb-5" />}
|
||||
<nav className="space-y-1.5 flex flex-col">
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'account' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('account')}
|
||||
>
|
||||
<Shield className="w-4 h-4 mr-2" />
|
||||
Account
|
||||
</Button>
|
||||
<NavButton section="account" icon={<Shield className="w-4 h-4 mr-2" />} label="Account" />
|
||||
)}
|
||||
<Button
|
||||
variant={activeSection === 'system' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('system')}
|
||||
>
|
||||
<Activity className="w-4 h-4 mr-2" />
|
||||
System Limits
|
||||
</Button>
|
||||
<NavButton
|
||||
section="system"
|
||||
icon={<Activity className="w-4 h-4 mr-2" />}
|
||||
label="System Limits"
|
||||
showDot={hasSystemChanges}
|
||||
/>
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'notifications' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('notifications')}
|
||||
>
|
||||
<Bell className="w-4 h-4 mr-2" />
|
||||
Notifications
|
||||
</Button>
|
||||
<NavButton section="notifications" icon={<Bell className="w-4 h-4 mr-2" />} label="Notifications" />
|
||||
)}
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'appearance' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('appearance')}
|
||||
>
|
||||
<Palette className="w-4 h-4 mr-2" />
|
||||
Appearance
|
||||
</Button>
|
||||
<NavButton section="appearance" icon={<Palette className="w-4 h-4 mr-2" />} label="Appearance" />
|
||||
)}
|
||||
<Button
|
||||
variant={activeSection === 'developer' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('developer')}
|
||||
>
|
||||
<Code className="w-4 h-4 mr-2" />
|
||||
Developer
|
||||
</Button>
|
||||
<NavButton
|
||||
section="developer"
|
||||
icon={<Code className="w-4 h-4 mr-2" />}
|
||||
label="Developer"
|
||||
showDot={hasDeveloperChanges}
|
||||
/>
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'nodes' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('nodes')}
|
||||
>
|
||||
<Server className="w-4 h-4 mr-2" />
|
||||
Nodes
|
||||
</Button>
|
||||
<NavButton section="nodes" icon={<Server className="w-4 h-4 mr-2" />} label="Nodes" />
|
||||
)}
|
||||
{!isRemote && (
|
||||
<Button
|
||||
variant={activeSection === 'appstore' ? 'secondary' : 'ghost'}
|
||||
className="w-full justify-start font-medium"
|
||||
onClick={() => setActiveSection('appstore')}
|
||||
>
|
||||
<Package className="w-4 h-4 mr-2" />
|
||||
App Store
|
||||
</Button>
|
||||
<NavButton section="appstore" icon={<Package className="w-4 h-4 mr-2" />} label="App Store" />
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-6">
|
||||
|
||||
{activeSection === 'account' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
@@ -365,8 +448,11 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
onChange={(e) => setAuthData(prev => ({ ...prev, confirmPassword: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handlePasswordChange} disabled={isLoading} className="w-full">
|
||||
Update Password
|
||||
<Button onClick={handlePasswordChange} disabled={isSavingPassword} className="w-full">
|
||||
{isSavingPassword
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Updating...</>
|
||||
: 'Update Password'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,74 +460,97 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
|
||||
{activeSection === 'system' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">System Limits & Watchdog</h3>
|
||||
<p className="text-sm text-muted-foreground">Configure auto-recovery thresholds and server constraints.</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">System Limits & Watchdog</h3>
|
||||
<p className="text-sm text-muted-foreground">Configure alert thresholds and crash detection.</p>
|
||||
</div>
|
||||
{isRemote && (
|
||||
<Badge variant="outline" className="text-xs shrink-0 ml-2 mt-0.5">
|
||||
<Info className="w-3 h-3 mr-1" />
|
||||
{activeNode!.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base">Host CPU Limit</Label>
|
||||
<span className="text-sm font-medium">{settings.host_cpu_limit}%</span>
|
||||
{isSettingsLoading ? <SettingsSkeleton /> : (
|
||||
<>
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base">Host CPU Alert Threshold</Label>
|
||||
<span className="text-sm font-medium">{settings.host_cpu_limit}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1} max={100} step={1}
|
||||
value={[parseInt(settings.host_cpu_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_cpu_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2 border-t border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base">Host RAM Alert Threshold</Label>
|
||||
<span className="text-sm font-medium">{settings.host_ram_limit}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1} max={100} step={1}
|
||||
value={[parseInt(settings.host_ram_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_ram_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2 border-t border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base">Host Disk Alert Threshold</Label>
|
||||
<span className="text-sm font-medium">{settings.host_disk_limit}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={1} max={100} step={1}
|
||||
value={[parseInt(settings.host_disk_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_disk_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<Label className="text-base">Docker Janitor Storage Threshold</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.5}
|
||||
value={settings.docker_janitor_gb}
|
||||
onChange={(e) => handleSettingChange('docker_janitor_gb', e.target.value)}
|
||||
className="max-w-[150px]"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">GB reclaimable</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Alert when unused Docker data exceeds this size.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="global_crash" className="text-base">Global Crash Detection</Label>
|
||||
<p className="text-xs text-muted-foreground">Watch all containers for unexpected exits</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="global_crash"
|
||||
checked={settings.global_crash === '1'}
|
||||
onCheckedChange={(c) => handleSettingChange('global_crash', c ? '1' : '0')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Slider
|
||||
max={100} step={1}
|
||||
value={[parseInt(settings.host_cpu_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_cpu_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2 border-t border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base">Host RAM Limit</Label>
|
||||
<span className="text-sm font-medium">{settings.host_ram_limit}%</span>
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={saveSystemSettings} disabled={isSavingSystem}>
|
||||
{isSavingSystem
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
|
||||
: 'Save Limits'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
<Slider
|
||||
max={100} step={1}
|
||||
value={[parseInt(settings.host_ram_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_ram_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 pt-2 border-t border-border">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base">Host Disk Limit</Label>
|
||||
<span className="text-sm font-medium">{settings.host_disk_limit}%</span>
|
||||
</div>
|
||||
<Slider
|
||||
max={100} step={1}
|
||||
value={[parseInt(settings.host_disk_limit || '90')]}
|
||||
onValueChange={(v) => handleSettingChange('host_disk_limit', v[0].toString())}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-border">
|
||||
<Label className="text-base">Docker Janitor Storage Threshold (GB)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={settings.docker_janitor_gb}
|
||||
onChange={(e) => handleSettingChange('docker_janitor_gb', e.target.value)}
|
||||
className="max-w-[200px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-border">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="global_crash" className="text-base">Global Crash Detection</Label>
|
||||
<p className="text-xs text-muted-foreground">Watch all containers indefinitely</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="global_crash"
|
||||
checked={settings.global_crash === '1'}
|
||||
onCheckedChange={(c) => handleSettingChange('global_crash', c ? '1' : '0')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button onClick={saveSettings} disabled={isLoading}>Save Limits</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -470,7 +579,6 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
<h3 className="text-lg font-semibold tracking-tight">Appearance</h3>
|
||||
<p className="text-sm text-muted-foreground">Customize Sencho's visual theme.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4 mt-6">
|
||||
<Button
|
||||
variant={!isDarkMode ? 'default' : 'outline'}
|
||||
@@ -494,50 +602,114 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
|
||||
{activeSection === 'developer' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">Developer</h3>
|
||||
<p className="text-sm text-muted-foreground">Power user settings for real-time observability and extended diagnostics.</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold tracking-tight">Developer</h3>
|
||||
<p className="text-sm text-muted-foreground">Power user settings for real-time observability and data retention.</p>
|
||||
</div>
|
||||
{isRemote && (
|
||||
<Badge variant="outline" className="text-xs shrink-0 ml-2 mt-0.5">
|
||||
<Info className="w-3 h-3 mr-1" />
|
||||
{activeNode!.name}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics & Extended Logs</p>
|
||||
{isSettingsLoading ? <SettingsSkeleton /> : (
|
||||
<>
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
|
||||
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics & Extended Logs</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="developer_mode"
|
||||
checked={settings.developer_mode === '1'}
|
||||
onCheckedChange={(c) => handleSettingChange('developer_mode', c ? '1' : '0')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-4 border-t border-border">
|
||||
<Label className={`text-base ${settings.developer_mode === '1' ? 'text-muted-foreground' : ''}`}>
|
||||
Standard Log Polling Rate
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.global_logs_refresh}
|
||||
onValueChange={(val) => handleSettingChange('global_logs_refresh', val as '1' | '3' | '5' | '10')}
|
||||
disabled={settings.developer_mode === '1'}
|
||||
>
|
||||
<SelectTrigger className="max-w-[200px]">
|
||||
<SelectValue placeholder="Select rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 second</SelectItem>
|
||||
<SelectItem value="3">3 seconds</SelectItem>
|
||||
<SelectItem value="5">5 seconds</SelectItem>
|
||||
<SelectItem value="10">10 seconds</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{settings.developer_mode === '1' && (
|
||||
<p className="text-xs text-amber-500">SSE streaming is active — polling rate is overridden.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="developer_mode"
|
||||
checked={settings.developer_mode === '1'}
|
||||
onCheckedChange={(c) => handleSettingChange('developer_mode', c ? '1' : '0')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-4 border-t border-border">
|
||||
<Label className={`text-base ${settings.developer_mode === '1' ? 'text-muted-foreground' : ''}`}>Standard Log Polling Rate</Label>
|
||||
<Select
|
||||
value={settings.global_logs_refresh}
|
||||
onValueChange={(val) => handleSettingChange('global_logs_refresh', val)}
|
||||
disabled={settings.developer_mode === '1'}
|
||||
>
|
||||
<SelectTrigger className="max-w-[200px]">
|
||||
<SelectValue placeholder="Select rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 second</SelectItem>
|
||||
<SelectItem value="3">3 seconds</SelectItem>
|
||||
<SelectItem value="5">5 seconds</SelectItem>
|
||||
<SelectItem value="10">10 seconds</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{settings.developer_mode === '1' && (
|
||||
<p className="text-xs text-amber-500">SSE streaming is active - polling rate is overridden by real-time streaming.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Data Retention (Observability) */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">Data Retention</span>
|
||||
</div>
|
||||
<div className="space-y-4 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">Container Metrics Retention</Label>
|
||||
<p className="text-xs text-muted-foreground">How long to keep per-container CPU/RAM/network history.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={8760}
|
||||
value={settings.metrics_retention_hours}
|
||||
onChange={(e) => handleSettingChange('metrics_retention_hours', e.target.value)}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground w-8">hrs</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<Button onClick={saveSettings} disabled={isLoading}>Save Developer Settings</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 pt-4 border-t border-border">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-base">Notification Log Retention</Label>
|
||||
<p className="text-xs text-muted-foreground">How long to keep alert and notification history.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={settings.log_retention_days}
|
||||
onChange={(e) => handleSettingChange('log_retention_days', e.target.value)}
|
||||
className="w-20"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground w-8">days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={saveDeveloperSettings} disabled={isSavingDeveloper}>
|
||||
{isSavingDeveloper
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
|
||||
: 'Save Developer Settings'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -552,52 +724,51 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se
|
||||
<p className="text-sm text-muted-foreground">Configure the template source used by the App Store.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base">Default Registry</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
LinuxServer.io — <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Used when no custom registry is set.</p>
|
||||
</div>
|
||||
</div>
|
||||
{isSettingsLoading ? <SettingsSkeleton /> : (
|
||||
<>
|
||||
<div className="space-y-6 bg-muted/10 p-4 border border-border rounded-xl">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base">Default Registry</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
LinuxServer.io — <span className="font-mono">https://api.linuxserver.io/api/v1/images</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Used when no custom registry is set.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-4 border-t border-border">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base">Custom Registry URL</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide a URL pointing to a <span className="font-medium">Portainer v2</span> compatible template JSON file. Overrides the default registry.
|
||||
</p>
|
||||
<div className="space-y-3 pt-4 border-t border-border">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base">Custom Registry URL</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Provide a URL pointing to a <span className="font-medium">Portainer v2</span> compatible template JSON file. Overrides the default registry.
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="https://example.com/templates.json"
|
||||
value={settings.template_registry_url ?? ''}
|
||||
onChange={(e) => handleSettingChange('template_registry_url', e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Leave empty to use the default LinuxServer.io registry.</p>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="https://example.com/templates.json"
|
||||
value={registryUrl}
|
||||
onChange={(e) => setRegistryUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the default LinuxServer.io registry.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRegistryUrl('')}
|
||||
disabled={isSavingRegistry || registryUrl === ''}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button onClick={saveRegistrySettings} disabled={isSavingRegistry}>
|
||||
{isSavingRegistry ? (
|
||||
<><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
|
||||
) : (
|
||||
'Save & Refresh'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSettingChange('template_registry_url', '')}
|
||||
disabled={isSavingRegistry || !settings.template_registry_url}
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
<Button onClick={saveRegistrySettings} disabled={isSavingRegistry}>
|
||||
{isSavingRegistry
|
||||
? <><RefreshCw className="w-4 h-4 mr-2 animate-spin" />Saving...</>
|
||||
: 'Save & Refresh'
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user