Add firewall policy matrix, service proxy, ntfy alerts, and scheduler
Firewall inter-VLAN policy matrix: - Visual VLAN-to-VLAN matrix with click-to-set policies - Policy types: block, allow, one-way, printer, service-ports - Printer template: other VLANs reach ports 9100/631/443/515, printers cannot initiate back — solves the "printer VLAN" use case - Generates both switch ACLs AND OPNsense firewall rules - Preview commands before pushing, auto-backup before changes Service proxy (LAN services via FQDN without inter-VLAN access): - Register services with FQDN + backend URL + allowed VLANs - Deploy generates Caddyfile entries, Unbound DNS overrides, and firewall rules allowing only port 443 to the proxy - Pattern: device on VLAN 30 → DNS resolves to mgmt box → Caddy proxies to actual LAN server — no VLAN-to-VLAN access needed ntfy push notifications: - Configure ntfy.sh or self-hosted ntfy server - Alert events: connectivity lost/restored, PoE budget >85%, backup failures, push failures - Integrated into poll loop — alerts fire on state transitions - Test notification button Scheduled operations: - Cron-like scheduler for automated backups and connectivity checks - Background thread checks every 60 seconds - Per-schedule: name, action, hour, minute, days (mon,wed,fri or *) - Run-now button for manual trigger - ntfy notifications on scheduled task completion/failure https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
This commit is contained in:
+698
-10
@@ -1525,16 +1525,19 @@ 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" },
|
||||
{ id:"cli", label:"Review & Push" },
|
||||
{ id:"devices", label:"Device Access" },
|
||||
{ id:"dhcp", label:"DHCP" },
|
||||
{ id:"dns", label:"DNS Filtering" },
|
||||
{ id:"vpn", label:"VPN" },
|
||||
{ id:"backups", label:"Backups" },
|
||||
{ id:"network", label:"Network" },
|
||||
{ id:"firewall", label:"Firewall" },
|
||||
{ id:"services", label:"Services" },
|
||||
{ id:"ports", label:"Port Map" },
|
||||
{ id:"vlans", label:"VLANs" },
|
||||
{ id:"acls", label:"ACL Builder" },
|
||||
{ id:"cli", label:"Review & Push" },
|
||||
{ id:"devices", label:"Device Access" },
|
||||
{ id:"dhcp", label:"DHCP" },
|
||||
{ id:"dns", label:"DNS Filtering" },
|
||||
{ id:"vpn", label:"VPN" },
|
||||
{ id:"backups", label:"Backups" },
|
||||
{ id:"alerts", label:"Alerts" },
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1607,6 +1610,23 @@ export default function App() {
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
backendOk={pollStatus!=="err"}
|
||||
/>}
|
||||
{tab==="firewall" && <FirewallTab
|
||||
vlans={vlans}
|
||||
session={session}
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
backendOk={pollStatus!=="err"}
|
||||
/>}
|
||||
{tab==="services" && <ServicesTab
|
||||
vlans={vlans}
|
||||
session={session}
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
backendOk={pollStatus!=="err"}
|
||||
/>}
|
||||
{tab==="alerts" && <AlertsTab
|
||||
session={session}
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
backendOk={pollStatus!=="err"}
|
||||
/>}
|
||||
{tab==="vpn" && <WireGuardTab
|
||||
session={session}
|
||||
onNeedAuth={() => setShowTotp(true)}
|
||||
@@ -4748,3 +4768,671 @@ function BackupTab({ session, onNeedAuth, backendOk }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// FIREWALL TAB — inter-VLAN policy matrix
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function FirewallTab({ vlans, session, onNeedAuth, backendOk }) {
|
||||
const [policies, setPolicies] = useState([]);
|
||||
const [presets, setPresets] = useState({});
|
||||
const [form, setForm] = useState({ src_vlan: "", dst_vlan: "", type: "block", ports: "" });
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [pushing, setPushing] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const d = await API("/firewall/policies");
|
||||
setPolicies(d.policies || []);
|
||||
setPresets(d.presets || {});
|
||||
} catch(e) { console.error(e); }
|
||||
};
|
||||
useEffect(() => { if (backendOk) load(); }, [backendOk]);
|
||||
|
||||
const doPreview = async () => {
|
||||
if (!form.src_vlan || !form.dst_vlan || !form.type) return;
|
||||
try {
|
||||
const policy = {
|
||||
src_vlan: parseInt(form.src_vlan), dst_vlan: parseInt(form.dst_vlan),
|
||||
type: form.type,
|
||||
ports: form.ports ? form.ports.split(",").map(p => parseInt(p.trim())).filter(Boolean) : [],
|
||||
};
|
||||
const p = await API("/firewall/preview", { method: "POST", body: { policy } });
|
||||
setPreview(p);
|
||||
} catch(e) { setPreview({ error: e.message }); }
|
||||
};
|
||||
|
||||
const pushPolicy = async () => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
setPushing(true); setResult(null);
|
||||
try {
|
||||
const policy = {
|
||||
src_vlan: parseInt(form.src_vlan), dst_vlan: parseInt(form.dst_vlan),
|
||||
type: form.type,
|
||||
ports: form.ports ? form.ports.split(",").map(p => parseInt(p.trim())).filter(Boolean) : [],
|
||||
};
|
||||
const r = await API("/firewall/push", { method: "POST", body: { token: session.token, policy } });
|
||||
setResult(r);
|
||||
await load();
|
||||
} catch(e) { setResult({ success: false, errors: [e.message] }); }
|
||||
setPushing(false);
|
||||
};
|
||||
|
||||
// Build the VLAN matrix
|
||||
const nonMgmt = vlans.filter(v => v.id !== 99 && v.id !== 1);
|
||||
const getPolicy = (src, dst) => policies.find(p => p.src_vlan === src && p.dst_vlan === dst);
|
||||
|
||||
const policyColor = (type) => ({
|
||||
block: "#ff1744", allow: "#00e676", "one-way": "#2979ff",
|
||||
printer: "#ff6d00", services: "#d500f9",
|
||||
}[type] || "var(--dm)");
|
||||
|
||||
return (
|
||||
<div className="main">
|
||||
<div style={{flex:1}}>
|
||||
<div className="panel">
|
||||
<div className="ph">Inter-VLAN Policy Matrix</div>
|
||||
<div className="pb">
|
||||
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.6}}>
|
||||
Click a cell to set the policy between two VLANs. Policies generate both switch ACLs
|
||||
and OPNsense firewall rules. LAN (VLAN 1) has full access by default.
|
||||
Management VLAN 99 is always isolated (enforced by hard-block).
|
||||
</div>
|
||||
|
||||
{nonMgmt.length > 1 ? (
|
||||
<div style={{overflowX:"auto"}}>
|
||||
<table style={{borderCollapse:"collapse",fontSize:12,width:"100%"}}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{padding:8,textAlign:"left",borderBottom:"1px solid var(--b2)",color:"var(--dm)"}}>
|
||||
From \ To
|
||||
</th>
|
||||
{nonMgmt.map(v => (
|
||||
<th key={v.id} style={{padding:8,textAlign:"center",borderBottom:"1px solid var(--b2)",
|
||||
color:v.color,fontWeight:700,minWidth:80}}>
|
||||
{v.name}<br/><span style={{fontSize:10,color:"var(--dm)"}}>V{v.id}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{nonMgmt.map(src => (
|
||||
<tr key={src.id}>
|
||||
<td style={{padding:8,fontWeight:700,color:src.color,borderBottom:"1px solid var(--b1)"}}>
|
||||
{src.name} <span style={{fontSize:10,color:"var(--dm)"}}>V{src.id}</span>
|
||||
</td>
|
||||
{nonMgmt.map(dst => {
|
||||
if (src.id === dst.id) return (
|
||||
<td key={dst.id} style={{padding:8,textAlign:"center",background:"var(--b1)",
|
||||
borderBottom:"1px solid var(--b1)",color:"var(--dm)",fontSize:10}}>—</td>
|
||||
);
|
||||
const p = getPolicy(src.id, dst.id);
|
||||
return (
|
||||
<td key={dst.id} style={{
|
||||
padding:4,textAlign:"center",borderBottom:"1px solid var(--b1)",
|
||||
cursor:"pointer",
|
||||
}} onClick={() => {
|
||||
setForm(f => ({...f, src_vlan: String(src.id), dst_vlan: String(dst.id)}));
|
||||
setPreview(null); setResult(null);
|
||||
}}>
|
||||
<div style={{
|
||||
padding:"6px 4px",borderRadius:4,fontSize:10,fontWeight:600,
|
||||
background: p ? policyColor(p.type) + "20" : "var(--bg)",
|
||||
border: `1px solid ${p ? policyColor(p.type) + "40" : "var(--b2)"}`,
|
||||
color: p ? policyColor(p.type) : "var(--dm)",
|
||||
}}>
|
||||
{p ? (presets[p.type]?.label || p.type) : "No policy"}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{color:"var(--dm)",fontSize:12,padding:16,textAlign:"center"}}>
|
||||
Create at least 2 non-management VLANs to use the policy matrix.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div style={{display:"flex",gap:12,marginTop:12,flexWrap:"wrap"}}>
|
||||
{Object.entries(presets).map(([k,v]) => (
|
||||
<div key={k} style={{display:"flex",alignItems:"center",gap:4,fontSize:11}}>
|
||||
<span style={{width:10,height:10,borderRadius:2,background:policyColor(k),display:"inline-block"}}/>
|
||||
{v.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Policy Editor */}
|
||||
<div className="panel">
|
||||
<div className="ph">Set Policy</div>
|
||||
<div className="pb">
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr 1fr",gap:12,alignItems:"flex-end"}}>
|
||||
<div className="field"><label>Source VLAN</label>
|
||||
<select value={form.src_vlan} onChange={e => { setForm(f => ({...f, src_vlan: e.target.value})); setPreview(null); }}>
|
||||
<option value="">Select...</option>
|
||||
{vlans.filter(v=>v.id!==99).map(v => <option key={v.id} value={v.id}>{v.name} (V{v.id})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>Destination VLAN</label>
|
||||
<select value={form.dst_vlan} onChange={e => { setForm(f => ({...f, dst_vlan: e.target.value})); setPreview(null); }}>
|
||||
<option value="">Select...</option>
|
||||
{vlans.filter(v=>v.id!==99).map(v => <option key={v.id} value={v.id}>{v.name} (V{v.id})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="field"><label>Policy Type</label>
|
||||
<select value={form.type} onChange={e => setForm(f => ({...f, type: e.target.value}))}>
|
||||
{Object.entries(presets).map(([k,v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{(form.type === "services" || form.type === "printer") && (
|
||||
<div className="field"><label>Ports (comma-separated)</label>
|
||||
<input value={form.ports} onChange={e => setForm(f => ({...f, ports: e.target.value}))}
|
||||
placeholder={form.type === "printer" ? "9100,631,443,515" : "80,443,8080"}/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{form.type && presets[form.type] && (
|
||||
<div style={{marginTop:8,fontSize:12,color:"var(--dm)",fontStyle:"italic"}}>
|
||||
{presets[form.type].description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{display:"flex",gap:8,marginTop:16}}>
|
||||
<button className="btn bd" onClick={doPreview}
|
||||
disabled={!form.src_vlan || !form.dst_vlan}>
|
||||
Preview Commands
|
||||
</button>
|
||||
<button className="btn bp" onClick={pushPolicy}
|
||||
disabled={pushing || !form.src_vlan || !form.dst_vlan}>
|
||||
{pushing ? "Pushing..." : "Push Policy"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{preview && !preview.error && (
|
||||
<div style={{marginTop:12,padding:12,background:"var(--bg)",borderRadius:6,border:"1px solid var(--b2)"}}>
|
||||
<div style={{fontWeight:700,fontSize:12,marginBottom:8,color:"var(--ac)"}}>
|
||||
{preview.description}
|
||||
</div>
|
||||
<div style={{fontSize:11,fontWeight:600,color:"var(--tx)",marginBottom:4}}>Switch ACL Commands:</div>
|
||||
<pre style={{fontSize:11,color:"var(--dm)",margin:0,whiteSpace:"pre-wrap"}}>
|
||||
{preview.switch_cmds?.join("\n")}
|
||||
</pre>
|
||||
{preview.opnsense_rules?.length > 0 && <>
|
||||
<div style={{fontSize:11,fontWeight:600,color:"var(--tx)",marginTop:8,marginBottom:4}}>
|
||||
OPNsense Firewall Rules:
|
||||
</div>
|
||||
{preview.opnsense_rules.map((r,i) => (
|
||||
<div key={i} style={{fontSize:11,color:"var(--dm)"}}>
|
||||
{r.rule.action.toUpperCase()} {r.rule.descr}
|
||||
</div>
|
||||
))}
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div style={{
|
||||
marginTop:12,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,color:result.success?"#00e676":"#ff1744",marginBottom:4}}>
|
||||
{result.success ? "Policy Pushed" : "Push Failed"}
|
||||
</div>
|
||||
{result.steps_done?.map((s,i) => (
|
||||
<div key={i} style={{fontSize:12,color:"var(--tx)"}}><span style={{color:"#00e676"}}>done </span>{s}</div>
|
||||
))}
|
||||
{result.errors?.map((e,i) => (
|
||||
<div key={i} style={{fontSize:12,color:"#ff1744"}}><span>error </span>{e}</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Policies List */}
|
||||
{policies.length > 0 && (
|
||||
<div className="panel">
|
||||
<div className="ph">Active Policies ({policies.length})</div>
|
||||
<div className="pb">
|
||||
<table className="vtbl">
|
||||
<thead><tr><th>Source</th><th>Destination</th><th>Type</th><th>Pushed</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{policies.map((p,i) => (
|
||||
<tr key={i}>
|
||||
<td style={{fontWeight:700}}>{vlans.find(v=>v.id===p.src_vlan)?.name || `V${p.src_vlan}`}</td>
|
||||
<td style={{fontWeight:700}}>{vlans.find(v=>v.id===p.dst_vlan)?.name || `V${p.dst_vlan}`}</td>
|
||||
<td><span style={{color:policyColor(p.type),fontWeight:600}}>{presets[p.type]?.label || p.type}</span></td>
|
||||
<td style={{fontSize:11,color:"var(--dm)"}}>{p.pushed_at || "not pushed"}</td>
|
||||
<td>
|
||||
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}} onClick={async () => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
await API("/firewall/policies", { method:"DELETE", body:{ token:session.token, src_vlan:p.src_vlan, dst_vlan:p.dst_vlan }});
|
||||
load();
|
||||
}}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// SERVICES TAB — expose LAN services to other VLANs via reverse proxy + DNS
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function ServicesTab({ vlans, session, onNeedAuth, backendOk }) {
|
||||
const [services, setServices] = useState([]);
|
||||
const [form, setForm] = useState({ fqdn: "", backend_url: "", description: "", allowed_vlans: [] });
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [deployResult, setDeployResult] = useState(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const d = await API("/services");
|
||||
setServices(d.services || []);
|
||||
} catch(e) { console.error(e); }
|
||||
};
|
||||
useEffect(() => { if (backendOk) load(); }, [backendOk]);
|
||||
|
||||
const addService = async () => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
if (!form.fqdn || !form.backend_url) return;
|
||||
try {
|
||||
await API("/services", { method: "POST", body: { token: session.token, service: form } });
|
||||
setForm({ fqdn: "", backend_url: "", description: "", allowed_vlans: [] });
|
||||
await load();
|
||||
} catch(e) { alert("Save failed: " + e.message); }
|
||||
};
|
||||
|
||||
const removeService = async (fqdn) => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
await API("/services", { method: "DELETE", body: { token: session.token, fqdn } });
|
||||
await load();
|
||||
};
|
||||
|
||||
const deploy = async () => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
setDeploying(true); setDeployResult(null);
|
||||
try {
|
||||
const r = await API("/services/deploy", { method: "POST", body: { token: session.token } });
|
||||
setDeployResult(r);
|
||||
} catch(e) { setDeployResult({ success: false, errors: [e.message] }); }
|
||||
setDeploying(false);
|
||||
};
|
||||
|
||||
const toggleVlan = (vid) => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
allowed_vlans: f.allowed_vlans.includes(vid)
|
||||
? f.allowed_vlans.filter(v => v !== vid)
|
||||
: [...f.allowed_vlans, vid],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main">
|
||||
<div style={{flex:1}}>
|
||||
<div className="panel">
|
||||
<div className="ph">Service Proxy</div>
|
||||
<div className="pb" style={{fontSize:12,color:"var(--dm)",lineHeight:1.8}}>
|
||||
Expose services running on your LAN to other VLANs without opening inter-VLAN access.
|
||||
Each service gets an FQDN (e.g. <span style={{color:"var(--ac)"}}>plex.home.lan</span>) that
|
||||
resolves to the management box. Caddy reverse-proxies the request to the actual server.
|
||||
Only port 443 is opened — no direct VLAN-to-VLAN access needed.
|
||||
<div style={{marginTop:8,padding:8,background:"var(--bg)",borderRadius:4,fontFamily:"monospace",fontSize:11}}>
|
||||
Device on VLAN 30 → DNS: plex.home.lan = mgmt IP → Caddy → LAN server:32400
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Service Form */}
|
||||
<div className="panel">
|
||||
<div className="ph">Add Service</div>
|
||||
<div className="pb">
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:12}}>
|
||||
<div className="field"><label>Service FQDN</label>
|
||||
<input value={form.fqdn} onChange={e => setForm(f => ({...f, fqdn: e.target.value}))}
|
||||
placeholder="plex.home.lan"/>
|
||||
</div>
|
||||
<div className="field"><label>Backend URL (actual server)</label>
|
||||
<input value={form.backend_url} onChange={e => setForm(f => ({...f, backend_url: e.target.value}))}
|
||||
placeholder="http://192.168.1.100:32400"/>
|
||||
</div>
|
||||
<div className="field"><label>Description</label>
|
||||
<input value={form.description} onChange={e => setForm(f => ({...f, description: e.target.value}))}
|
||||
placeholder="Plex Media Server"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{marginTop:12}}>
|
||||
<div className="sect">Allowed VLANs (which VLANs can reach this service)</div>
|
||||
<div style={{display:"flex",gap:8,flexWrap:"wrap"}}>
|
||||
{vlans.filter(v => v.id !== 99 && v.id !== 1).map(v => (
|
||||
<label key={v.id} style={{
|
||||
display:"flex",alignItems:"center",gap:4,cursor:"pointer",
|
||||
padding:"4px 10px",borderRadius:4,fontSize:12,
|
||||
background: form.allowed_vlans.includes(v.id) ? v.color + "20" : "var(--bg)",
|
||||
border: `1px solid ${form.allowed_vlans.includes(v.id) ? v.color : "var(--b2)"}`,
|
||||
}}>
|
||||
<input type="checkbox" checked={form.allowed_vlans.includes(v.id)}
|
||||
onChange={() => toggleVlan(v.id)}/>
|
||||
<span style={{color: v.color, fontWeight:600}}>{v.name}</span>
|
||||
<span style={{color:"var(--dm)",fontSize:10}}>V{v.id}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className="btn bp" onClick={addService} disabled={!form.fqdn || !form.backend_url}
|
||||
style={{marginTop:12}}>
|
||||
Add Service
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service List */}
|
||||
{services.length > 0 && (
|
||||
<div className="panel">
|
||||
<div className="ph">Configured Services ({services.length})</div>
|
||||
<div className="pb">
|
||||
<table className="vtbl">
|
||||
<thead><tr><th>FQDN</th><th>Backend</th><th>Description</th><th>VLANs</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{services.map((s,i) => (
|
||||
<tr key={i}>
|
||||
<td style={{fontWeight:700,color:"var(--ac)",fontFamily:"monospace"}}>{s.fqdn}</td>
|
||||
<td style={{fontFamily:"monospace",fontSize:11}}>{s.backend_url}</td>
|
||||
<td>{s.description || "—"}</td>
|
||||
<td style={{fontSize:11}}>
|
||||
{(s.allowed_vlans || []).map(vid => {
|
||||
const v = vlans.find(x => x.id === vid);
|
||||
return <span key={vid} style={{color:v?.color||"var(--dm)",marginRight:4}}>{v?.name||`V${vid}`}</span>;
|
||||
})}
|
||||
</td>
|
||||
<td>
|
||||
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
||||
onClick={() => removeService(s.fqdn)}>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{marginTop:16,display:"flex",gap:12,alignItems:"center"}}>
|
||||
<button className="btn bp" onClick={deploy} disabled={deploying} style={{padding:"10px 24px"}}>
|
||||
{deploying ? "Deploying..." : "Deploy All Services"}
|
||||
</button>
|
||||
<span style={{fontSize:11,color:"var(--dm)"}}>
|
||||
Writes Caddyfile, pushes DNS overrides to Unbound, adds firewall rules
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{deployResult && (
|
||||
<div style={{
|
||||
marginTop:12,padding:12,borderRadius:6,
|
||||
background: deployResult.success ? "rgba(0,230,118,0.08)" : "rgba(255,23,68,0.08)",
|
||||
border: `1px solid ${deployResult.success ? "rgba(0,230,118,0.3)" : "rgba(255,23,68,0.3)"}`,
|
||||
}}>
|
||||
<div style={{fontWeight:700,color:deployResult.success?"#00e676":"#ff1744",marginBottom:4}}>
|
||||
{deployResult.success ? "Deploy Complete" : "Deploy Had Errors"}
|
||||
</div>
|
||||
{deployResult.steps_done?.map((s,i) => (
|
||||
<div key={i} style={{fontSize:12,color:"var(--tx)"}}><span style={{color:"#00e676"}}>done </span>{s}</div>
|
||||
))}
|
||||
{deployResult.errors?.map((e,i) => (
|
||||
<div key={i} style={{fontSize:12,color:"#ff1744"}}><span>error </span>{e}</div>
|
||||
))}
|
||||
{deployResult.note && (
|
||||
<div style={{marginTop:8,fontSize:11,color:"var(--ac)",fontWeight:600}}>
|
||||
{deployResult.note}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// ALERTS TAB — ntfy configuration + scheduled operations
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
function AlertsTab({ session, onNeedAuth, backendOk }) {
|
||||
const [ntfyCfg, setNtfyCfg] = useState({ url: "https://ntfy.sh", topic: "", enabled: false, events: {} });
|
||||
const [ntfyToken, setNtfyToken] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [schedules, setSchedules] = useState([]);
|
||||
const [schedForm, setSchedForm] = useState({
|
||||
name: "", action: "backup", device: "both", hour: "3", minute: "0", days: "*", enabled: true,
|
||||
});
|
||||
|
||||
const loadNtfy = async () => {
|
||||
try { setNtfyCfg(await API("/alerts/config")); } catch(e) { console.error(e); }
|
||||
};
|
||||
const loadSchedules = async () => {
|
||||
try {
|
||||
const d = await API("/schedules");
|
||||
setSchedules(d.schedules || []);
|
||||
} catch(e) { console.error(e); }
|
||||
};
|
||||
useEffect(() => { if (backendOk) { loadNtfy(); loadSchedules(); } }, [backendOk]);
|
||||
|
||||
const saveNtfy = async () => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await API("/alerts/config", { method: "POST", body: {
|
||||
token: session.token, url: ntfyCfg.url, topic: ntfyCfg.topic,
|
||||
ntfy_token: ntfyToken, enabled: ntfyCfg.enabled, events: ntfyCfg.events,
|
||||
}});
|
||||
await loadNtfy();
|
||||
} catch(e) { alert("Save failed: " + e.message); }
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
const testNtfy = async () => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
setTesting(true);
|
||||
try {
|
||||
await API("/alerts/test", { method: "POST", body: { token: session.token } });
|
||||
alert("Test notification sent! Check your ntfy app/topic.");
|
||||
} catch(e) { alert("Test failed: " + e.message); }
|
||||
setTesting(false);
|
||||
};
|
||||
|
||||
const addSchedule = async () => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
if (!schedForm.name) return;
|
||||
try {
|
||||
await API("/schedules", { method: "POST", body: { token: session.token, schedule: schedForm } });
|
||||
setSchedForm(f => ({...f, name: ""}));
|
||||
await loadSchedules();
|
||||
} catch(e) { alert("Save failed: " + e.message); }
|
||||
};
|
||||
|
||||
const deleteSchedule = async (name) => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
await API("/schedules", { method: "DELETE", body: { token: session.token, name } });
|
||||
await loadSchedules();
|
||||
};
|
||||
|
||||
const runNow = async (name) => {
|
||||
if (!session) { onNeedAuth(); return; }
|
||||
try {
|
||||
await API("/schedules/run-now", { method: "POST", body: { token: session.token, name } });
|
||||
alert(`Schedule "${name}" triggered.`);
|
||||
} catch(e) { alert("Run failed: " + e.message); }
|
||||
};
|
||||
|
||||
const toggleEvent = (key) => {
|
||||
setNtfyCfg(c => ({ ...c, events: { ...c.events, [key]: !c.events[key] } }));
|
||||
};
|
||||
|
||||
const eventLabels = {
|
||||
connectivity_lost: "Switch goes offline / comes back",
|
||||
backup_failed: "Scheduled backup fails",
|
||||
push_failed: "Config push fails",
|
||||
poe_budget_warning: "PoE budget exceeds 85%",
|
||||
port_down: "Port goes down (high volume)",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="main">
|
||||
<div style={{flex:1}}>
|
||||
{/* ntfy Configuration */}
|
||||
<div className="panel">
|
||||
<div className="ph">Push Notifications (ntfy)</div>
|
||||
<div className="pb">
|
||||
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.6}}>
|
||||
Get push notifications on your phone/desktop when network events occur.
|
||||
Works with <span style={{color:"var(--ac)"}}>ntfy.sh</span> (free, no account needed)
|
||||
or a self-hosted ntfy server.
|
||||
</div>
|
||||
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gap:12}}>
|
||||
<div className="field"><label>ntfy Server URL</label>
|
||||
<input value={ntfyCfg.url} onChange={e => setNtfyCfg(c => ({...c, url: e.target.value}))}
|
||||
placeholder="https://ntfy.sh"/>
|
||||
</div>
|
||||
<div className="field"><label>Topic</label>
|
||||
<input value={ntfyCfg.topic} onChange={e => setNtfyCfg(c => ({...c, topic: e.target.value}))}
|
||||
placeholder="my-network-alerts"/>
|
||||
</div>
|
||||
<div className="field"><label>Access Token (optional)</label>
|
||||
<input value={ntfyToken} onChange={e => setNtfyToken(e.target.value)}
|
||||
type="password" placeholder={ntfyCfg.has_token ? "••••••• (saved)" : "for private topics"}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{marginTop:12}}>
|
||||
<div className="sect">Alert Events</div>
|
||||
<div style={{display:"flex",flexDirection:"column",gap:6}}>
|
||||
{Object.entries(eventLabels).map(([k,label]) => (
|
||||
<label key={k} style={{display:"flex",alignItems:"center",gap:8,cursor:"pointer",fontSize:12}}>
|
||||
<input type="checkbox" checked={ntfyCfg.events?.[k] || false}
|
||||
onChange={() => toggleEvent(k)}/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{marginTop:12}}>
|
||||
<label style={{display:"flex",alignItems:"center",gap:8,cursor:"pointer",fontSize:13,fontWeight:600}}>
|
||||
<input type="checkbox" checked={ntfyCfg.enabled}
|
||||
onChange={e => setNtfyCfg(c => ({...c, enabled: e.target.checked}))}/>
|
||||
Enable notifications
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{display:"flex",gap:8,marginTop:16}}>
|
||||
<button className="btn bp" onClick={saveNtfy} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Configuration"}
|
||||
</button>
|
||||
<button className="btn bd" onClick={testNtfy} disabled={testing || !ntfyCfg.topic}>
|
||||
{testing ? "Sending..." : "Send Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scheduled Operations */}
|
||||
<div className="panel">
|
||||
<div className="ph">Scheduled Operations</div>
|
||||
<div className="pb">
|
||||
<div style={{fontSize:12,color:"var(--dm)",marginBottom:12,lineHeight:1.6}}>
|
||||
Schedule recurring tasks like automatic backups or connectivity checks.
|
||||
Tasks run in the background and send ntfy alerts on failure (if configured).
|
||||
</div>
|
||||
|
||||
<div style={{display:"grid",gridTemplateColumns:"1fr 1fr 1fr 80px 80px 1fr",gap:12,alignItems:"flex-end"}}>
|
||||
<div className="field"><label>Name</label>
|
||||
<input value={schedForm.name} onChange={e => setSchedForm(f => ({...f, name: e.target.value}))}
|
||||
placeholder="nightly-backup"/>
|
||||
</div>
|
||||
<div className="field"><label>Action</label>
|
||||
<select value={schedForm.action} onChange={e => setSchedForm(f => ({...f, action: e.target.value}))}>
|
||||
<option value="backup">Backup</option>
|
||||
<option value="connectivity_check">Connectivity Check</option>
|
||||
</select>
|
||||
</div>
|
||||
{schedForm.action === "backup" && (
|
||||
<div className="field"><label>Device</label>
|
||||
<select value={schedForm.device} onChange={e => setSchedForm(f => ({...f, device: e.target.value}))}>
|
||||
<option value="both">Both</option>
|
||||
<option value="switch">Switch only</option>
|
||||
<option value="opnsense">OPNsense only</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="field"><label>Hour</label>
|
||||
<input value={schedForm.hour} onChange={e => setSchedForm(f => ({...f, hour: e.target.value}))}
|
||||
placeholder="3" style={{textAlign:"center"}}/>
|
||||
</div>
|
||||
<div className="field"><label>Minute</label>
|
||||
<input value={schedForm.minute} onChange={e => setSchedForm(f => ({...f, minute: e.target.value}))}
|
||||
placeholder="0" style={{textAlign:"center"}}/>
|
||||
</div>
|
||||
<div className="field"><label>Days (* = every day)</label>
|
||||
<input value={schedForm.days} onChange={e => setSchedForm(f => ({...f, days: e.target.value}))}
|
||||
placeholder="mon,wed,fri or *"/>
|
||||
</div>
|
||||
</div>
|
||||
<button className="btn bp" onClick={addSchedule} disabled={!schedForm.name}
|
||||
style={{marginTop:12}}>Add Schedule</button>
|
||||
|
||||
{schedules.length > 0 && (
|
||||
<div style={{marginTop:16}}>
|
||||
<table className="vtbl">
|
||||
<thead><tr><th>Name</th><th>Action</th><th>Time</th><th>Days</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{schedules.map((s,i) => (
|
||||
<tr key={i}>
|
||||
<td style={{fontWeight:700}}>{s.name}</td>
|
||||
<td>{s.action}{s.device ? ` (${s.device})` : ""}</td>
|
||||
<td style={{fontFamily:"monospace"}}>{s.hour || "*"}:{(s.minute || "0").padStart(2,"0")}</td>
|
||||
<td style={{fontSize:11}}>{s.days || "*"}</td>
|
||||
<td><span style={{color:s.enabled!==false?"#00e676":"var(--dm)",fontSize:11}}>
|
||||
{s.enabled!==false?"active":"disabled"}</span></td>
|
||||
<td style={{display:"flex",gap:4}}>
|
||||
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
||||
onClick={() => runNow(s.name)}>Run Now</button>
|
||||
<button className="btn bd" style={{fontSize:10,padding:"2px 8px"}}
|
||||
onClick={() => deleteSchedule(s.name)}>Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user