// Network detail with per-network tabs const { useState: useStateN, useEffect: useEffectN } = React; function fmtBytes(b) { if (!b || b === 0) return "0 B"; if (b >= 1e9) return (b / 1e9).toFixed(1) + " GB"; if (b >= 1e6) return (b / 1e6).toFixed(1) + " MB"; if (b >= 1e3) return (b / 1e3).toFixed(0) + " KB"; return b + " B"; } function NetworkDetail({ ctx }) { const { t, networkId } = ctx; const [net, setNet] = useStateN(null); const [tab, setTab] = useStateN("devices"); useEffectN(() => { if (!networkId) return; ww.get("/networks/" + networkId).then(setNet).catch(() => {}); }, [networkId]); const role = net ? net.my_role : ""; if (!net) return
Loading...
; const tabs = [ ["devices", t.network.devicesTab], ["topology", t.network.topologyTab], ["performance", t.network.performanceTab], ["netflow", t.network.netflowTab], ["routes", t.network.routesTab], ["acl", t.network.aclTab], ["qos", t.network.qosTab], ["dns", t.network.dnsTab], ["audit", t.network.auditTab], ["billing", t.network.billingTab], ["settings", t.network.settingsTab], ]; return (

{net.name}

{tabs.map(([k, l]) => ( ))}
{tab === "devices" && } {tab === "topology" && } {tab === "performance" && } {tab === "routes" && } {tab === "dns" && } {tab === "acl" && } {tab === "qos" && } {tab === "netflow" && } {tab === "audit" && } {tab === "billing" && } {tab === "settings" && }
); } function DevicesTab({ ctx, role, net }) { const { t, networkId } = ctx; const [devices, setDevices] = useStateN([]); const [pending, setPending] = useStateN([]); const [search, setSearch] = useStateN(""); useEffectN(() => { if (!networkId) return; ww.get("/networks/" + networkId + "/devices").then(setDevices).catch(() => {}); ww.get("/networks/" + networkId + "/pending").then(setPending).catch(() => {}); }, [networkId]); function isOnline(d) { if (!d.last_seen_at) return false; return (Date.now() / 1000 - d.last_seen_at) < 120; } function relTime(ts) { if (!ts) return "—"; const s = Math.floor(Date.now() / 1000 - ts); if (s < 60) return s + "s"; if (s < 3600) return Math.floor(s / 60) + "m"; if (s < 86400) return Math.floor(s / 3600) + "h"; return Math.floor(s / 86400) + "d"; } async function approveDevice(pid) { try { await ww.post("/networks/" + networkId + "/pending/" + pid + "/approve"); setPending(pending.filter(p => p.id !== pid)); ww.get("/networks/" + networkId + "/devices").then(setDevices).catch(() => {}); } catch (err) { alert(err.message); } } async function deleteDevice(did) { if (!confirm("Delete this device?")) return; try { await ww.del("/networks/" + networkId + "/devices/" + did); setDevices(devices.filter(d => d.id !== did)); } catch (err) { alert(err.message); } } const filtered = devices.filter(d => !search || d.instance_name.toLowerCase().includes(search.toLowerCase())); const cols = t.network.deviceColumns; const grid = "1.4fr 1fr 0.8fr 1fr 1fr 1.2fr"; return (
setSearch(e.target.value)} style={{ flex: 1 }}/>
{[...cols, ""].map((c, i) =>
{c}
)}
{pending.map((p) => (
{p.instance_name} {p.platform || "—"} {t.network.pending} {can(role, "edit") && approveDevice(p.id)}>{t.network.approve}}
))} {filtered.map((d) => { const on = isOnline(d); return (
{d.instance_name} {d.ipv4_address || "—"} {d.platform || "—"} {on ? t.common.online : t.common.offline} {relTime(d.last_seen_at)} {can(role, "delete") && deleteDevice(d.id)}>{t.network.revoke}}
); })} {devices.length === 0 && pending.length === 0 && (
No devices yet.
)}
); } function MatrixView({ ctx }) { const { t, networkId } = ctx; const [matrixData, setMatrixData] = useStateN({ nodes: [], edges: [] }); useEffectN(() => { if (!networkId) return; ww.get("/networks/" + networkId + "/matrix").then(d => setMatrixData(d || { nodes: [], edges: [] })).catch(() => {}); }, [networkId]); const names = (matrixData.nodes || []).map(n => n.name || n.instance_name || "?"); const n = names.length; const [sel, setSel] = useStateN(null); // Build edge lookup from real API data. const edgeMap = {}; (matrixData.edges || []).forEach(e => { edgeMap[e.from + "," + e.to] = e; if (!edgeMap[e.to + "," + e.from]) edgeMap[e.to + "," + e.from] = e; }); if (n === 0) return
No devices to display.
; const selPair = sel || [0, Math.min(1, n - 1)]; const cellFor = (i, j) => { if (i === j) return null; const edge = edgeMap[i + "," + j]; if (!edge) return null; return edge.path_type || "unreachable"; }; const edgeFor = (i, j) => edgeMap[i + "," + j] || null; const colorFor = (v) => v === "direct" ? "var(--accent)" : v === "user_relay" ? "color-mix(in oklab, var(--accent) 50%, var(--bg-2))" : v === "managed_relay" ? "var(--warn)" : v === "unreachable" ? "color-mix(in oklab, var(--bad) 30%, var(--bg))" : "transparent"; return (
{names.map((nm, i) => (
{nm}
))} {names.map((nm, i) => (
{nm}
{names.map((_, j) => { const v = cellFor(i, j); const isSel = selPair[0] === i && selPair[1] === j; return (
{[[t.matrix.legend[0], "var(--accent)"], [t.matrix.legend[1], "color-mix(in oklab, var(--accent) 50%, var(--bg-2))"], [t.matrix.legend[2], "var(--warn)"], [t.matrix.legend[3], "color-mix(in oklab, var(--bad) 30%, var(--bg))"]].map(([l, c]) => (
{l}
))}
{t.matrix.selected}
from
{names[selPair[0]]}
to
{names[selPair[1]]}
{(() => { const selEdge = edgeFor(selPair[0], selPair[1]); const rttVal = selEdge && selEdge.rtt_us ? (selEdge.rtt_us / 1000).toFixed(1) + " ms" : "—"; const lossVal = selEdge ? (selEdge.loss_rate * 100).toFixed(2) + " %" : "—"; return [[t.matrix.rtt, rttVal], [t.matrix.loss, lossVal], [t.matrix.path, cellFor(selPair[0], selPair[1]) || "—"]]; })().map(([k, v], i) => (
{k} {v}
))}
{t.matrix.hint}
); } function TopologyTab({ ctx }) { return ; } function TopologyGraph({ ctx }) { const { t, networkId } = ctx; const [devices, setDevices] = useStateN([]); useEffectN(() => { if (!networkId) return; ww.get("/networks/" + networkId + "/devices").then(setDevices).catch(() => {}); }, [networkId]); if (devices.length === 0) { return
No devices in this network.
; } // Auto-layout: arrange nodes in a circle. const cx = 450, cy = 230, radius = 180; const nodes = devices.map((d, i) => { const angle = (2 * Math.PI * i) / devices.length - Math.PI / 2; const online = d.last_seen_at && (Date.now() / 1000 - d.last_seen_at) < 120; return { id: d.instance_name, x: cx + radius * Math.cos(angle), y: cy + radius * Math.sin(angle), offline: !online }; }); const n = (id) => nodes.find(x => x.id === id); return ( {nodes.map(nd => ( {!nd.offline && } {nd.id} ))}
{[["online", "var(--accent)"], ["offline", "var(--ink-3)"]].map(([l, c]) => (
{l}
))}
); } function RoutesTab({ ctx, role }) { const { t, networkId } = ctx; const editable = can(role, "edit"); const [devices, setDevices] = useStateN([]); const [masquerade, setMasquerade] = useStateN([]); useEffectN(() => { if (!networkId) return; ww.get("/networks/" + networkId + "/devices").then(setDevices).catch(() => {}); ww.get("/networks/" + networkId + "/masquerade").then(setMasquerade).catch(() => {}); }, [networkId]); // Aggregate advertised routes from all devices. const advertisements = []; devices.forEach(d => { (d.advertised_routes || []).forEach(cidr => { advertisements.push({ device: d.instance_name, cidr, type: "subnet" }); }); }); return (
{t.network.routesAdvHelp}
{[t.network.routesAdvBy, t.network.routesCidr, t.network.routesAdvType].map((c, i) =>
{c}
)}
{advertisements.map((r, i) => (
{r.device} {r.cidr} {r.type}
))} {advertisements.length === 0 &&
No advertised routes.
}
{t.network.routesMasqHelp}
{[t.network.ruleSrc, t.network.ruleDst, t.network.ruleVia, t.network.ruleNat, ""].map((c, i) =>
{c}
)}
{masquerade.map((r) => (
{r.src_cidr} {r.dst_cidr} {r.via_device_id.substring(0, 8)}... {r.nat_enabled ? "on" : "off"} {editable && { await ww.del("/networks/"+networkId+"/masquerade/"+r.id); setMasquerade(masquerade.filter(x=>x.id!==r.id)); }}>×}
))} {masquerade.length === 0 &&
No masquerade rules.
}
); } function DnsTab({ ctx }) { const { t, networkId } = ctx; const [records, setRecords] = useStateN([]); useEffectN(() => { if (!networkId) return; ww.get("/networks/" + networkId + "/dns").then(setRecords).catch(() => {}); }, [networkId]); return (
{records.map((r, i) => (
= 2 ? "1px solid var(--line-2)" : 0, borderRight: i % 2 === 0 ? "1px solid var(--line-2)" : 0, }}> {r.hostname} {r.ip}
))} {records.length === 0 &&
No DNS records.
}
); } function AclTab({ ctx, role }) { const { t, networkId } = ctx; const editable = can(role, "edit"); const [rules, setRules] = useStateN([]); const [adding, setAdding] = useStateN(false); const [aclForm, setAclForm] = useStateN({ action: "allow", chain: "inbound", source_cidr: "", dest_cidr: "", protocol: "", ports: "", l7_protocol: "", domain_pattern: "", priority: 100 }); useEffectN(() => { if (!networkId) return; ww.get("/networks/" + networkId + "/acl").then(setRules).catch(() => {}); }, [networkId]); async function deleteRule(id) { try { await ww.del("/networks/" + networkId + "/acl/" + id); setRules(rules.filter(r => r.id !== id)); } catch (e) { alert(e.message); } } async function createRule() { try { const r = await ww.post("/networks/" + networkId + "/acl", aclForm); setRules([...rules, r]); setAdding(false); setAclForm({ action: "allow", chain: "inbound", source_cidr: "", dest_cidr: "", protocol: "", ports: "", l7_protocol: "", domain_pattern: "", priority: 100 }); } catch (e) { alert(e.message); } } const grid = "0.5fr 1.2fr 1.5fr 0.7fr 40px"; return ( setAdding(!adding)}>+ {t.network.aclRule}} pad={0}>
{t.network.aclHelp}
{adding && (
Source CIDR
setAclForm({...aclForm, source_cidr: e.target.value})} placeholder="0.0.0.0/0" style={{ width: "100%", fontFamily: "var(--mono)", fontSize: 12 }}/>
Dest CIDR
setAclForm({...aclForm, dest_cidr: e.target.value})} placeholder="0.0.0.0/0" style={{ width: "100%", fontFamily: "var(--mono)", fontSize: 12 }}/>
Protocol
Ports
setAclForm({...aclForm, ports: e.target.value})} placeholder="80,443" style={{ width: "100%", fontFamily: "var(--mono)", fontSize: 12 }}/>
L7 Protocol
Domain Pattern
setAclForm({...aclForm, domain_pattern: e.target.value})} placeholder="*.example.com" style={{ width: "100%", fontFamily: "var(--mono)", fontSize: 12 }}/>
Action
Chain
setAdding(false)}>{t.common.cancel} {t.common.save}
)}
{[t.network.ruleKind, t.network.ruleSrc, t.network.ruleDst, t.network.ruleAction, ""].map((c, i) =>
{c}
)}
{rules.map((r) => { const kind = r.l7_protocol ? "L7" : "L3"; const src = r.source_cidr || "*"; const dst = r.l7_protocol ? (r.domain_pattern || r.l7_protocol) : (r.dest_cidr || "*"); return (
{kind} {src} {dst} {r.action} {editable && deleteRule(r.id)}>×}
); })} {rules.length === 0 &&
No ACL rules.
}
); } function QosTab({ ctx, role }) { const { t, networkId } = ctx; const [rules, setRules] = useStateN([]); const [adding, setAdding] = useStateN(false); const [qosForm, setQosForm] = useStateN({ match_protocol: "", match_src_cidr: "", match_dst_cidr: "", match_ports: "", match_l7: "", match_domain: "", dscp: 0, rate_limit_bps: 0, description: "", priority: 100 }); useEffectN(() => { if (!networkId) return; ww.get("/networks/" + networkId + "/qos").then(setRules).catch(() => {}); }, [networkId]); async function deleteRule(id) { try { await ww.del("/networks/" + networkId + "/qos/" + id); setRules(rules.filter(r => r.id !== id)); } catch (e) { alert(e.message); } } async function createRule() { try { const r = await ww.post("/networks/" + networkId + "/qos", { ...qosForm, dscp: parseInt(qosForm.dscp) || 0, rate_limit_bps: parseInt(qosForm.rate_limit_bps) || 0, priority: parseInt(qosForm.priority) || 100 }); setRules([...rules, r]); setAdding(false); setQosForm({ match_protocol: "", match_src_cidr: "", match_dst_cidr: "", match_ports: "", match_l7: "", match_domain: "", dscp: 0, rate_limit_bps: 0, description: "", priority: 100 }); } catch (e) { alert(e.message); } } function fmtRate(bps) { if (!bps || bps === 0) return "unlimited"; if (bps >= 1e9) return (bps / 1e9).toFixed(1) + " Gbps"; if (bps >= 1e6) return (bps / 1e6).toFixed(0) + " Mbps"; if (bps >= 1e3) return (bps / 1e3).toFixed(0) + " Kbps"; return bps + " bps"; } function dscpName(v) { const names = { 0: "BE", 46: "EF", 26: "AF31", 34: "AF41", 8: "CS1", 16: "CS2", 24: "CS3", 32: "CS4", 40: "CS5", 48: "CS6" }; return names[v] || ("DSCP " + v); } const prioForDscp = (v) => v >= 40 ? "high" : v >= 16 ? "mid" : "low"; const prioTone = { high: "accent", mid: "ghost", low: "warn" }; return ( setAdding(!adding)}>+ {t.network.qosAdd}} pad={0}>
{t.network.qosHelp}
{adding && (
Description
setQosForm({...qosForm, description: e.target.value})} placeholder="Rule name" style={{ width: "100%", fontSize: 12 }}/>
Match Protocol
DSCP (0-63)
setQosForm({...qosForm, dscp: e.target.value})} style={{ width: "100%", fontFamily: "var(--mono)", fontSize: 12 }}/>
Rate Limit (bps)
setQosForm({...qosForm, rate_limit_bps: e.target.value})} placeholder="0 = unlimited" style={{ width: "100%", fontFamily: "var(--mono)", fontSize: 12 }}/>
Src CIDR
setQosForm({...qosForm, match_src_cidr: e.target.value})} placeholder="0.0.0.0/0" style={{ width: "100%", fontFamily: "var(--mono)", fontSize: 12 }}/>
Dst CIDR
setQosForm({...qosForm, match_dst_cidr: e.target.value})} placeholder="0.0.0.0/0" style={{ width: "100%", fontFamily: "var(--mono)", fontSize: 12 }}/>
L7 Protocol
Domain
setQosForm({...qosForm, match_domain: e.target.value})} placeholder="*.example.com" style={{ width: "100%", fontFamily: "var(--mono)", fontSize: 12 }}/>
setAdding(false)}>{t.common.cancel} {t.common.save}
)}
{[t.network.qosMatch, t.network.qosDscp, t.network.qosRate, t.network.qosPriority, ""].map((c, i) =>
{c}
)}
{rules.map((r) => { const match = [r.match_src_cidr, r.match_dst_cidr, r.match_l7, r.match_domain].filter(Boolean).join(" → ") || r.description || "*"; const prio = prioForDscp(r.dscp); return (
{match} {dscpName(r.dscp)} {fmtRate(r.rate_limit_bps)} {prio} {can(role, "edit") && deleteRule(r.id)}>×}
); })} {rules.length === 0 &&
No QoS rules.
}
); } function NetFlowTab({ ctx }) { const { t, networkId } = ctx; const [flows, setFlows] = useStateN([]); const [hourly, setHourly] = useStateN([]); const [flowSearch, setFlowSearch] = useStateN(""); const [flowProto, setFlowProto] = useStateN(""); const [flowPeer, setFlowPeer] = useStateN(""); useEffectN(() => { if (!networkId) return; ww.get("/networks/" + networkId + "/flows").then(setFlows).catch(() => {}); ww.get("/networks/" + networkId + "/stats/hourly").then(setHourly).catch(() => {}); }, [networkId]); // Compute throughput chart data from hourly stats. const data = hourly.length > 0 ? hourly.slice(-60).map(r => (r.total_tx_bytes + r.total_rx_bytes) / 1e6) : Array.from({ length: 60 }, () => 0); const max = Math.max(1, ...data); const filteredFlows = flows.filter(f => { if (flowSearch && !(`${f.src_ip} ${f.dst_ip}`).toLowerCase().includes(flowSearch.toLowerCase())) return false; if (flowProto && f.protocol !== flowProto) return false; if (flowPeer && f.src_ip !== flowPeer && f.dst_ip !== flowPeer) return false; return true; }); const peers = [...new Set(flows.flatMap(f => [f.src_ip, f.dst_ip]))].sort(); const top = filteredFlows.map(f => [ `${f.src_ip} → ${f.dst_ip}`, `${f.protocol}/${f.dst_port}`, fmtBytes(f.tx_bytes + f.rx_bytes), "", ]); return (
{[0, 25, 50, 75].map(y => )} `${i * 10},${180 - (v / max) * 160}`).join(" ")} fill="none" stroke="var(--accent)" strokeWidth="1.5"/> `${i * 10},${180 - (v / max) * 160}`).join(" ")} 600,180`} fill="var(--accent-soft)" opacity="0.5"/>
{(() => { if (flows.length === 0) return
No flow data.
; const totalBytes = flows.reduce((s, f) => s + (f.tx_bytes || 0) + (f.rx_bytes || 0), 0) || 1; const buckets = {}; flows.forEach(f => { const key = f.protocol + "/" + f.dst_port; buckets[key] = (buckets[key] || 0) + (f.tx_bytes || 0) + (f.rx_bytes || 0); }); const sorted = Object.entries(buckets).sort((a, b) => b[1] - a[1]).slice(0, 5); const colors = ["var(--accent)", "var(--ink-2)", "var(--warn)", "var(--ink-3)", "var(--bad)"]; return sorted.map(([label, bytes], i) => { const pct = Math.round(bytes / totalBytes * 100); return (
{label}{pct}%
); }); })()}
setFlowSearch(e.target.value)} style={{ flex: 1 }}/>
{[t.network.netflowPair, t.network.netflowProtoCol, t.network.netflowBytes].map(c =>
{c}
)}
{top.map((r, i) => (
{r[0]} {r[1]} {r[2]}
))} {top.length === 0 &&
No flows recorded.
}
); } function InstallCard({ ctx, net }) { const { t } = ctx; const code = net.config_code || ""; const platforms = [ ["linux", "Linux", `ww connect ${code}`], ["macos", "macOS", `ww connect ${code}`], ["windows", "Windows", `ww.exe connect ${code}`], ]; const [plat, setPlat] = useStateN("linux"); const cmd = platforms.find(p => p[0] === plat)?.[2] || ""; if (!code) return null; return (
{platforms.map(([k, l]) => ( ))}
$ {cmd}
{ navigator.clipboard.writeText(cmd); }}>Copy
); } function NetAuditTab({ ctx, net }) { const { t, networkId } = ctx; const [events, setEvents] = useStateN([]); const [search, setSearch] = useStateN(""); const [filter, setFilter] = useStateN(""); useEffectN(() => { if (!networkId) return; ww.get("/networks/" + networkId + "/timeline").then(setEvents).catch(() => {}); }, [networkId]); function fmtTs(ts) { return new Date(ts * 1000).toISOString().replace("T", " ").slice(0, 19); } const filtered = events.filter(e => { if (search && !JSON.stringify(e).toLowerCase().includes(search.toLowerCase())) return false; if (filter && !e.event_type.startsWith(filter)) return false; return true; }); return (
setSearch(e.target.value)} style={{ flex: 1 }}/>
{[t.network.auditTimestamp, t.network.auditAction, t.network.auditSubject, t.network.auditActor, ""].map((c, i) =>
{c}
)}
{filtered.map((e) => (
{fmtTs(e.ts)} {e.event_type} {e.device_name || e.detail || "—"} {e.actor || "—"} {e.detail || ""}
))} {filtered.length === 0 &&
No events.
}
); } function NetBillingTab({ ctx, net, role }) { const { t, networkId } = ctx; const editable = can(role, "billing"); const [orders, setOrders] = useStateN([]); useEffectN(() => { if (!networkId) return; ww.get("/networks/" + networkId + "/billing/orders").then(setOrders).catch(() => {}); }, [networkId]); const plans = [ { id: "free", price: "$0", priceCny: "¥0", blurb: t.billing.freeBlurb }, { id: "pro", price: "$36", priceCny: "¥99", blurb: t.billing.proBlurb }, { id: "enterprise", price: "$108", priceCny: "¥299", blurb: t.billing.entBlurb }, ]; async function handleUpgrade(plan) { try { const res = await ww.post("/networks/" + networkId + "/billing/stripe", { plan, period: "monthly" }); if (res.checkout_url) window.location.href = res.checkout_url; } catch (e) { alert(e.message); } } return (
{plans.map(p => { const active = p.id === (net.plan || "free"); return (
{t.plans[p.id]}
{active && {t.billing.current}}
{p.price} / mo
{p.blurb}
{!active && editable && p.id !== "free" && handleUpgrade(p.id)}>{t.common.upgrade} →}
); })}
{[t.billing.invoiceDate, "Plan", "Period", t.billing.invoiceAmount, t.billing.invoiceStatus].map((c, i) =>
{c}
)}
{orders.map((o) => (
{new Date((o.paid_at || o.created_at) * 1000).toISOString().slice(0, 10)} {o.plan} {o.period} {o.currency === "cny" ? "¥" : "$"}{(o.amount / 100).toFixed(2)} {o.status}
))} {orders.length === 0 &&
No orders yet.
}
); } function NetSettingsTab({ ctx, role, net }) { const { t, networkId } = ctx; const editable = can(role, "edit"); const canInvite = can(role, "invite"); const cidr = net.ipv4_cidr || "10.0.0.0/24"; const [inviteOpen, setInviteOpen] = useStateN(false); const [invEmail, setInvEmail] = useStateN(""); const [invRole, setInvRole] = useStateN("editor"); const [members, setMembers] = useStateN([]); const memberRoles = [["editor", t.roles.editor], ["reader", t.roles.reader]]; async function sendInvite() { if (!invEmail.trim()) return; try { await ww.post("/networks/" + networkId + "/members", { email: invEmail.trim(), role: invRole }); setInvEmail(""); setInviteOpen(false); ww.get("/networks/" + networkId + "/members").then(setMembers).catch(() => {}); } catch (e) { alert(e.message); } } const [a, b] = cidr.split("/"); const hostBits = 32 - parseInt(b || "24", 10); const totalHosts = Math.max(0, (1 << hostBits) - 2); const used = net.device_count || 0; useEffectN(() => { if (!networkId) return; ww.get("/networks/" + networkId + "/members").then(setMembers).catch(() => {}); ww.get("/tokens").then(setTokens).catch(() => {}); }, [networkId]); async function createToken() { if (!tokenForm.name.trim()) return; try { const body = { name: tokenForm.name.trim(), scope: tokenForm.scope }; if (tokenForm.expires_days) body.expires_days = parseInt(tokenForm.expires_days); const r = await ww.post("/tokens", body); setNewTokenValue(r.token); setTokens([r, ...tokens]); setTokenForm({ name: "", scope: "readonly", expires_days: null }); setAddingToken(false); } catch (e) { alert(e.message); } } async function revokeToken(id) { if (!confirm("Revoke this token? This cannot be undone.")) return; try { await ww.del("/tokens/" + id); setTokens(tokens.filter(tk => tk.id !== id)); } catch (e) { alert(e.message); } } const [tokens, setTokens] = useStateN([]); const [addingToken, setAddingToken] = useStateN(false); const [tokenForm, setTokenForm] = useStateN({ name: "", scope: "readonly", expires_days: null }); const [newTokenValue, setNewTokenValue] = useStateN(""); const [netName, setNetName] = useStateN(net.name); const [saving, setSaving] = useStateN(false); async function saveName() { setSaving(true); try { await ww.put("/networks/" + networkId, { name: netName }); } catch (e) { alert(e.message); } setSaving(false); } async function deleteNetwork() { if (!confirm("Delete this network and all devices? This cannot be undone.")) return; try { await ww.del("/networks/" + networkId); window.location.href = "app.html"; } catch (e) { alert(e.message); } } return (
{t.networks.name}
setNetName(e.target.value)} disabled={!editable} style={{ width: "100%" }}/>
{editable && netName !== net.name && {t.common.save}}
{t.network.addressHelp}
{t.networks.cidr}
{[ [t.network.addressNetwork, `${a || "—"} / ${b || "24"}`], [t.network.addressUsable, `${totalHosts.toLocaleString()} ${t.network.addressHosts}`], [t.network.addressUsed, `${used} (${Math.round(used / Math.max(totalHosts, 1) * 100)}%)`], ].map(([l, v], i) => (
{l}
{v}
))}
setInviteOpen(true)}>{t.members.invite}} pad={0}>
{t.network.membersHelp}
{inviteOpen && (
{t.members.inviteEmail}
setInvEmail(e.target.value)} placeholder="user@company.com" style={{ width: "100%" }}/>
{t.members.inviteRole}
setInviteOpen(false)}>{t.common.cancel} {t.members.sendInvite}
)}
{[t.members.member, t.members.role, t.members.joined, t.members.lastSeen, ""].map((c, i) =>
{c}
)}
{members.map((m, i) => (
{m.email.split("@")[0]}
{m.email}
{t.roles[m.role] || m.role}
{m.joined_at ? new Date(m.joined_at * 1000).toISOString().slice(0, 10) : "—"}
{canInvite && m.role !== "owner" && }
))}
setAddingToken(!addingToken)}>+ {t.tokens.add}} pad={0}>
{t.tokens.networkHint}
{addingToken && (
Name
setTokenForm({...tokenForm, name: e.target.value})} placeholder="My token" style={{ width: "100%" }}/>
Scope
Expires (days)
setTokenForm({...tokenForm, expires_days: e.target.value || null})} placeholder="never" style={{ width: "100%" }}/>
setAddingToken(false)}>{t.common.cancel} {t.common.save}
)} {newTokenValue && (
New token: {newTokenValue} { navigator.clipboard.writeText(newTokenValue); }}>Copy setNewTokenValue("")}>Dismiss
)}
{[t.tokens.colName, t.tokens.colToken, t.tokens.colScopes, t.tokens.colCreated, t.tokens.colExpires, ""].map((c, i) =>
{c}
)}
{tokens.map((tk) => (
{tk.name} {tk.token_prefix || "ww_***"} {tk.scope} {tk.created_at ? new Date(tk.created_at * 1000).toISOString().slice(0, 10) : "—"} {tk.expires_at ? new Date(tk.expires_at * 1000).toISOString().slice(0, 10) : "never"} {can(role, "delete") && revokeToken(tk.id)}>{t.tokens.revoke}}
))} {tokens.length === 0 && !addingToken &&
No API tokens.
}
{can(role, "delete") && (

{t.network.deleteBody}

{t.common.delete}
)}
); } Object.assign(window, { NetworkDetail });