mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +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;
|
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 {
|
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)
|
const raw = getComputedStyle(document.documentElement)
|
||||||
.getPropertyValue(varName)
|
.getPropertyValue(varName)
|
||||||
.trim();
|
.trim();
|
||||||
@@ -132,6 +152,29 @@ function buildNodeSvg(
|
|||||||
ip: string,
|
ip: string,
|
||||||
tags: string[],
|
tags: string[],
|
||||||
status: 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 {
|
): string {
|
||||||
const isOnline = status === "online";
|
const isOnline = status === "online";
|
||||||
const isOffline = status === "offline";
|
const isOffline = status === "offline";
|
||||||
@@ -201,7 +244,7 @@ function buildNodeSvg(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkGraphCard({
|
export const NetworkGraphCard = React.memo(function NetworkGraphCard({
|
||||||
embedded = true,
|
embedded = true,
|
||||||
onOpenInNewTab,
|
onOpenInNewTab,
|
||||||
isVisible = true,
|
isVisible = true,
|
||||||
@@ -217,7 +260,10 @@ export function NetworkGraphCard({
|
|||||||
// This avoids the "bb is undefined" crash when the card is hidden via display:none.
|
// This avoids the "bb is undefined" crash when the card is hidden via display:none.
|
||||||
const [containerReady, setContainerReady] = useState(false);
|
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 [elements, setElements] = useState<NetworkElement[]>([]);
|
||||||
|
const [graphVersion, bumpGraphVersion] = useReducer((x: number) => x + 1, 0);
|
||||||
const [hostMap, setHostMap] = useState<HostMap>({});
|
const [hostMap, setHostMap] = useState<HostMap>({});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -252,8 +298,6 @@ export function NetworkGraphCard({
|
|||||||
type: null,
|
type: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (containerReady) return;
|
if (containerReady) return;
|
||||||
const el = cyContainerRef.current;
|
const el = cyContainerRef.current;
|
||||||
@@ -280,14 +324,19 @@ export function NetworkGraphCard({
|
|||||||
(liveHosts as HostWithStatus[]).forEach((h) => (newMap[String(h.id)] = h));
|
(liveHosts as HostWithStatus[]).forEach((h) => (newMap[String(h.id)] = h));
|
||||||
setHostMap(newMap);
|
setHostMap(newMap);
|
||||||
|
|
||||||
if (!cyRef.current) return;
|
const cy = cyRef.current;
|
||||||
cyRef.current.nodes().forEach((node) => {
|
if (!cy) return;
|
||||||
if (node.isParent()) return;
|
cy.batch(() => {
|
||||||
const h = newMap[node.data("id")];
|
cy.nodes().forEach((node) => {
|
||||||
if (h) {
|
if (node.isParent()) return;
|
||||||
node.data("status", h.status ?? "unknown");
|
const h = newMap[node.data("id")];
|
||||||
node.data("tags", h.tags ?? []);
|
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]);
|
}, [liveHosts]);
|
||||||
|
|
||||||
@@ -303,6 +352,7 @@ export function NetworkGraphCard({
|
|||||||
document.addEventListener("mousedown", onClickOutside, true);
|
document.addEventListener("mousedown", onClickOutside, true);
|
||||||
|
|
||||||
const themeObserver = new MutationObserver(() => {
|
const themeObserver = new MutationObserver(() => {
|
||||||
|
clearThemeCaches();
|
||||||
if (cyRef.current) applyStyle(cyRef.current);
|
if (cyRef.current) applyStyle(cyRef.current);
|
||||||
});
|
});
|
||||||
themeObserver.observe(document.documentElement, {
|
themeObserver.observe(document.documentElement, {
|
||||||
@@ -486,9 +536,13 @@ export function NetworkGraphCard({
|
|||||||
.update();
|
.update();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// react-cytoscapejs calls this after every render, so set up each instance once.
|
||||||
|
const initializedCyRef = useRef<cytoscape.Core | null>(null);
|
||||||
const handleNodeInit = useCallback(
|
const handleNodeInit = useCallback(
|
||||||
(cy: cytoscape.Core) => {
|
(cy: cytoscape.Core) => {
|
||||||
cyRef.current = cy;
|
cyRef.current = cy;
|
||||||
|
if (initializedCyRef.current === cy) return;
|
||||||
|
initializedCyRef.current = cy;
|
||||||
if (embedded) {
|
if (embedded) {
|
||||||
cy.nodes().forEach((n) => {
|
cy.nodes().forEach((n) => {
|
||||||
n.ungrabify();
|
n.ungrabify();
|
||||||
@@ -536,6 +590,10 @@ export function NetworkGraphCard({
|
|||||||
setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)),
|
setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)),
|
||||||
);
|
);
|
||||||
cy.on("free", "node", () => !embedded && debouncedSave());
|
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", () => {
|
cy.on("boxselect", "node", () => {
|
||||||
const sel = cy.$("node:selected");
|
const sel = cy.$("node:selected");
|
||||||
if (sel.length === 1) setSelectedNodeId(sel[0].id());
|
if (sel.length === 1) setSelectedNodeId(sel[0].id());
|
||||||
@@ -640,11 +698,8 @@ export function NetworkGraphCard({
|
|||||||
y: 100 + Math.random() * 200,
|
y: 100 + Math.random() * 200,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
applyStyle(cyRef.current);
|
|
||||||
await saveCurrentLayout();
|
|
||||||
setElements([...(cyRef.current.elements().jsons() as NetworkElement[])]);
|
|
||||||
forceUpdate();
|
|
||||||
setShowAddNodeDialog(false);
|
setShowAddNodeDialog(false);
|
||||||
|
await saveCurrentLayout();
|
||||||
} catch {
|
} catch {
|
||||||
setError(t("networkGraph.failedToAddNode"));
|
setError(t("networkGraph.failedToAddNode"));
|
||||||
}
|
}
|
||||||
@@ -656,11 +711,9 @@ export function NetworkGraphCard({
|
|||||||
cyRef.current.add({
|
cyRef.current.add({
|
||||||
data: { id: groupId, label: newGroupName, color: newGroupColor },
|
data: { id: groupId, label: newGroupName, color: newGroupColor },
|
||||||
});
|
});
|
||||||
await saveCurrentLayout();
|
|
||||||
setElements([...(cyRef.current.elements().jsons() as NetworkElement[])]);
|
|
||||||
forceUpdate();
|
|
||||||
setShowAddGroupDialog(false);
|
setShowAddGroupDialog(false);
|
||||||
setNewGroupName("");
|
setNewGroupName("");
|
||||||
|
await saveCurrentLayout();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateGroup = async () => {
|
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(
|
const availableGroups = useMemo(
|
||||||
() =>
|
() =>
|
||||||
elements
|
graphElements
|
||||||
.filter(
|
.filter(
|
||||||
(el) =>
|
(el) =>
|
||||||
!el.data.source && !el.data.target && !el.data.ip && el.data.id,
|
!el.data.source && !el.data.target && !el.data.ip && el.data.id,
|
||||||
@@ -745,26 +805,26 @@ export function NetworkGraphCard({
|
|||||||
id: el.data.id!,
|
id: el.data.id!,
|
||||||
label: el.data.label || el.data.id!,
|
label: el.data.label || el.data.id!,
|
||||||
})),
|
})),
|
||||||
[elements],
|
[graphElements],
|
||||||
);
|
);
|
||||||
|
|
||||||
const availableNodesForConnection = useMemo(
|
const availableNodesForConnection = useMemo(
|
||||||
() =>
|
() =>
|
||||||
elements
|
graphElements
|
||||||
.filter((el) => !el.data.source && !el.data.target)
|
.filter((el) => !el.data.source && !el.data.target)
|
||||||
.map((el) => ({
|
.map((el) => ({
|
||||||
id: el.data.id!,
|
id: el.data.id!,
|
||||||
label: el.data.label || el.data.id!,
|
label: el.data.label || el.data.id!,
|
||||||
})),
|
})),
|
||||||
[elements],
|
[graphElements],
|
||||||
);
|
);
|
||||||
|
|
||||||
const availableHostsForAdd = useMemo(() => {
|
const availableHostsForAdd = useMemo(() => {
|
||||||
const hostList = liveHosts as HostWithStatus[];
|
const hostList = liveHosts as HostWithStatus[];
|
||||||
if (!cyRef.current) return hostList;
|
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)));
|
return hostList.filter((h) => !existing.has(String(h.id)));
|
||||||
}, [liveHosts, elements]);
|
}, [liveHosts, graphElements]);
|
||||||
|
|
||||||
const btnCls =
|
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";
|
"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}
|
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">
|
<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" />
|
<Network className="size-8 text-muted-foreground/30" />
|
||||||
<p className="text-xs text-muted-foreground/50">
|
<p className="text-xs text-muted-foreground/50">
|
||||||
@@ -1304,7 +1364,7 @@ export function NetworkGraphCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* embedded card */
|
/* embedded card */
|
||||||
const nodeCount = elements.filter((e) => !e.data.source).length;
|
const nodeCount = graphElements.filter((e) => !e.data.source).length;
|
||||||
return (
|
return (
|
||||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-0 min-h-0">
|
<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">
|
<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}
|
{dialogs}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import { Network } from "lucide-react";
|
import { Network } from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
DashboardCardProps,
|
DashboardCardProps,
|
||||||
@@ -13,11 +14,20 @@ function GraphTab({ isVisible }: TabProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GraphCard({ isVisible, shell }: DashboardCardProps) {
|
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 (
|
return (
|
||||||
<NetworkGraphCard
|
<NetworkGraphCard
|
||||||
embedded={true}
|
embedded={true}
|
||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
onOpenInNewTab={() => shell.openSingletonTab(VIEW_ID)}
|
onOpenInNewTab={openInNewTab}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user