Add unified network management with backup/restore and safety checks
New capabilities: - Unified Network tab: provision VLANs across switch + OPNsense in one operation — select ports, set PoE per-port, auto-configure DHCP and firewall rules on OPNsense - Automatic backup before every change: switch running-config via SSH, OPNsense full XML config export via API - Backup/Restore tab: manual backups, download, restore with safety net (creates backup of current state before restoring) - Connectivity safety checks: pre-change and post-change SSH/API probes to both devices — warns if connectivity lost after push - Safe push endpoint (/api/switch/push-safe) wraps existing push with auto-backup and connectivity verification - Backup pruning (keeps last 50 per device) https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
This commit is contained in:
@@ -1525,6 +1525,7 @@ export default function App() {
|
||||
|
||||
const TABS = [
|
||||
{ id:"dashboard",label:"Dashboard" },
|
||||
{ id:"network", label:"Network" },
|
||||
{ id:"ports", label:"Port Map" },
|
||||
{ id:"vlans", label:"VLANs" },
|
||||
{ id:"acls", label:"ACL Builder" },
|
||||
@@ -1533,6 +1534,7 @@ export default function App() {
|
||||
{ id:"dhcp", label:"DHCP" },
|
||||
{ id:"dns", label:"DNS Filtering" },
|
||||
{ id:"vpn", label:"VPN" },
|
||||
{ id:"backups", label:"Backups" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1598,12 +1600,24 @@ export default function App() {
|
||||
acls={acls}
|
||||
setAcls={setAcls}
|
||||
/>}
|
||||
{tab==="network" && <NetworkTab
|
||||
vlans={vlans} setVlans={setVlans}
|
||||
ports={ports} updatePort={updatePort}
|
||||
session={session}
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
backendOk={pollStatus!=="err"}
|
||||
/>}
|
||||
{tab==="vpn" && <WireGuardTab
|
||||
session={session}
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
backendOk={pollStatus!=="err"}
|
||||
vlans={vlans}
|
||||
/>}
|
||||
{tab==="backups" && <BackupTab
|
||||
session={session}
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
backendOk={pollStatus!=="err"}
|
||||
/>}
|
||||
|
||||
{showTotp && <TotpModal
|
||||
onSuccess={handleTotpSuccess}
|
||||
@@ -4265,3 +4279,472 @@ function DNSTab({ vlans, session, onNeedAuth, backendOk, acls, setAcls }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// UNIFIED NETWORK TAB — manage VLANs + ports + OPNsense in one place
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function NetworkTab({ vlans, setVlans, ports, updatePort, session, onNeedAuth, backendOk }) {
|
||||
const [form, setForm] = useState({
|
||||
vlan_id: "", name: "", subnet: "", gateway: "",
|
||||
dhcp_start: "", dhcp_end: "",
|
||||
parent_if: "igb0", opnsense_if: "", allow_internet: true,
|
||||
});
|
||||
const [selectedPorts, setSelectedPorts] = useState([]);
|
||||
const [trunkPorts, setTrunkPorts] = useState([]);
|
||||
const [provisioning, setProvisioning] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [connStatus, setConnStatus] = useState(null);
|
||||
const [connLoading, setConnLoading] = useState(false);
|
||||
|
||||
const checkConn = async () => {
|
||||
setConnLoading(true);
|
||||
try { setConnStatus(await API("/connectivity/check")); }
|
||||
catch(e) { setConnStatus({ error: e.message }); }
|
||||
setConnLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { if (backendOk) checkConn(); }, [backendOk]);
|
||||
|
||||
const autoFillSubnet = (vid) => {
|
||||
if (!vid) return;
|
||||
const v = parseInt(vid);
|
||||
if (v > 0 && v < 255) {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
subnet: `192.168.${v}.0/24`,
|
||||
gateway: `192.168.${v}.1`,
|
||||
dhcp_start: `192.168.${v}.100`,
|
||||
dhcp_end: `192.168.${v}.200`,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const togglePort = (portId, list, setList) => {
|
||||
setList(prev => prev.includes(portId) ? prev.filter(p => p !== portId) : [...prev, portId]);
|
||||
};
|
||||
|
||||
const provision = async () => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
setProvisioning(true); setResult(null);
|
||||
try {
|
||||
const body = {
|
||||
token: session.token,
|
||||
vlan_id: parseInt(form.vlan_id),
|
||||
name: form.name,
|
||||
subnet: form.subnet,
|
||||
gateway: form.gateway,
|
||||
dhcp_start: form.dhcp_start,
|
||||
dhcp_end: form.dhcp_end,
|
||||
parent_if: form.parent_if,
|
||||
opnsense_if: form.opnsense_if || "",
|
||||
allow_internet: form.allow_internet,
|
||||
ports: selectedPorts.map(p => ({ port: p.id, poe: p.poe, poe_limit: p.poeLimit || 30000 })),
|
||||
trunk_ports: trunkPorts,
|
||||
};
|
||||
const r = await API("/network/provision", { method: "POST", body });
|
||||
setResult(r);
|
||||
if (r.success) {
|
||||
// Update local vlans state
|
||||
const newVlan = { id: parseInt(form.vlan_id), name: form.name,
|
||||
color: VLAN_COLORS[vlans.length % VLAN_COLORS.length] };
|
||||
if (!vlans.find(v => v.id === newVlan.id)) setVlans([...vlans, newVlan]);
|
||||
}
|
||||
} catch(e) {
|
||||
setResult({ success: false, errors: [e.message] });
|
||||
}
|
||||
setProvisioning(false);
|
||||
};
|
||||
|
||||
const portGrid = (start, end, forTrunk) => (
|
||||
<div style={{ display:"flex", flexWrap:"wrap", gap:4, marginBottom:8 }}>
|
||||
{ports.filter(p => p.id >= start && p.id <= end).map(p => {
|
||||
const inUse = p.mode === "access" && p.accessVlan !== 1;
|
||||
const vlan = vlans.find(v => v.id === (p.accessVlan || 1));
|
||||
const isSelected = forTrunk ? trunkPorts.includes(p.id) : selectedPorts.find(s => s.id === p.id);
|
||||
return (
|
||||
<div key={p.id} onClick={() => {
|
||||
if (forTrunk) {
|
||||
togglePort(p.id, trunkPorts, setTrunkPorts);
|
||||
} else {
|
||||
if (isSelected) {
|
||||
setSelectedPorts(prev => prev.filter(s => s.id !== p.id));
|
||||
} else {
|
||||
setSelectedPorts(prev => [...prev, { id: p.id, poe: true, poeLimit: 30000 }]);
|
||||
}
|
||||
}
|
||||
}} style={{
|
||||
width:36, height:36, display:"flex", alignItems:"center", justifyContent:"center",
|
||||
fontSize:11, fontWeight:700, borderRadius:4, cursor:"pointer",
|
||||
border: isSelected ? "2px solid var(--ac)" : "1px solid var(--b2)",
|
||||
background: isSelected ? "var(--ac)" : inUse ? (vlan?.color || "var(--b1)") + "30" : "var(--bg)",
|
||||
color: isSelected ? "var(--bg)" : inUse ? "var(--dm)" : "var(--tx)",
|
||||
opacity: inUse && !forTrunk ? 0.5 : 1,
|
||||
}} title={`Port ${p.id}${inUse ? ` (${vlan?.name || "VLAN " + p.accessVlan})` : ""}${p.description ? " — " + p.description : ""}`}>
|
||||
{p.id}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="main">
|
||||
<div style={{flex:1}}>
|
||||
{/* Connectivity Status */}
|
||||
<div className="panel">
|
||||
<div className="ph">Connectivity Status</div>
|
||||
<div className="pb" style={{display:"flex",gap:16,alignItems:"center"}}>
|
||||
<div style={{display:"flex",alignItems:"center",gap:6}}>
|
||||
<span className={`dot ${connStatus?.switch?.ok ? "ok" : "err"}`}/>
|
||||
<span style={{fontSize:12}}>Switch {connStatus?.switch?.ok ? "Online" : "Offline"}</span>
|
||||
</div>
|
||||
<div style={{display:"flex",alignItems:"center",gap:6}}>
|
||||
<span className={`dot ${connStatus?.opnsense?.ok ? "ok" : connStatus?.opnsense?.configured ? "warn" : "idle"}`}/>
|
||||
<span style={{fontSize:12}}>
|
||||
OPNsense {connStatus?.opnsense?.ok ? "Online" : connStatus?.opnsense?.configured ? "Unreachable" : "Not configured"}
|
||||
</span>
|
||||
</div>
|
||||
<button className="btn bd" onClick={checkConn} disabled={connLoading}
|
||||
style={{marginLeft:"auto",fontSize:11,padding:"4px 12px"}}>
|
||||
{connLoading ? "Checking..." : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Existing VLANs overview */}
|
||||
<div className="panel">
|
||||
<div className="ph">Active VLANs</div>
|
||||
<div className="pb">
|
||||
<div style={{display:"flex",flexWrap:"wrap",gap:8}}>
|
||||
{vlans.map(v => {
|
||||
const cnt = ports.filter(p =>
|
||||
p.mode === "access" ? p.accessVlan === v.id : p.taggedVlans?.includes(v.id)
|
||||
).length;
|
||||
return (
|
||||
<div key={v.id} style={{
|
||||
background: v.color + "20", border:`1px solid ${v.color}50`,
|
||||
borderRadius:6, padding:"8px 14px", minWidth:140,
|
||||
}}>
|
||||
<div style={{fontWeight:700,color:v.color,fontSize:14}}>VLAN {v.id}</div>
|
||||
<div style={{fontSize:12,color:"var(--tx)"}}>{v.name}</div>
|
||||
<div style={{fontSize:11,color:"var(--dm)",marginTop:4}}>
|
||||
{cnt} port{cnt !== 1 ? "s" : ""} | 192.168.{v.id}.0/24
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Unified VLAN Provisioning Form */}
|
||||
<div className="panel">
|
||||
<div className="ph">Provision New VLAN (Switch + OPNsense)</div>
|
||||
<div className="pb">
|
||||
<div style={{
|
||||
background:"var(--bg)",border:"1px solid var(--ac)30",borderRadius:6,
|
||||
padding:12,marginBottom:16,fontSize:12,color:"var(--dm)",lineHeight:1.6,
|
||||
}}>
|
||||
This creates everything in one step: VLAN on the switch, assigns ports with PoE settings,
|
||||
creates the VLAN tag and DHCP scope on OPNsense, and adds a firewall rule.
|
||||
A backup is taken automatically before any changes are made.
|
||||
</div>
|
||||
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:12}}>
|
||||
<div className="field"><label>VLAN ID</label>
|
||||
<input value={form.vlan_id} onChange={e => {
|
||||
setForm(f => ({...f, vlan_id: e.target.value}));
|
||||
autoFillSubnet(e.target.value);
|
||||
}} type="number" placeholder="60"/>
|
||||
</div>
|
||||
<div className="field"><label>Name</label>
|
||||
<input value={form.name} onChange={e => setForm(f => ({...f, name: e.target.value}))} placeholder="e.g. Cameras"/>
|
||||
</div>
|
||||
<div className="field"><label>Subnet</label>
|
||||
<input value={form.subnet} onChange={e => setForm(f => ({...f, subnet: e.target.value}))} placeholder="192.168.60.0/24"/>
|
||||
</div>
|
||||
<div className="field"><label>Gateway (OPNsense IP)</label>
|
||||
<input value={form.gateway} onChange={e => setForm(f => ({...f, gateway: e.target.value}))} placeholder="192.168.60.1"/>
|
||||
</div>
|
||||
<div className="field"><label>DHCP Start</label>
|
||||
<input value={form.dhcp_start} onChange={e => setForm(f => ({...f, dhcp_start: e.target.value}))} placeholder="192.168.60.100"/>
|
||||
</div>
|
||||
<div className="field"><label>DHCP End</label>
|
||||
<input value={form.dhcp_end} onChange={e => setForm(f => ({...f, dhcp_end: e.target.value}))} placeholder="192.168.60.200"/>
|
||||
</div>
|
||||
<div className="field"><label>OPNsense Parent Interface</label>
|
||||
<input value={form.parent_if} onChange={e => setForm(f => ({...f, parent_if: e.target.value}))} placeholder="igb0"/>
|
||||
</div>
|
||||
<div className="field"><label>OPNsense Interface (if assigned)</label>
|
||||
<input value={form.opnsense_if} onChange={e => setForm(f => ({...f, opnsense_if: e.target.value}))} placeholder="e.g. opt3 (leave blank if new)"/>
|
||||
</div>
|
||||
<div className="field" style={{display:"flex",alignItems:"flex-end"}}>
|
||||
<label style={{display:"flex",alignItems:"center",gap:6,cursor:"pointer"}}>
|
||||
<input type="checkbox" checked={form.allow_internet}
|
||||
onChange={e => setForm(f => ({...f, allow_internet: e.target.checked}))}/>
|
||||
Allow internet access
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Port Selection */}
|
||||
<div style={{marginTop:20}}>
|
||||
<div className="sect">Access Ports (will be assigned to this VLAN)</div>
|
||||
<div style={{fontSize:11,color:"var(--dm)",marginBottom:8}}>
|
||||
Click ports to select. Dimmed ports are already assigned to another VLAN.
|
||||
</div>
|
||||
{portGrid(1, 24, false)}
|
||||
{portGrid(25, 48, false)}
|
||||
|
||||
{selectedPorts.length > 0 && (
|
||||
<div style={{marginTop:12}}>
|
||||
<div className="sect">PoE Settings for Selected Ports</div>
|
||||
<div style={{display:"flex",flexWrap:"wrap",gap:8}}>
|
||||
{selectedPorts.map(sp => (
|
||||
<div key={sp.id} style={{
|
||||
display:"flex",alignItems:"center",gap:6,
|
||||
background:"var(--bg)",border:"1px solid var(--b2)",
|
||||
borderRadius:4,padding:"4px 10px",fontSize:12,
|
||||
}}>
|
||||
<span style={{fontWeight:700,color:"var(--ac)"}}>Port {sp.id}</span>
|
||||
<label style={{display:"flex",alignItems:"center",gap:4,cursor:"pointer"}}>
|
||||
<input type="checkbox" checked={sp.poe}
|
||||
onChange={e => setSelectedPorts(prev =>
|
||||
prev.map(p => p.id === sp.id ? {...p, poe: e.target.checked} : p)
|
||||
)}/>
|
||||
<span style={{fontSize:11}}>PoE</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{marginTop:16}}>
|
||||
<div className="sect">Trunk Uplinks (tag this VLAN on existing trunks)</div>
|
||||
<div style={{fontSize:11,color:"var(--dm)",marginBottom:8}}>
|
||||
Select uplink ports that should carry this VLAN (typically SFP+ ports 49-52).
|
||||
</div>
|
||||
{portGrid(49, 52, true)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{(form.vlan_id && form.name) && (
|
||||
<div style={{
|
||||
marginTop:16,padding:12,background:"var(--b1)",borderRadius:6,
|
||||
fontSize:12,lineHeight:1.8,
|
||||
}}>
|
||||
<div style={{fontWeight:700,color:"var(--ac)",marginBottom:4}}>Provision Summary</div>
|
||||
<div>VLAN {form.vlan_id} "{form.name}" | Subnet: {form.subnet || "—"}</div>
|
||||
<div>Access ports: {selectedPorts.length > 0
|
||||
? selectedPorts.map(p => `${p.id}${p.poe ? " (PoE)" : ""}`).join(", ")
|
||||
: "none selected"}</div>
|
||||
<div>Trunk ports: {trunkPorts.length > 0 ? trunkPorts.join(", ") : "none"}</div>
|
||||
<div>OPNsense: {form.opnsense_if
|
||||
? `DHCP ${form.dhcp_start}–${form.dhcp_end} on ${form.opnsense_if}`
|
||||
: "VLAN tag only (assign interface in OPNsense UI)"}</div>
|
||||
{form.allow_internet && <div>Firewall: allow outbound</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{marginTop:16,display:"flex",gap:12,alignItems:"center"}}>
|
||||
<button className="btn bp" onClick={provision}
|
||||
disabled={provisioning || !form.vlan_id || !form.name}
|
||||
style={{padding:"10px 24px"}}>
|
||||
{provisioning ? "Provisioning..." : "Provision VLAN"}
|
||||
</button>
|
||||
<span style={{fontSize:11,color:"var(--dm)"}}>
|
||||
Auto-backup runs before changes. TOTP required.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div style={{
|
||||
marginTop:16,padding:12,borderRadius:6,
|
||||
background: result.success ? "rgba(0,230,118,0.08)" : "rgba(255,23,68,0.08)",
|
||||
border: `1px solid ${result.success ? "rgba(0,230,118,0.3)" : "rgba(255,23,68,0.3)"}`,
|
||||
}}>
|
||||
<div style={{fontWeight:700,marginBottom:8,color:result.success?"#00e676":"#ff1744"}}>
|
||||
{result.success ? "Provisioning Complete" : "Provisioning Failed"}
|
||||
</div>
|
||||
{result.steps_done?.map((s,i) => (
|
||||
<div key={i} style={{fontSize:12,color:"var(--tx)",lineHeight:1.8}}>
|
||||
<span style={{color:"#00e676",marginRight:6}}>done</span>{s}
|
||||
</div>
|
||||
))}
|
||||
{result.errors?.map((e,i) => (
|
||||
<div key={i} style={{fontSize:12,color:"#ff1744",lineHeight:1.8}}>
|
||||
<span style={{marginRight:6}}>error</span>{e}
|
||||
</div>
|
||||
))}
|
||||
{result.pending_steps?.map((s,i) => (
|
||||
<div key={i} style={{fontSize:12,color:"var(--warn, #ffea00)",lineHeight:1.8}}>
|
||||
<span style={{marginRight:6}}>manual</span>{s}
|
||||
</div>
|
||||
))}
|
||||
{result.backup && (
|
||||
<div style={{marginTop:8,fontSize:11,color:"var(--dm)"}}>
|
||||
Backup: switch={result.backup.switch?.file || "N/A"}
|
||||
{result.backup.opnsense ? `, opnsense=${result.backup.opnsense.file || "N/A"}` : ""}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// BACKUP TAB — view, create, restore, download backups
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function BackupTab({ session, onNeedAuth, backendOk }) {
|
||||
const [backups, setBackups] = useState({ switch: [], opnsense: [] });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [restoring, setRestoring] = useState(null);
|
||||
const [restoreResult, setRestoreResult] = useState(null);
|
||||
const [reason, setReason] = useState("");
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try { setBackups(await API("/backup/list")); } catch(e) { console.error(e); }
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => { if (backendOk) load(); }, [backendOk]);
|
||||
|
||||
const createBackup = async (device) => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
setCreating(true);
|
||||
try {
|
||||
await API("/backup/create", { method: "POST", body: {
|
||||
token: session.token, device, reason: reason || "manual backup",
|
||||
}});
|
||||
setReason("");
|
||||
await load();
|
||||
} catch(e) { alert("Backup failed: " + e.message); }
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const restore = async (device, filename) => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
if (!confirm(`Restore ${device} from ${filename}?\n\nA safety backup will be created first.`)) return;
|
||||
setRestoring(filename); setRestoreResult(null);
|
||||
try {
|
||||
const r = await API("/backup/restore", { method: "POST", body: {
|
||||
token: session.token, device, filename,
|
||||
}});
|
||||
setRestoreResult(r);
|
||||
await load();
|
||||
} catch(e) { setRestoreResult({ result: { ok: false, error: e.message } }); }
|
||||
setRestoring(null);
|
||||
};
|
||||
|
||||
const BackupTable = ({ device, items }) => (
|
||||
<div className="panel">
|
||||
<div className="ph">{device === "switch" ? "Switch" : "OPNsense"} Backups</div>
|
||||
<div className="pb">
|
||||
<div style={{display:"flex",gap:8,marginBottom:12,alignItems:"flex-end"}}>
|
||||
<div className="field" style={{margin:0,flex:1}}>
|
||||
<label>Reason (optional)</label>
|
||||
<input value={reason} onChange={e => setReason(e.target.value)} placeholder="e.g. before VLAN change"/>
|
||||
</div>
|
||||
<button className="btn bp" onClick={() => createBackup(device)} disabled={creating}
|
||||
style={{padding:"8px 16px"}}>
|
||||
{creating ? "Creating..." : `Backup ${device === "switch" ? "Switch" : "OPNsense"}`}
|
||||
</button>
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<div style={{color:"var(--dm)",fontSize:12,padding:16,textAlign:"center"}}>
|
||||
No backups yet. Create one before making changes.
|
||||
</div>
|
||||
) : (
|
||||
<table className="vtbl">
|
||||
<thead><tr><th>Time</th><th>Reason</th><th>Size</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
{items.map((b,i) => (
|
||||
<tr key={i}>
|
||||
<td style={{fontSize:12,fontFamily:"monospace"}}>{b.timestamp}</td>
|
||||
<td style={{fontSize:12}}>{b.reason || "—"}</td>
|
||||
<td style={{fontSize:12}}>{b.size ? `${(b.size/1024).toFixed(1)} KB` : "—"}</td>
|
||||
<td style={{display:"flex",gap:6}}>
|
||||
<a href={`/api/backup/download/${device}/${b.file}`}
|
||||
style={{fontSize:11,color:"var(--ac)",textDecoration:"none",cursor:"pointer"}}>
|
||||
Download
|
||||
</a>
|
||||
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
||||
onClick={() => restore(device, b.file)}
|
||||
disabled={restoring === b.file}>
|
||||
{restoring === b.file ? "Restoring..." : "Restore"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="main">
|
||||
<div style={{flex:1}}>
|
||||
<div className="panel">
|
||||
<div className="ph">Backup & Restore</div>
|
||||
<div className="pb" style={{fontSize:12,color:"var(--dm)",lineHeight:1.8}}>
|
||||
Backups are created automatically before every change (VLAN provisioning, push operations).
|
||||
You can also create manual backups here. OPNsense backups are full XML config exports.
|
||||
Switch backups capture the running configuration.
|
||||
<div style={{marginTop:8,color:"var(--ac)",fontWeight:600}}>
|
||||
Restore: OPNsense configs can be restored via API. Switch configs must be reviewed
|
||||
and applied via the Review & Push tab (to prevent accidental lockout).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{display:"flex",gap:8,marginBottom:16}}>
|
||||
<button className="btn bp" onClick={() => createBackup("both")} disabled={creating}
|
||||
style={{padding:"10px 20px"}}>
|
||||
{creating ? "Creating..." : "Backup Both Devices"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{restoreResult && (
|
||||
<div style={{
|
||||
marginBottom:16,padding:12,borderRadius:6,
|
||||
background: restoreResult.result?.ok ? "rgba(0,230,118,0.08)" : "rgba(255,23,68,0.08)",
|
||||
border: `1px solid ${restoreResult.result?.ok ? "rgba(0,230,118,0.3)" : "rgba(255,23,68,0.3)"}`,
|
||||
fontSize:12,
|
||||
}}>
|
||||
{restoreResult.result?.ok
|
||||
? <div style={{color:"#00e676"}}>Restore successful. Safety backup was created first.</div>
|
||||
: <div style={{color:"#ff1744"}}>Restore failed: {restoreResult.result?.error || "Unknown error"}</div>
|
||||
}
|
||||
{restoreResult.config_preview && (
|
||||
<details style={{marginTop:8}}>
|
||||
<summary style={{cursor:"pointer",color:"var(--ac)"}}>Config preview</summary>
|
||||
<pre style={{fontSize:10,maxHeight:300,overflow:"auto",marginTop:4,
|
||||
background:"var(--bg)",padding:8,borderRadius:4}}>
|
||||
{restoreResult.config_preview}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BackupTable device="switch" items={backups.switch} />
|
||||
<BackupTable device="opnsense" items={backups.opnsense} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user