feat: fully editable Settings page with service configs, backup/restore, export/import
- Rewrote Settings.jsx: Cell Identity editor, per-service config sections (network, wireguard, email, calendar, files, routing, vault) with collapsible cards, appropriate input types, and per-section Save buttons - Added Backup & Restore panel with create/restore/delete actions - Added Export (download JSON) and Import (upload JSON) panel - Added PUT /api/config identity field persistence (_identity key in cell_config.json) so cell_name/domain/ip_range/wireguard_port survive restarts - GET /api/config now returns service_configs separately and prefers stored identity - Added DELETE /api/config/backups/<id> endpoint - Extended cellAPI in api.js with createBackup, listBackups, restoreBackup, deleteBackup, exportConfig, importConfig Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+31
-9
@@ -373,13 +373,14 @@ def get_config():
|
||||
"""Get cell configuration."""
|
||||
try:
|
||||
service_configs = config_manager.get_all_configs()
|
||||
identity = service_configs.pop('_identity', {})
|
||||
config = {
|
||||
'cell_name': os.environ.get('CELL_NAME', 'mycell'),
|
||||
'domain': os.environ.get('CELL_DOMAIN', 'cell'),
|
||||
'ip_range': os.environ.get('CELL_IP_RANGE', '172.20.0.0/16'),
|
||||
'wireguard_port': int(os.environ.get('WG_PORT', '51820')),
|
||||
'cell_name': identity.get('cell_name', os.environ.get('CELL_NAME', 'mycell')),
|
||||
'domain': identity.get('domain', os.environ.get('CELL_DOMAIN', 'cell')),
|
||||
'ip_range': identity.get('ip_range', os.environ.get('CELL_IP_RANGE', '172.20.0.0/16')),
|
||||
'wireguard_port': identity.get('wireguard_port', int(os.environ.get('WG_PORT', '51820'))),
|
||||
}
|
||||
config.update(service_configs)
|
||||
config['service_configs'] = service_configs
|
||||
return jsonify(config)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting config: {e}")
|
||||
@@ -392,18 +393,26 @@ def update_config():
|
||||
data = request.get_json(silent=True)
|
||||
if data is None:
|
||||
return jsonify({"error": "No data provided"}), 400
|
||||
|
||||
# Update configuration using config manager
|
||||
|
||||
# Handle identity fields (cell_name, domain, ip_range, wireguard_port)
|
||||
identity_keys = {'cell_name', 'domain', 'ip_range', 'wireguard_port'}
|
||||
identity_updates = {k: v for k, v in data.items() if k in identity_keys}
|
||||
if identity_updates:
|
||||
stored = config_manager.configs.get('_identity', {})
|
||||
stored.update(identity_updates)
|
||||
config_manager.configs['_identity'] = stored
|
||||
config_manager._save_all_configs()
|
||||
|
||||
# Update service configurations
|
||||
for service, config in data.items():
|
||||
if service in config_manager.service_schemas:
|
||||
success = config_manager.update_service_config(service, config)
|
||||
if success:
|
||||
# Publish config change event
|
||||
service_bus.publish_event(EventType.CONFIG_CHANGED, service, {
|
||||
'service': service,
|
||||
'config': config
|
||||
})
|
||||
|
||||
|
||||
logger.info(f"Updated config: {data}")
|
||||
return jsonify({"message": "Configuration updated successfully"})
|
||||
except Exception as e:
|
||||
@@ -483,6 +492,19 @@ def import_config():
|
||||
logger.error(f"Error importing config: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/config/backups/<backup_id>', methods=['DELETE'])
|
||||
def delete_config_backup(backup_id):
|
||||
"""Delete a configuration backup."""
|
||||
try:
|
||||
success = config_manager.delete_backup(backup_id)
|
||||
if success:
|
||||
return jsonify({"message": f"Backup {backup_id} deleted"})
|
||||
else:
|
||||
return jsonify({"error": f"Failed to delete backup {backup_id}"}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting backup: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
# Service bus endpoints
|
||||
@app.route('/api/services/bus/status', methods=['GET'])
|
||||
def get_service_bus_status():
|
||||
|
||||
+589
-98
@@ -1,98 +1,589 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Settings as SettingsIcon, Server, Shield } from 'lucide-react';
|
||||
import { cellAPI } from '../services/api';
|
||||
|
||||
function Settings() {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await cellAPI.getConfig();
|
||||
setConfig(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Configure your Personal Internet Cell
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Cell Configuration */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Server className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Cell Configuration</h3>
|
||||
</div>
|
||||
{config ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Cell Name:</span>
|
||||
<span className="text-sm font-medium">{config.cell_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Domain:</span>
|
||||
<span className="text-sm font-medium">{config.domain}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">IP Range:</span>
|
||||
<span className="text-sm font-medium">{config.ip_range}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">WireGuard Port:</span>
|
||||
<span className="text-sm font-medium">{config.wireguard_port}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Configuration unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Settings */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Shield className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Security Settings</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">TLS Certificate:</span>
|
||||
<span className="text-sm font-medium text-success-600">Valid</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Firewall:</span>
|
||||
<span className="text-sm font-medium text-success-600">Active</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">VPN Encryption:</span>
|
||||
<span className="text-sm font-medium text-success-600">Enabled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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">
|
||||
<NumberInput value={data.smtp_port} onChange={(v) => onChange({ ...data, smtp_port: v })} min={1} max={65535} />
|
||||
</Field>
|
||||
<Field label="IMAP Port">
|
||||
<NumberInput value={data.imap_port} onChange={(v) => onChange({ ...data, imap_port: v })} min={1} max={65535} />
|
||||
</Field>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarForm({ data, onChange }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Field label="CalDAV Port">
|
||||
<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">
|
||||
<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/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: 587, imap_port: 993 } },
|
||||
{ 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: 80, 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();
|
||||
|
||||
// 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]);
|
||||
|
||||
// identity save
|
||||
const saveIdentity = async () => {
|
||||
setIdentitySaving(true);
|
||||
try {
|
||||
await cellAPI.updateConfig(identity);
|
||||
setIdentityDirty(false);
|
||||
toast('Cell identity saved');
|
||||
} catch {
|
||||
toast('Failed to save identity', 'error');
|
||||
} finally {
|
||||
setIdentitySaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// service config save
|
||||
const saveService = async (key) => {
|
||||
setServiceSaving((s) => ({ ...s, [key]: true }));
|
||||
try {
|
||||
await cellAPI.updateConfig({ [key]: serviceConfigs[key] });
|
||||
setServiceDirty((d) => ({ ...d, [key]: false }));
|
||||
toast(`${key} configuration saved`);
|
||||
} 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">
|
||||
Note: IP Range and WireGuard Port are also set via environment variables in docker-compose.yml.
|
||||
Changes here are stored in config and take effect on next container start.
|
||||
</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 justify-end mt-4">
|
||||
<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;
|
||||
|
||||
@@ -37,6 +37,12 @@ export const cellAPI = {
|
||||
getStatus: () => api.get('/api/status'),
|
||||
getConfig: () => api.get('/api/config'),
|
||||
updateConfig: (config) => api.put('/api/config', config),
|
||||
createBackup: () => api.post('/api/config/backup'),
|
||||
listBackups: () => api.get('/api/config/backups'),
|
||||
restoreBackup: (id) => api.post(`/api/config/restore/${id}`),
|
||||
deleteBackup: (id) => api.delete(`/api/config/backups/${id}`),
|
||||
exportConfig: (format = 'json') => api.get('/api/config/export', { params: { format } }),
|
||||
importConfig: (config, format = 'json') => api.post('/api/config/import', { config, format }),
|
||||
};
|
||||
|
||||
// Network Services API
|
||||
|
||||
Reference in New Issue
Block a user