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:
Claude
2026-03-28 00:32:45 +00:00
parent 4eee783af0
commit 7f6fa75aff
2 changed files with 1011 additions and 0 deletions
+483
View File
@@ -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>
);
}