forked from enderofwings/NexusOS
Brings the public tree back in line with the development repo after several weeks of drift caused by a stale publish include list. New: - In-app update path: GET /update/check compares the checkout against origin/main and POST /update/apply runs `ncp upgrade` detached (pull, rebuild, restart). The sidebar shows the version, checks on click, and offers an "update available" pill. - Projects: a project workspace groups chats and RAG documents, with per-project instructions and document retrieval scoped to the active project. Replaces the standalone Documents page. - modules/: auto-discovered feature plugins (mail, network) with their frontend counterparts and tests. - Memory curation runs in-process (synapse/memory/curator.py) on the chat model when a conversation goes idle. The separate memory service on :8001 is gone, along with the launcher lines that started it. Also: the KDE theme, panel and Promethean terminal assets, the full test suite, and VERSION 1.2.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
160 lines
7.5 KiB
React
160 lines
7.5 KiB
React
import { useEffect, useState } from "react";
|
|
import { API_BASE } from "../../config";
|
|
|
|
const inputStyle = { padding: "0.6rem", background: "#222", color: "#eee", border: "1px solid #333", borderRadius: "8px", boxSizing: "border-box" };
|
|
const btn = (bg) => ({ padding: "0.5rem 1rem", background: bg, color: "#fff", border: "none", borderRadius: "8px", cursor: "pointer" });
|
|
const sectionStyle = { background: "#161616", border: "1px solid #333", borderRadius: "12px", padding: "1.25rem", marginBottom: "1rem" };
|
|
|
|
const dot = (ok) => ({
|
|
display: "inline-block", width: "8px", height: "8px", borderRadius: "50%", flexShrink: 0,
|
|
background: ok === null ? "#555" : ok ? "#4caf50" : "#f44336",
|
|
});
|
|
|
|
export function Network() {
|
|
const [status, setStatus] = useState(null); // {hostname, connection, vpn}
|
|
const [targets, setTargets] = useState(null); // [{id,label,host,ok,latency_ms}]
|
|
const [newLabel, setNewLabel] = useState("");
|
|
const [newHost, setNewHost] = useState("");
|
|
const [busy, setBusy] = useState(false);
|
|
const [vpnBusy, setVpnBusy] = useState(false);
|
|
const [note, setNote] = useState("");
|
|
|
|
const loadStatus = async () => {
|
|
const r = await fetch(`${API_BASE}/network/status`);
|
|
if (r.ok) setStatus(await r.json());
|
|
};
|
|
|
|
const loadAndPingTargets = async () => {
|
|
const r = await fetch(`${API_BASE}/network/ping`);
|
|
setTargets(r.ok ? (await r.json()).targets || [] : []);
|
|
};
|
|
|
|
const refresh = async () => {
|
|
setBusy(true);
|
|
try { await Promise.all([loadStatus(), loadAndPingTargets()]); }
|
|
finally { setBusy(false); }
|
|
};
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
useEffect(() => { refresh(); }, []);
|
|
|
|
const toggleVpn = async () => {
|
|
if (!status || !status.vpn.configured) return;
|
|
setVpnBusy(true); setNote("");
|
|
try {
|
|
const r = await fetch(`${API_BASE}/network/vpn/toggle`, {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enable: !status.vpn.connected }),
|
|
});
|
|
const d = await r.json().catch(() => ({}));
|
|
if (r.ok) setStatus(s => ({ ...s, vpn: d }));
|
|
else setNote(d.detail || "VPN toggle failed.");
|
|
} finally { setVpnBusy(false); }
|
|
};
|
|
|
|
const addTarget = async () => {
|
|
if (!newHost.trim()) return;
|
|
setBusy(true);
|
|
try {
|
|
const r = await fetch(`${API_BASE}/network/targets`, {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ label: newLabel.trim(), host: newHost.trim() }),
|
|
});
|
|
if (r.ok) { setNewLabel(""); setNewHost(""); await loadAndPingTargets(); }
|
|
} finally { setBusy(false); }
|
|
};
|
|
|
|
const removeTarget = async (id) => {
|
|
setBusy(true);
|
|
try {
|
|
await fetch(`${API_BASE}/network/targets/${id}`, { method: "DELETE" });
|
|
await loadAndPingTargets();
|
|
} finally { setBusy(false); }
|
|
};
|
|
|
|
if (status === null) return <div style={{ padding: "1.5rem", color: "#888" }}>Loading…</div>;
|
|
|
|
const conn = status.connection;
|
|
const vpn = status.vpn;
|
|
|
|
return (
|
|
<div style={{ width: "100%" }}>
|
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.25rem" }}>
|
|
<h2 style={{ margin: 0, fontSize: "1.1rem", color: "#eee" }}>📡 Network</h2>
|
|
<button onClick={refresh} disabled={busy} style={{ ...btn("transparent"), border: "1px solid #444", color: "#ccc" }}>
|
|
{busy ? "Refreshing…" : "⟳ Refresh"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Connection */}
|
|
<div style={sectionStyle}>
|
|
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>Connection</h3>
|
|
<div style={{ display: "flex", gap: "2rem", flexWrap: "wrap", fontSize: "0.88rem" }}>
|
|
<div><span style={{ color: "#666" }}>Host</span><div style={{ color: "#eee" }}>{status.hostname}</div></div>
|
|
<div><span style={{ color: "#666" }}>Type</span><div style={{ color: "#eee", textTransform: "capitalize" }}>{conn.type}</div></div>
|
|
<div><span style={{ color: "#666" }}>Interface</span><div style={{ color: "#eee" }}>{conn.interface || "—"}</div></div>
|
|
<div><span style={{ color: "#666" }}>IP</span><div style={{ color: "#eee" }}>{conn.ip || "—"}</div></div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* VPN */}
|
|
{vpn.available && (
|
|
<div style={sectionStyle}>
|
|
<h3 style={{ margin: "0 0 0.75rem", fontSize: "0.95rem", color: "#bbb" }}>WireGuard VPN</h3>
|
|
{!vpn.configured ? (
|
|
<p style={{ margin: 0, fontSize: "0.85rem", color: "#666" }}>No WireGuard tunnel configured in NetworkManager.</p>
|
|
) : (
|
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
<span style={dot(vpn.connected)} />
|
|
<span style={{ fontSize: "0.88rem", color: "#ccc" }}>{vpn.name}</span>
|
|
<span style={{ fontSize: "0.82rem", color: vpn.connected ? "#4caf50" : "#888" }}>
|
|
{vpn.connected ? "Connected" : "Disconnected"}
|
|
</span>
|
|
<button onClick={toggleVpn} disabled={vpnBusy}
|
|
style={{ ...btn(vpn.connected ? "#2a1a1a" : "#152a15"), color: vpn.connected ? "#ff8a80" : "#8aff8a", marginLeft: "auto" }}>
|
|
{vpnBusy ? "Working…" : vpn.connected ? "Disconnect" : "Connect"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{note && <p style={{ margin: "0.5rem 0 0", color: "#f44336", fontSize: "0.82rem" }}>{note}</p>}
|
|
</div>
|
|
)}
|
|
|
|
{/* Ping targets */}
|
|
<div style={sectionStyle}>
|
|
<h3 style={{ margin: "0 0 0.5rem", fontSize: "0.95rem", color: "#bbb" }}>Ping targets</h3>
|
|
<p style={{ margin: "0 0 1rem", fontSize: "0.78rem", color: "#555" }}>
|
|
Hosts to check reachability for — a router, a VPN endpoint, anything on your network.
|
|
</p>
|
|
|
|
{(targets || []).length === 0 ? (
|
|
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "1rem" }}>No targets yet.</div>
|
|
) : (
|
|
<div style={{ marginBottom: "1rem" }}>
|
|
{targets.map(t => (
|
|
<div key={t.id} style={{ display: "flex", alignItems: "center", gap: "0.6rem", padding: "0.45rem 0", borderBottom: "1px solid #222" }}>
|
|
<span style={dot(t.ok)} />
|
|
<span style={{ fontSize: "0.88rem", color: "#eee", minWidth: "120px" }}>{t.label}</span>
|
|
<span style={{ fontSize: "0.82rem", color: "#888" }}>{t.host}</span>
|
|
<span style={{ fontSize: "0.8rem", color: "#666", marginLeft: "auto" }}>
|
|
{t.ok ? (t.latency_ms != null ? `${t.latency_ms.toFixed(0)} ms` : "reachable") : t.ok === false ? "unreachable" : ""}
|
|
</span>
|
|
<button onClick={() => removeTarget(t.id)} disabled={busy}
|
|
style={{ background: "transparent", border: "none", color: "#888", cursor: "pointer", fontSize: "0.9rem", padding: "0 4px" }}>✕</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
|
|
<input placeholder="Label (optional)" value={newLabel} onChange={e => setNewLabel(e.target.value)}
|
|
style={{ ...inputStyle, width: "160px" }} />
|
|
<input placeholder="Host or IP" value={newHost} onChange={e => setNewHost(e.target.value)}
|
|
onKeyDown={e => e.key === "Enter" && addTarget()} style={{ ...inputStyle, width: "200px" }} />
|
|
<button onClick={addTarget} disabled={busy || !newHost.trim()} style={btn(!newHost.trim() ? "#555" : "#007acc")}>+ Add</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|