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:
Claude
2026-03-28 00:55:29 +00:00
parent 7f6fa75aff
commit 7928f6f769
3 changed files with 1495 additions and 10 deletions
+698 -10
View File
@@ -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>
);
}