mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-10 13:51:45 +00:00
fix: network graph lag when adding hosts and on every render
This commit is contained in:
1 parent
744510520d
commit
5ff5ae19d9
2 files changed
+98
-28
No files matched your search
@@ -103,7 +103,27 @@ interface NetworkGraphCardProps {
|
||||
|
||||
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
|
||||
|
||||
// Resolving a CSS var forces a style recalc, and node SVGs are costly to
|
||||
// rebuild, so both are cached until the theme changes.
|
||||
const cssVarCache = new Map<string, string>();
|
||||
const nodeSvgCache = new Map<string, string>();
|
||||
|
||||
function clearThemeCaches(): void {
|
||||
cssVarCache.clear();
|
||||
nodeSvgCache.clear();
|
||||
}
|
||||
|
||||
function resolveCssVar(varName: string, fallback: string): string {
|
||||
const key = varName + "|" + fallback;
|
||||
let value = cssVarCache.get(key);
|
||||
if (value === undefined) {
|
||||
value = readCssVar(varName, fallback);
|
||||
cssVarCache.set(key, value);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readCssVar(varName: string, fallback: string): string {
|
||||
const raw = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
@@ -132,6 +152,29 @@ function buildNodeSvg(
|
||||
ip: string,
|
||||
tags: string[],
|
||||
status: string,
|
||||
): string {
|
||||
const key = JSON.stringify([
|
||||
name,
|
||||
ip,
|
||||
tags,
|
||||
status,
|
||||
getHostStatusColorScheme(),
|
||||
window.devicePixelRatio,
|
||||
]);
|
||||
let svg = nodeSvgCache.get(key);
|
||||
if (svg === undefined) {
|
||||
if (nodeSvgCache.size > 1000) nodeSvgCache.clear();
|
||||
svg = renderNodeSvg(name, ip, tags, status);
|
||||
nodeSvgCache.set(key, svg);
|
||||
}
|
||||
return svg;
|
||||
}
|
||||
|
||||
function renderNodeSvg(
|
||||
name: string,
|
||||
ip: string,
|
||||
tags: string[],
|
||||
status: string,
|
||||
): string {
|
||||
const isOnline = status === "online";
|
||||
const isOffline = status === "offline";
|
||||
@@ -201,7 +244,7 @@ function buildNodeSvg(
|
||||
);
|
||||
}
|
||||
|
||||
export function NetworkGraphCard({
|
||||
export const NetworkGraphCard = React.memo(function NetworkGraphCard({
|
||||
embedded = true,
|
||||
onOpenInNewTab,
|
||||
isVisible = true,
|
||||
@@ -217,7 +260,10 @@ export function NetworkGraphCard({
|
||||
// This avoids the "bb is undefined" crash when the card is hidden via display:none.
|
||||
const [containerReady, setContainerReady] = useState(false);
|
||||
|
||||
// What cytoscape is seeded with on load. Edits go straight to cytoscape, and
|
||||
// graphVersion tells the lists below to re-read it.
|
||||
const [elements, setElements] = useState<NetworkElement[]>([]);
|
||||
const [graphVersion, bumpGraphVersion] = useReducer((x: number) => x + 1, 0);
|
||||
const [hostMap, setHostMap] = useState<HostMap>({});
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -252,8 +298,6 @@ export function NetworkGraphCard({
|
||||
type: null,
|
||||
});
|
||||
|
||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerReady) return;
|
||||
const el = cyContainerRef.current;
|
||||
@@ -280,14 +324,19 @@ export function NetworkGraphCard({
|
||||
(liveHosts as HostWithStatus[]).forEach((h) => (newMap[String(h.id)] = h));
|
||||
setHostMap(newMap);
|
||||
|
||||
if (!cyRef.current) return;
|
||||
cyRef.current.nodes().forEach((node) => {
|
||||
if (node.isParent()) return;
|
||||
const h = newMap[node.data("id")];
|
||||
if (h) {
|
||||
node.data("status", h.status ?? "unknown");
|
||||
node.data("tags", h.tags ?? []);
|
||||
}
|
||||
const cy = cyRef.current;
|
||||
if (!cy) return;
|
||||
cy.batch(() => {
|
||||
cy.nodes().forEach((node) => {
|
||||
if (node.isParent()) return;
|
||||
const h = newMap[node.data("id")];
|
||||
if (!h) return;
|
||||
const status = h.status ?? "unknown";
|
||||
const tags = h.tags ?? [];
|
||||
if (node.data("status") !== status) node.data("status", status);
|
||||
if (JSON.stringify(node.data("tags") ?? []) !== JSON.stringify(tags))
|
||||
node.data("tags", tags);
|
||||
});
|
||||
});
|
||||
}, [liveHosts]);
|
||||
|
||||
@@ -303,6 +352,7 @@ export function NetworkGraphCard({
|
||||
document.addEventListener("mousedown", onClickOutside, true);
|
||||
|
||||
const themeObserver = new MutationObserver(() => {
|
||||
clearThemeCaches();
|
||||
if (cyRef.current) applyStyle(cyRef.current);
|
||||
});
|
||||
themeObserver.observe(document.documentElement, {
|
||||
@@ -486,9 +536,13 @@ export function NetworkGraphCard({
|
||||
.update();
|
||||
}, []);
|
||||
|
||||
// react-cytoscapejs calls this after every render, so set up each instance once.
|
||||
const initializedCyRef = useRef<cytoscape.Core | null>(null);
|
||||
const handleNodeInit = useCallback(
|
||||
(cy: cytoscape.Core) => {
|
||||
cyRef.current = cy;
|
||||
if (initializedCyRef.current === cy) return;
|
||||
initializedCyRef.current = cy;
|
||||
if (embedded) {
|
||||
cy.nodes().forEach((n) => {
|
||||
n.ungrabify();
|
||||
@@ -536,6 +590,10 @@ export function NetworkGraphCard({
|
||||
setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)),
|
||||
);
|
||||
cy.on("free", "node", () => !embedded && debouncedSave());
|
||||
cy.on("add remove", () => bumpGraphVersion());
|
||||
cy.on("data", "node", (evt) => {
|
||||
if (evt.target.isParent()) bumpGraphVersion();
|
||||
});
|
||||
cy.on("boxselect", "node", () => {
|
||||
const sel = cy.$("node:selected");
|
||||
if (sel.length === 1) setSelectedNodeId(sel[0].id());
|
||||
@@ -640,11 +698,8 @@ export function NetworkGraphCard({
|
||||
y: 100 + Math.random() * 200,
|
||||
},
|
||||
});
|
||||
applyStyle(cyRef.current);
|
||||
await saveCurrentLayout();
|
||||
setElements([...(cyRef.current.elements().jsons() as NetworkElement[])]);
|
||||
forceUpdate();
|
||||
setShowAddNodeDialog(false);
|
||||
await saveCurrentLayout();
|
||||
} catch {
|
||||
setError(t("networkGraph.failedToAddNode"));
|
||||
}
|
||||
@@ -656,11 +711,9 @@ export function NetworkGraphCard({
|
||||
cyRef.current.add({
|
||||
data: { id: groupId, label: newGroupName, color: newGroupColor },
|
||||
});
|
||||
await saveCurrentLayout();
|
||||
setElements([...(cyRef.current.elements().jsons() as NetworkElement[])]);
|
||||
forceUpdate();
|
||||
setShowAddGroupDialog(false);
|
||||
setNewGroupName("");
|
||||
await saveCurrentLayout();
|
||||
};
|
||||
|
||||
const handleUpdateGroup = async () => {
|
||||
@@ -734,9 +787,16 @@ export function NetworkGraphCard({
|
||||
}
|
||||
};
|
||||
|
||||
// The live graph, re-read whenever cytoscape adds or removes something.
|
||||
const graphElements = useMemo<NetworkElement[]>(() => {
|
||||
const cy = cyRef.current;
|
||||
if (!cy) return elements;
|
||||
return cy.elements().map((el) => ({ data: el.data() })) as NetworkElement[];
|
||||
}, [elements, graphVersion]);
|
||||
|
||||
const availableGroups = useMemo(
|
||||
() =>
|
||||
elements
|
||||
graphElements
|
||||
.filter(
|
||||
(el) =>
|
||||
!el.data.source && !el.data.target && !el.data.ip && el.data.id,
|
||||
@@ -745,26 +805,26 @@ export function NetworkGraphCard({
|
||||
id: el.data.id!,
|
||||
label: el.data.label || el.data.id!,
|
||||
})),
|
||||
[elements],
|
||||
[graphElements],
|
||||
);
|
||||
|
||||
const availableNodesForConnection = useMemo(
|
||||
() =>
|
||||
elements
|
||||
graphElements
|
||||
.filter((el) => !el.data.source && !el.data.target)
|
||||
.map((el) => ({
|
||||
id: el.data.id!,
|
||||
label: el.data.label || el.data.id!,
|
||||
})),
|
||||
[elements],
|
||||
[graphElements],
|
||||
);
|
||||
|
||||
const availableHostsForAdd = useMemo(() => {
|
||||
const hostList = liveHosts as HostWithStatus[];
|
||||
if (!cyRef.current) return hostList;
|
||||
const existing = new Set(elements.map((e) => e.data.id));
|
||||
const existing = new Set(graphElements.map((e) => e.data.id));
|
||||
return hostList.filter((h) => !existing.has(String(h.id)));
|
||||
}, [liveHosts, elements]);
|
||||
}, [liveHosts, graphElements]);
|
||||
|
||||
const btnCls =
|
||||
"h-7 w-7 p-0 rounded-sm border-0 hover:bg-muted/60 transition-colors flex items-center justify-center text-muted-foreground hover:text-foreground";
|
||||
@@ -879,7 +939,7 @@ export function NetworkGraphCard({
|
||||
maxZoom={3}
|
||||
/>
|
||||
)}
|
||||
{!loading && elements.length === 0 && (
|
||||
{!loading && graphElements.length === 0 && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 pointer-events-none">
|
||||
<Network className="size-8 text-muted-foreground/30" />
|
||||
<p className="text-xs text-muted-foreground/50">
|
||||
@@ -1304,7 +1364,7 @@ export function NetworkGraphCard({
|
||||
}
|
||||
|
||||
/* embedded card */
|
||||
const nodeCount = elements.filter((e) => !e.data.source).length;
|
||||
const nodeCount = graphElements.filter((e) => !e.data.source).length;
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0 min-h-0">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0">
|
||||
@@ -1363,4 +1423,4 @@ export function NetworkGraphCard({
|
||||
{dialogs}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Network } from "lucide-react";
|
||||
import type {
|
||||
DashboardCardProps,
|
||||
@@ -13,11 +14,20 @@ function GraphTab({ isVisible }: TabProps) {
|
||||
}
|
||||
|
||||
function GraphCard({ isVisible, shell }: DashboardCardProps) {
|
||||
// A stable callback lets the memoized graph skip dashboard re-renders.
|
||||
const shellRef = useRef(shell);
|
||||
useEffect(() => {
|
||||
shellRef.current = shell;
|
||||
});
|
||||
const openInNewTab = useCallback(
|
||||
() => shellRef.current.openSingletonTab(VIEW_ID),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<NetworkGraphCard
|
||||
embedded={true}
|
||||
isVisible={isVisible}
|
||||
onOpenInNewTab={() => shell.openSingletonTab(VIEW_ID)}
|
||||
onOpenInNewTab={openInNewTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user