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:
2026-04-21 03:19:46 -04:00
parent 8e1814c7d2
commit c778ee8eb8
3 changed files with 626 additions and 107 deletions
+29 -7
View File
@@ -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}")
@@ -393,12 +394,20 @@ def update_config():
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
@@ -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():
+554 -63
View File
@@ -1,96 +1,587 @@
import { useState, useEffect } from 'react';
import { Settings as SettingsIcon, Server, Shield } from 'lucide-react';
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 [config, setConfig] = useState(null);
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);
useEffect(() => {
fetchConfig();
}, []);
const fetchConfig = async () => {
const loadAll = useCallback(async () => {
setIsLoading(true);
try {
const response = await cellAPI.getConfig();
setConfig(response.data);
} catch (error) {
console.error('Failed to fetch config:', error);
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>
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600" />
</div>
);
}
return (
<div>
<div className="mb-8">
<Toast toasts={toasts} />
<div className="mb-6">
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<p className="mt-2 text-gray-600">
Configure your Personal Internet Cell
<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>
</div>
</Section>
<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>
{/* Service Configurations */}
<div className="mb-2">
<h2 className="text-lg font-semibold text-gray-800">Service Configuration</h2>
</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>
{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>
) : (
<p className="text-gray-500 text-sm">Configuration unavailable</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>
)}
</div>
</Section>
{/* 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>
{/* 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>
);
}
+6
View File
@@ -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