import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
Copy,
Cpu,
HardDrive,
MemoryStick,
Plus,
Server,
Settings,
Signal,
Users,
} from "lucide-react";
import { useDeleteNode, useNodeDebugBundle, useNodes } from "@/api/hooks";
import { useRestartCore, useStopCore, useUpdateGeo } from "@/api/policy-hooks";
import type { Node } from "@/api/types";
import { Badge, Button } from "@/components/ui";
import { CreateNodeModal, diagColor, diagLabel, phaseLabel } from "@/components/CreateNodeModal";
import { EditNodeModal } from "@/components/EditNodeModal";
import { NodeInboundsModal } from "@/components/NodeInboundsModal";
import { NodeLogsModal } from "@/components/NodeLogsModal";
import { CoreBadge, GlassCard, StatusBadge } from "@/components/vortexui";
import { useConfirm } from "@/components/confirm";
import { useToast } from "@/components/toast";
import { useI18n } from "@/i18n/i18n";
import { useTitle } from "@/lib/useTitle";
import { useAuth } from "@/auth/auth";
import { EmptyState } from "@/components/EmptyState";
import { cn } from "@/lib/utils";
type FleetFilter = "" | "online" | "warning" | "offline";
const FLEET_FILTERS: { value: FleetFilter; labelKey: "users.filterAll" | "nodes.filterOnline" | "nodes.filterWarning" | "nodes.filterOffline" }[] = [
{ value: "", labelKey: "users.filterAll" },
{ value: "online", labelKey: "nodes.filterOnline" },
{ value: "warning", labelKey: "nodes.filterWarning" },
{ value: "offline", labelKey: "nodes.filterOffline" },
];
function timeAgoShort(iso: string | null): string {
if (!iso) return "—";
const diff = Date.now() - new Date(iso).getTime();
const sec = Math.floor(diff / 1000);
if (sec < 0) return "now";
if (sec < 60) return `${sec}s`;
const min = Math.floor(sec / 60);
if (min < 60) return `${min}m`;
const hrs = Math.floor(min / 60);
if (hrs < 24) return `${hrs}h`;
return `${Math.floor(hrs / 24)}d`;
}
function isNodeOnline(n: Node): boolean {
const lastSeenFresh =
n.last_seen != null && Date.now() - new Date(n.last_seen).getTime() < 90_000;
return lastSeenFresh && n.health.core_running;
}
function nodeDisplayStatus(n: Node): "active" | "warning" | "inactive" {
if (!isNodeOnline(n)) return "inactive";
const load = Math.max(n.health.cpu_percent ?? 0, n.health.mem_percent ?? 0);
if (load > 75) return "warning";
return "active";
}
function statusLabel(status: "active" | "warning" | "inactive", online: boolean): string {
if (!online) return "OFFLINE";
if (status === "warning") return "WARNING";
return "ONLINE";
}
function matchesFleetFilter(n: Node, filter: FleetFilter): boolean {
const st = nodeDisplayStatus(n);
switch (filter) {
case "online":
return st === "active";
case "warning":
return st === "warning";
case "offline":
return st === "inactive";
default:
return true;
}
}
/** Renders a 2-letter ISO country code as a flag emoji via regional indicator
* symbols; falls back to a globe icon when no code is set. */
function flagEmoji(code?: string): string {
if (!code || code.length !== 2) return "🌐";
const cc = code.toUpperCase();
const points = [...cc].map((c) => 127397 + c.charCodeAt(0));
return String.fromCodePoint(...points);
}
function metricTone(v: number): { bar: string; text: string } {
if (v > 85) return { bar: "bg-danger", text: "text-danger" };
if (v > 65) return { bar: "bg-warning", text: "text-warning" };
return { bar: "bg-success", text: "text-success" };
}
function MetricBar({ icon, label, value }: { icon: React.ReactNode; label: string; value: number }) {
const v = Math.min(100, Math.max(0, value || 0));
const tone = metricTone(v);
return (
{icon}
{label}
{v.toFixed(0)}%
);
}
export function Nodes() {
useTitle("Nodes");
const { can } = useAuth();
const canManage = can("node:write");
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [fleetFilter, setFleetFilter] = useState("");
const [menuNodeId, setMenuNodeId] = useState(null);
const menuRef = useRef(null);
useEffect(() => {
if (searchParams.get("tab") === "inbounds") {
navigate("/inbounds", { replace: true });
}
}, [searchParams, navigate]);
const { data, isLoading } = useNodes();
const del = useDeleteNode();
const restart = useRestartCore();
const stop = useStopCore();
const updateGeo = useUpdateGeo();
const debug = useNodeDebugBundle();
const confirm = useConfirm();
const toast = useToast();
const { t } = useI18n();
const [createOpen, setCreateOpen] = useState(false);
const [editing, setEditing] = useState(null);
const [managing, setManaging] = useState(null);
const [logging, setLogging] = useState(null);
const nodes = data?.nodes ?? [];
const filteredNodes = useMemo(
() => nodes.filter((n) => matchesFleetFilter(n, fleetFilter)),
[nodes, fleetFilter],
);
const summary = useMemo(() => {
let active = 0;
let warning = 0;
let offline = 0;
let users = 0;
for (const n of nodes) {
const st = nodeDisplayStatus(n);
if (st === "active") active++;
else if (st === "warning") warning++;
else offline++;
users += n.users_count ?? 0;
}
return { active, warning, offline, users };
}, [nodes]);
useEffect(() => {
function onDocClick(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as globalThis.Node)) {
setMenuNodeId(null);
}
}
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, []);
async function remove(n: Node) {
setMenuNodeId(null);
if (
await confirm({
title: `Delete node ${n.name}?`,
message: "Its inbounds are removed and the agent is deregistered.",
confirmLabel: "Delete",
destructive: true,
})
) {
del
.mutateAsync(n.id)
.then(() => toast.success(`Deleted ${n.name}`))
.catch(() => toast.error("Delete failed"));
}
}
async function doStop(n: Node) {
setMenuNodeId(null);
if (
await confirm({
title: `Stop core on ${n.name}?`,
message: "The proxy engine will shut down. Users on this node will disconnect.",
confirmLabel: "Stop",
destructive: true,
})
) {
stop
.mutateAsync(n.id)
.then(() => toast.success("Core stopped"))
.catch(() => toast.error("Stop failed"));
}
}
async function doUpdateGeo(n: Node) {
setMenuNodeId(null);
if (
await confirm({
title: `Update geo data on ${n.name}?`,
message: "Downloads the latest Iran geoip/geosite databases and restarts the core (brief reconnect).",
confirmLabel: "Update",
})
) {
toast.info("Updating geo data…");
updateGeo
.mutateAsync(n.id)
.then((r) =>
toast.success(`Geo updated (${Math.round((r.geoip_bytes + r.geosite_bytes) / 1024)} KB)`),
)
.catch(() => toast.error("Geo update failed"));
}
}
async function copyDebug(n: Node) {
setMenuNodeId(null);
try {
const res = await debug.mutateAsync(n.id);
await navigator.clipboard.writeText(res.debug_text);
toast.success("Debug bundle copied");
} catch {
toast.error("Could not copy debug bundle");
}
}
return (
setCreateOpen(false)} />
setEditing(null)} />
setManaging(null)} />
setLogging(null)} />
{t("nodes.managementTitle")}
{nodes.length} {t("nodes.registeredNodes")}
Fleet monitoring & management
{canManage && (
)}
{/* Fleet summary — active / warning / offline / total users at a glance */}
{summary.active}
{t("nodes.filterOnline")}
0 ? (summary.active / nodes.length) * 100 : 0}%` }} />
{summary.warning}
{t("nodes.filterWarning")}
0 ? (summary.warning / nodes.length) * 100 : 0}%` }} />
{summary.offline}
{t("nodes.filterOffline")}
0 ? (summary.offline / nodes.length) * 100 : 0}%` }} />
{summary.users}
{t("nodes.users")}
0 ? (summary.users / Math.max(...nodes.map(n => n.users_count ?? 0), 1)) * 100 : 0}%` }} />
{FLEET_FILTERS.map((f) => (
))}
{isLoading && (
{Array.from({ length: 3 }).map((_, i) => (
))}
)}
{!isLoading && filteredNodes.length === 0 && (
setCreateOpen(true) } : undefined}
/>
)}
{!isLoading && filteredNodes.length > 0 && (
{filteredNodes.map((n) => {
const online = isNodeOnline(n);
const status = nodeDisplayStatus(n);
const location = n.location || n.region || n.name;
const isMenuOpen = menuNodeId === n.id;
return (
{flagEmoji(n.country_code)}
{isMenuOpen && (
{ setManaging(n); setMenuNodeId(null); }}>
{t("nodes.inbounds")}
{ setLogging(n); setMenuNodeId(null); }}>
Logs
{!online && (
copyDebug(n)}>
Debug
)}
{canManage && (
<>
{online ? (
doStop(n)}>
Stop
) : (
{
setMenuNodeId(null);
restart
.mutateAsync(n.id)
.then(() => toast.success("Core started"))
.catch(() => toast.error("Start failed"));
}}
>
Start
)}
{
setMenuNodeId(null);
restart
.mutateAsync(n.id)
.then(() => toast.success("Core restarted"))
.catch(() => toast.error("Restart failed"));
}}
>
Restart
doUpdateGeo(n)}>Update Geo
{ setEditing(n); setMenuNodeId(null); }}>
{t("common.edit")}
remove(n)}>
{t("common.delete")}
>
)}
)}
{(n.core_version || n.enrollment_phase) && (
{n.enrollment_phase && n.enrollment_phase !== "synced" && (
{phaseLabel(n.enrollment_phase)}
)}
{!online && n.diagnostics && n.diagnostics.code !== "ok" && (
{diagLabel(n.diagnostics.code)}
)}
)}
} label="CPU" value={n.health.cpu_percent} />
} label="RAM" value={n.health.mem_percent} />
} label="Disk" value={n.health.disk_percent} />
Latency
{n.ping_ms && n.ping_ms > 0 ? `${n.ping_ms}ms` : "—"}
{t("nodes.users")}
{n.users_count ?? 0}
{t("nodes.connections")}
{n.health.connections}
{n.address}
{online ? "· live" : `${t("nodes.lastSeen")} ${timeAgoShort(n.last_seen)}`}
);
})}
)}
{!isLoading && filteredNodes.length > 0 && (
{t("users.showingOf")
.replace("{count}", String(filteredNodes.length))
.replace("{total}", String(nodes.length))}
)}
);
}
function MenuAction({
children,
onClick,
className,
}: {
children: React.ReactNode;
onClick: () => void;
className?: string;
}) {
return (
);
}