673fe04164
All host port bindings in docker-compose.yml now use \${VAR:-default} substitution,
driven by the .env file generated by ip_utils.write_env_file(). Changing a port in
Settings triggers a per-container pending-restart banner so only the affected container
is restarted on Apply.
- ip_utils: add PORT_DEFAULTS, PORT_ENV_VAR_NAMES, PORT_TO_CONTAINERS; extend
write_env_file() to accept optional ports dict and write all port env vars
- docker-compose: convert all hardcoded port bindings to \${VAR:-default} form
- app.py: add _collect_service_ports helper; detect port changes in update_config,
write updated .env and call _set_pending_restart with specific container list;
update _set_pending_restart to merge/accumulate pending state with containers list;
update apply_pending_config to use --no-deps <service> for targeted restarts
- config_manager: add submission_port, webmail_port to email schema; add manager_port
to files schema
- Settings.jsx: make all email/files ports editable, add submission_port, webmail_port,
manager_port fields; update stale identity note
- tests: 8 new tests for PORT_DEFAULTS, PORT_ENV_VAR_NAMES, and port override in write_env_file
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
613 lines
24 KiB
React
613 lines
24 KiB
React
import { useState, useEffect, useCallback } from 'react';
|
||
import { useConfig } from '../contexts/ConfigContext';
|
||
import {
|
||
Settings as SettingsIcon, Server, Shield, Network, Mail, Calendar,
|
||
HardDrive, GitBranch, Archive, Upload, Download, Trash2, RotateCcw,
|
||
Save, ChevronDown, ChevronRight, CheckCircle, XCircle, AlertCircle,
|
||
RefreshCw, Lock
|
||
} from 'lucide-react';
|
||
import { cellAPI } from '../services/api';
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
function toast(msg, type = 'success') {
|
||
// simple inline notification via a thrown CustomEvent consumed below
|
||
window.dispatchEvent(new CustomEvent('settings-toast', { detail: { msg, type } }));
|
||
}
|
||
|
||
function Toast({ toasts }) {
|
||
return (
|
||
<div className="fixed bottom-4 right-4 z-50 space-y-2">
|
||
{toasts.map((t) => (
|
||
<div
|
||
key={t.id}
|
||
className={`px-4 py-3 rounded-lg shadow-lg text-sm text-white flex items-center gap-2 ${
|
||
t.type === 'success' ? 'bg-green-600' : t.type === 'error' ? 'bg-red-600' : 'bg-yellow-600'
|
||
}`}
|
||
>
|
||
{t.type === 'success' ? <CheckCircle className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
|
||
{t.msg}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function useToasts() {
|
||
const [toasts, setToasts] = useState([]);
|
||
useEffect(() => {
|
||
const handler = (e) => {
|
||
const id = Date.now();
|
||
setToasts((prev) => [...prev, { ...e.detail, id }]);
|
||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000);
|
||
};
|
||
window.addEventListener('settings-toast', handler);
|
||
return () => window.removeEventListener('settings-toast', handler);
|
||
}, []);
|
||
return toasts;
|
||
}
|
||
|
||
// ── Section wrapper ───────────────────────────────────────────────────────────
|
||
|
||
function Section({ icon: Icon, title, children, collapsible = false, defaultOpen = true }) {
|
||
const [open, setOpen] = useState(defaultOpen);
|
||
return (
|
||
<div className="card mb-4">
|
||
<button
|
||
className="w-full flex items-center justify-between"
|
||
onClick={() => collapsible && setOpen((v) => !v)}
|
||
disabled={!collapsible}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<Icon className="h-5 w-5 text-primary-500" />
|
||
<h3 className="text-base font-semibold text-gray-900">{title}</h3>
|
||
</div>
|
||
{collapsible && (open ? <ChevronDown className="h-4 w-4 text-gray-400" /> : <ChevronRight className="h-4 w-4 text-gray-400" />)}
|
||
</button>
|
||
{(!collapsible || open) && <div className="mt-4">{children}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Field components ──────────────────────────────────────────────────────────
|
||
|
||
function Field({ label, children, hint }) {
|
||
return (
|
||
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4">
|
||
<label className="text-sm text-gray-600 sm:w-48 shrink-0">{label}</label>
|
||
<div className="flex-1">{children}</div>
|
||
{hint && <span className="text-xs text-gray-400">{hint}</span>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TextInput({ value, onChange, placeholder, type = 'text', readOnly }) {
|
||
return (
|
||
<input
|
||
type={type}
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange && onChange(e.target.value)}
|
||
placeholder={placeholder}
|
||
readOnly={readOnly}
|
||
className={`w-full text-sm border rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary-400 ${
|
||
readOnly ? 'bg-gray-50 text-gray-500 cursor-default' : 'bg-white'
|
||
}`}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function NumberInput({ value, onChange, min, max }) {
|
||
return (
|
||
<input
|
||
type="number"
|
||
value={value ?? ''}
|
||
min={min}
|
||
max={max}
|
||
onChange={(e) => onChange && onChange(Number(e.target.value))}
|
||
className="w-full text-sm border rounded px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary-400 bg-white"
|
||
/>
|
||
);
|
||
}
|
||
|
||
function BoolToggle({ value, onChange, label }) {
|
||
return (
|
||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||
<div
|
||
onClick={() => onChange && onChange(!value)}
|
||
className={`relative w-10 h-5 rounded-full transition-colors ${value ? 'bg-primary-500' : 'bg-gray-300'}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform ${value ? 'translate-x-5' : ''}`}
|
||
/>
|
||
</div>
|
||
<span className="text-sm text-gray-700">{label}</span>
|
||
</label>
|
||
);
|
||
}
|
||
|
||
function TagList({ value = [], onChange, placeholder }) {
|
||
const [input, setInput] = useState('');
|
||
const add = () => {
|
||
const v = input.trim();
|
||
if (v && !value.includes(v)) { onChange([...value, v]); setInput(''); }
|
||
};
|
||
return (
|
||
<div className="space-y-1">
|
||
<div className="flex gap-2 flex-wrap">
|
||
{value.map((item) => (
|
||
<span key={item} className="flex items-center gap-1 bg-primary-100 text-primary-700 text-xs rounded-full px-2 py-0.5">
|
||
{item}
|
||
<button onClick={() => onChange(value.filter((v) => v !== item))} className="hover:text-red-500">×</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input
|
||
className="flex-1 text-sm border rounded px-3 py-1 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||
value={input}
|
||
onChange={(e) => setInput(e.target.value)}
|
||
onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), add())}
|
||
placeholder={placeholder}
|
||
/>
|
||
<button onClick={add} className="btn-secondary text-xs px-3 py-1">Add</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── Service config forms ──────────────────────────────────────────────────────
|
||
|
||
function NetworkForm({ data, onChange }) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<Field label="DNS Port">
|
||
<NumberInput value={data.dns_port} onChange={(v) => onChange({ ...data, dns_port: v })} min={1} max={65535} />
|
||
</Field>
|
||
<Field label="DHCP Range" hint="e.g. 10.0.0.100,10.0.0.200,12h">
|
||
<TextInput value={data.dhcp_range} onChange={(v) => onChange({ ...data, dhcp_range: v })} placeholder="10.0.0.100,10.0.0.200,12h" />
|
||
</Field>
|
||
<Field label="NTP Servers">
|
||
<TagList value={data.ntp_servers || []} onChange={(v) => onChange({ ...data, ntp_servers: v })} placeholder="0.pool.ntp.org" />
|
||
</Field>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WireguardForm({ data, onChange }) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<Field label="Listen Port">
|
||
<NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
|
||
</Field>
|
||
<Field label="Server Address" hint="CIDR, e.g. 10.0.0.1/24">
|
||
<TextInput value={data.address} onChange={(v) => onChange({ ...data, address: v })} placeholder="10.0.0.1/24" />
|
||
</Field>
|
||
<Field label="Private Key">
|
||
<TextInput value={data.private_key} onChange={(v) => onChange({ ...data, private_key: v })} placeholder="base64 private key" type="password" />
|
||
</Field>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmailForm({ data, onChange }) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<Field label="Mail Domain">
|
||
<TextInput value={data.domain} onChange={(v) => onChange({ ...data, domain: v })} placeholder="mail.example.com" />
|
||
</Field>
|
||
<Field label="SMTP Port" hint="MTA-to-MTA (default 25)">
|
||
<NumberInput value={data.smtp_port ?? 25} onChange={(v) => onChange({ ...data, smtp_port: v })} min={1} max={65535} />
|
||
</Field>
|
||
<Field label="Submission Port" hint="Client mail send (default 587)">
|
||
<NumberInput value={data.submission_port ?? 587} onChange={(v) => onChange({ ...data, submission_port: v })} min={1} max={65535} />
|
||
</Field>
|
||
<Field label="IMAP Port" hint="Client mail fetch (default 993)">
|
||
<NumberInput value={data.imap_port ?? 993} onChange={(v) => onChange({ ...data, imap_port: v })} min={1} max={65535} />
|
||
</Field>
|
||
<Field label="Webmail Port" hint="Rainloop webmail UI (default 8888)">
|
||
<NumberInput value={data.webmail_port ?? 8888} onChange={(v) => onChange({ ...data, webmail_port: v })} min={1} max={65535} />
|
||
</Field>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CalendarForm({ data, onChange }) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<Field label="Radicale Port" hint="Internal port; clients use port 80 via Caddy">
|
||
<NumberInput value={data.port} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
|
||
</Field>
|
||
<Field label="Data Directory">
|
||
<TextInput value={data.data_dir} onChange={(v) => onChange({ ...data, data_dir: v })} placeholder="/app/data/radicale" />
|
||
</Field>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FilesForm({ data, onChange }) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<Field label="WebDAV Port" hint="Host port for WebDAV (default 8080)">
|
||
<NumberInput value={data.port ?? 8080} onChange={(v) => onChange({ ...data, port: v })} min={1} max={65535} />
|
||
</Field>
|
||
<Field label="File Manager Port" hint="Filegator host port (default 8082)">
|
||
<NumberInput value={data.manager_port ?? 8082} onChange={(v) => onChange({ ...data, manager_port: v })} min={1} max={65535} />
|
||
</Field>
|
||
<Field label="Data Directory">
|
||
<TextInput value={data.data_dir} onChange={(v) => onChange({ ...data, data_dir: v })} placeholder="/app/data/webdav" />
|
||
</Field>
|
||
<Field label="Default Quota (MB)">
|
||
<NumberInput value={data.quota} onChange={(v) => onChange({ ...data, quota: v })} min={0} />
|
||
</Field>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function RoutingForm({ data, onChange }) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<Field label="">
|
||
<BoolToggle value={data.nat_enabled} onChange={(v) => onChange({ ...data, nat_enabled: v })} label="NAT Enabled" />
|
||
</Field>
|
||
<Field label="">
|
||
<BoolToggle value={data.firewall_enabled} onChange={(v) => onChange({ ...data, firewall_enabled: v })} label="Firewall Enabled" />
|
||
</Field>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function VaultForm({ data, onChange }) {
|
||
return (
|
||
<div className="space-y-3">
|
||
<Field label="">
|
||
<BoolToggle value={data.ca_configured} onChange={(v) => onChange({ ...data, ca_configured: v })} label="CA Configured" />
|
||
</Field>
|
||
<Field label="">
|
||
<BoolToggle value={data.fernet_configured} onChange={(v) => onChange({ ...data, fernet_configured: v })} label="Fernet Encryption Configured" />
|
||
</Field>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// service config meta
|
||
const SERVICE_DEFS = [
|
||
{ key: 'network', label: 'Network (DNS/DHCP/NTP)', icon: Network, Form: NetworkForm, defaults: { dns_port: 53, dhcp_range: '', ntp_servers: [] } },
|
||
{ key: 'wireguard', label: 'WireGuard VPN', icon: Shield, Form: WireguardForm, defaults: { port: 51820, address: '', private_key: '' } },
|
||
{ key: 'email', label: 'Email (SMTP/IMAP)', icon: Mail, Form: EmailForm, defaults: { domain: '', smtp_port: 25, submission_port: 587, imap_port: 993, webmail_port: 8888 } },
|
||
{ key: 'calendar', label: 'Calendar (CalDAV)', icon: Calendar, Form: CalendarForm, defaults: { port: 5232, data_dir: '' } },
|
||
{ key: 'files', label: 'Files (WebDAV)', icon: HardDrive, Form: FilesForm, defaults: { port: 8080, manager_port: 8082, data_dir: '', quota: 1024 } },
|
||
{ key: 'routing', label: 'Routing & Firewall', icon: GitBranch, Form: RoutingForm, defaults: { nat_enabled: true, firewall_enabled: true } },
|
||
{ key: 'vault', label: 'Vault & Trust', icon: Lock, Form: VaultForm, defaults: { ca_configured: false, fernet_configured: false } },
|
||
];
|
||
|
||
// ── Main component ────────────────────────────────────────────────────────────
|
||
|
||
function Settings() {
|
||
const toasts = useToasts();
|
||
const { refresh: refreshConfig } = useConfig();
|
||
|
||
// identity
|
||
const [identity, setIdentity] = useState({ cell_name: '', domain: '', ip_range: '', wireguard_port: 51820 });
|
||
const [identityDirty, setIdentityDirty] = useState(false);
|
||
const [identitySaving, setIdentitySaving] = useState(false);
|
||
|
||
// service configs
|
||
const [serviceConfigs, setServiceConfigs] = useState({});
|
||
const [serviceDirty, setServiceDirty] = useState({});
|
||
const [serviceSaving, setServiceSaving] = useState({});
|
||
|
||
// backups
|
||
const [backups, setBackups] = useState([]);
|
||
const [backupsLoading, setBackupsLoading] = useState(false);
|
||
const [backupCreating, setBackupCreating] = useState(false);
|
||
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
|
||
const loadAll = useCallback(async () => {
|
||
setIsLoading(true);
|
||
try {
|
||
const [cfgRes, bkRes] = await Promise.all([
|
||
cellAPI.getConfig(),
|
||
cellAPI.listBackups(),
|
||
]);
|
||
const cfg = cfgRes.data;
|
||
setIdentity({
|
||
cell_name: cfg.cell_name || '',
|
||
domain: cfg.domain || '',
|
||
ip_range: cfg.ip_range || '',
|
||
wireguard_port: cfg.wireguard_port || 51820,
|
||
});
|
||
setServiceConfigs(cfg.service_configs || {});
|
||
setBackups(bkRes.data || []);
|
||
} catch (err) {
|
||
toast('Failed to load configuration', 'error');
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => { loadAll(); }, [loadAll]);
|
||
|
||
const _applyResult = (res, label) => {
|
||
const { restarted = [], warnings = [] } = res.data || {};
|
||
if (restarted.length > 0) {
|
||
toast(`${label} saved — restarted: ${restarted.join(', ')}`);
|
||
} else {
|
||
toast(`${label} saved`);
|
||
}
|
||
warnings.forEach((w) => toast(w, 'warning'));
|
||
};
|
||
|
||
// identity save
|
||
const saveIdentity = async () => {
|
||
setIdentitySaving(true);
|
||
try {
|
||
const res = await cellAPI.updateConfig(identity);
|
||
setIdentityDirty(false);
|
||
_applyResult(res, 'Cell identity');
|
||
refreshConfig();
|
||
} catch {
|
||
toast('Failed to save identity', 'error');
|
||
} finally {
|
||
setIdentitySaving(false);
|
||
}
|
||
};
|
||
|
||
// service config save
|
||
const saveService = async (key) => {
|
||
setServiceSaving((s) => ({ ...s, [key]: true }));
|
||
try {
|
||
const res = await cellAPI.updateConfig({ [key]: serviceConfigs[key] });
|
||
setServiceDirty((d) => ({ ...d, [key]: false }));
|
||
_applyResult(res, key);
|
||
} catch {
|
||
toast(`Failed to save ${key} config`, 'error');
|
||
} finally {
|
||
setServiceSaving((s) => ({ ...s, [key]: false }));
|
||
}
|
||
};
|
||
|
||
const updateServiceConfig = (key, data) => {
|
||
setServiceConfigs((prev) => ({ ...prev, [key]: data }));
|
||
setServiceDirty((d) => ({ ...d, [key]: true }));
|
||
};
|
||
|
||
// backups
|
||
const createBackup = async () => {
|
||
setBackupCreating(true);
|
||
try {
|
||
await cellAPI.createBackup();
|
||
toast('Backup created');
|
||
const res = await cellAPI.listBackups();
|
||
setBackups(res.data || []);
|
||
} catch {
|
||
toast('Failed to create backup', 'error');
|
||
} finally {
|
||
setBackupCreating(false);
|
||
}
|
||
};
|
||
|
||
const restoreBackup = async (id) => {
|
||
if (!confirm(`Restore backup ${id}? Current config will be overwritten.`)) return;
|
||
try {
|
||
await cellAPI.restoreBackup(id);
|
||
toast('Configuration restored — reloading…');
|
||
setTimeout(() => loadAll(), 500);
|
||
} catch {
|
||
toast('Failed to restore backup', 'error');
|
||
}
|
||
};
|
||
|
||
const deleteBackup = async (id) => {
|
||
if (!confirm(`Delete backup ${id}?`)) return;
|
||
try {
|
||
await cellAPI.deleteBackup(id);
|
||
setBackups((prev) => prev.filter((b) => b.backup_id !== id));
|
||
toast('Backup deleted');
|
||
} catch {
|
||
toast('Failed to delete backup', 'error');
|
||
}
|
||
};
|
||
|
||
// export
|
||
const exportConfig = async () => {
|
||
try {
|
||
const res = await cellAPI.exportConfig('json');
|
||
const blob = new Blob([res.data.config], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `pic-config-${new Date().toISOString().slice(0, 10)}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} catch {
|
||
toast('Export failed', 'error');
|
||
}
|
||
};
|
||
|
||
// import
|
||
const importConfig = async (e) => {
|
||
const file = e.target.files?.[0];
|
||
if (!file) return;
|
||
const text = await file.text();
|
||
if (!confirm('Import this config? Current settings will be replaced.')) { e.target.value = ''; return; }
|
||
try {
|
||
await cellAPI.importConfig(text, 'json');
|
||
toast('Config imported — reloading…');
|
||
setTimeout(() => loadAll(), 500);
|
||
} catch {
|
||
toast('Import failed', 'error');
|
||
} finally {
|
||
e.target.value = '';
|
||
}
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex items-center justify-center h-64">
|
||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<Toast toasts={toasts} />
|
||
<div className="mb-6">
|
||
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||
<p className="mt-1 text-gray-500 text-sm">Configure your Personal Internet Cell</p>
|
||
</div>
|
||
|
||
{/* Cell Identity */}
|
||
<Section icon={Server} title="Cell Identity">
|
||
<div className="space-y-3">
|
||
<Field label="Cell Name">
|
||
<TextInput
|
||
value={identity.cell_name}
|
||
onChange={(v) => { setIdentity((i) => ({ ...i, cell_name: v })); setIdentityDirty(true); }}
|
||
placeholder="mycell"
|
||
/>
|
||
</Field>
|
||
<Field label="Domain">
|
||
<TextInput
|
||
value={identity.domain}
|
||
onChange={(v) => { setIdentity((i) => ({ ...i, domain: v })); setIdentityDirty(true); }}
|
||
placeholder="cell.local"
|
||
/>
|
||
</Field>
|
||
<Field label="IP Range" hint="Docker bridge subnet">
|
||
<TextInput
|
||
value={identity.ip_range}
|
||
onChange={(v) => { setIdentity((i) => ({ ...i, ip_range: v })); setIdentityDirty(true); }}
|
||
placeholder="172.20.0.0/16"
|
||
/>
|
||
</Field>
|
||
<Field label="WireGuard Port">
|
||
<NumberInput
|
||
value={identity.wireguard_port}
|
||
onChange={(v) => { setIdentity((i) => ({ ...i, wireguard_port: v })); setIdentityDirty(true); }}
|
||
min={1} max={65535}
|
||
/>
|
||
</Field>
|
||
</div>
|
||
<div className="flex justify-end mt-4">
|
||
<button
|
||
onClick={saveIdentity}
|
||
disabled={!identityDirty || identitySaving}
|
||
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
|
||
>
|
||
{identitySaving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||
Save Identity
|
||
</button>
|
||
</div>
|
||
<p className="text-xs text-gray-400 mt-2">
|
||
IP Range and port changes update the .env file and mark affected containers for restart.
|
||
Use the banner above to apply when ready.
|
||
</p>
|
||
</Section>
|
||
|
||
{/* Service Configurations */}
|
||
<div className="mb-2">
|
||
<h2 className="text-lg font-semibold text-gray-800">Service Configuration</h2>
|
||
</div>
|
||
{SERVICE_DEFS.map(({ key, label, icon: Icon, Form, defaults }) => {
|
||
const data = { ...defaults, ...(serviceConfigs[key] || {}) };
|
||
const dirty = serviceDirty[key];
|
||
const saving = serviceSaving[key];
|
||
return (
|
||
<Section key={key} icon={Icon} title={label} collapsible defaultOpen={false}>
|
||
<Form data={data} onChange={(d) => updateServiceConfig(key, d)} />
|
||
<div className="flex items-center justify-between mt-4">
|
||
<span className="text-xs text-gray-400">Port/directory changes take effect after container restart.</span>
|
||
<button
|
||
onClick={() => saveService(key)}
|
||
disabled={!dirty || saving}
|
||
className="btn-primary flex items-center gap-2 text-sm disabled:opacity-50"
|
||
>
|
||
{saving ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||
Save
|
||
</button>
|
||
</div>
|
||
</Section>
|
||
);
|
||
})}
|
||
|
||
{/* Backup & Restore */}
|
||
<Section icon={Archive} title="Backup & Restore" collapsible defaultOpen>
|
||
<div className="flex justify-between items-center mb-3">
|
||
<span className="text-sm text-gray-600">{backups.length} backup{backups.length !== 1 ? 's' : ''} stored</span>
|
||
<button
|
||
onClick={createBackup}
|
||
disabled={backupCreating}
|
||
className="btn-secondary flex items-center gap-2 text-sm"
|
||
>
|
||
{backupCreating ? <RefreshCw className="h-4 w-4 animate-spin" /> : <Archive className="h-4 w-4" />}
|
||
Create Backup
|
||
</button>
|
||
</div>
|
||
{backups.length === 0 ? (
|
||
<p className="text-sm text-gray-400 text-center py-4">No backups yet</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-left text-xs text-gray-500 border-b">
|
||
<th className="pb-2 font-medium">Backup ID</th>
|
||
<th className="pb-2 font-medium">Timestamp</th>
|
||
<th className="pb-2 font-medium">Services</th>
|
||
<th className="pb-2" />
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{backups.map((b) => (
|
||
<tr key={b.backup_id} className="hover:bg-gray-50">
|
||
<td className="py-2 font-mono text-xs text-gray-700">{b.backup_id}</td>
|
||
<td className="py-2 text-gray-600">{new Date(b.timestamp).toLocaleString()}</td>
|
||
<td className="py-2 text-gray-500">{(b.services || []).length} services</td>
|
||
<td className="py-2">
|
||
<div className="flex gap-2 justify-end">
|
||
<button
|
||
onClick={() => restoreBackup(b.backup_id)}
|
||
className="text-blue-600 hover:text-blue-800 flex items-center gap-1 text-xs"
|
||
title="Restore"
|
||
>
|
||
<RotateCcw className="h-3.5 w-3.5" /> Restore
|
||
</button>
|
||
<button
|
||
onClick={() => deleteBackup(b.backup_id)}
|
||
className="text-red-500 hover:text-red-700 flex items-center gap-1 text-xs"
|
||
title="Delete"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5" /> Delete
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</Section>
|
||
|
||
{/* Export / Import */}
|
||
<Section icon={Download} title="Export & Import">
|
||
<div className="flex flex-wrap gap-3">
|
||
<button onClick={exportConfig} className="btn-secondary flex items-center gap-2 text-sm">
|
||
<Download className="h-4 w-4" /> Export JSON
|
||
</button>
|
||
<label className="btn-secondary flex items-center gap-2 text-sm cursor-pointer">
|
||
<Upload className="h-4 w-4" /> Import JSON
|
||
<input type="file" accept=".json" className="hidden" onChange={importConfig} />
|
||
</label>
|
||
</div>
|
||
<p className="text-xs text-gray-400 mt-2">
|
||
Export downloads all service configs as JSON. Import replaces current service configs.
|
||
</p>
|
||
</Section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default Settings;
|