mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
* fix(sso): send the PKCE verifier for GitHub login (#1534) * fix(remote-desktop): clip cursor overflow without disabling zoom (#1535) * test(remote-desktop): preserve sessions beyond one hour (#1536) * fix(hosts): expose controls for overflowing editor tabs (#1537) * fix(hosts): expose controls for overflowing editor tabs * test(hosts): mock resize observation in admin panel tests * fix(status): skip TCP probes while host sessions are active (#1538) * fix(database): batch session activity persistence (#1539) * docs(api): describe cookie and API key authentication (#1540) * fix(snippets): repair missing note column on SQLite upgrades (#1541) * fix(docker): retain stored SSH credential association (#1543) * fix(file-manager): render transfer toasts without plugin hooks (#1546) * feat(hosts): keep compact row actions inline (#1547) * fix(auth): allow retrying unavailable second-factor interfaces (#1549) * fix(auth): reject unresolved legacy identity provisioning (#1550) * fix(desktop): ship the plugin SDK inside Mac App Store builds (#1551) * fix(tmux): pass full session info to the terminal's session picker (#1552) The tmux.sessions service reduced detected sessions to bare names, but the picker reads session.name, so every entry rendered blank and selecting one sent an empty name, which created a new session instead of attaching. * fix(ssh): read force keyboard-interactive as a boolean when resolving hosts (#1553) The column holds the text "true"/"false", and the resolver passed it through as-is. The string "false" is truthy, so the password provider treated every saved host as forced keyboard-interactive and left the password out. Jump hops are built straight from the resolved host, so a password hop whose server doesn't offer keyboard-interactive failed with "All configured authentication methods failed". * fix(database): batch database saves for bulk host writes (#1554) * fix(database): yield to the event loop between database saves Each SQLite save serializes and encrypts the whole database into new buffers, which V8 only releases once the event loop turns. Boot migrations and bulk host import write plugin settings per host and per key in an awaited loop, so hundreds of full copies piled up and the container ran out of memory. * fix(database): save once per bulk host write instead of once per row On SQLite every repository write force-saves the whole database. Boot plugin data migrations, host defaults materialization and the bulk host routes write one row per host per setting, so they serialized and encrypted the full database hundreds of times in a row. DatabaseSaveTrigger.batched wraps a function so force saves made while it runs collapse into a single save when it finishes. Nested scopes fold into the outer one, and work that outlives its scope saves normally. * feat(hosts): add instance-wide predefined tag suggestions (#1548) * feat(hosts): add shared predefined tag suggestions * style(hosts): format tag catalog routes * test(database): advance fake timers past the save yield (#1555) * fix(i18n): complete Simplified Chinese core and plugin translations (#1542) * fix(i18n): complete Chinese onboarding and navigation labels * fix(i18n): translate remaining Chinese core and plugin interfaces * fix(i18n): translate host editor tab overflow controls * fix(i18n): use consistent Chinese fleet terminology * fix(i18n): localize hardcoded controls and plugin views * fix(i18n): translate built-in homepage widget catalog * test(homepage): follow translated timezone placeholder * fix(i18n): translate plugin-provided homepage widgets * fix(i18n): localize feature settings section titles * fix: 2.8 oidc accounts unable to sign in after upgrading (#1381) * fix: plugins losing the saved ssh login when copying a host (#1391) * fix: fleets, proxmox and automations not getting the host sudo password * fix: host imports running a defaults pass and metrics restart per host (#1384) * fix: high cpu from status probes and full database saves on every sample (#1300) * fix: user data export freezing the server (#1393) * fix: op:// secret references rejected as ssh keys on credentials (#1394) * fix: slow file deletes from the trash lookups on every delete (#1390) * fix: add openapi docs and error handling to host tag routes * test: compare download stream buffers directly so it stops timing out * chore: drop em dash from host row comment * chore: update release notes for 2.9.1 * fix: restore space to add host tags and redesign predefined tags editor * fix: host key silently accepted when the host is missing from the database (#1397) * fix: clearer host login failed status label and fix its translations (#1396) * chore: add host key and status label fixes to release notes * fix: sso and ldap provider dialogs overflowing the screen and using mismatched toggles * chore: increment ver * fix(audit): store plugin entries with no acting user as a null user_id (#1556) Plugin audit entries written outside a request used the literal "system" as user_id. That column references users.id, so the insert was refused (Postgres logs it as an FK violation) and the entry was silently dropped. Write null instead and keep "system" / plugin:<id> in username. * chore: add sso/ldap dialog and audit log fixes to release notes * fix: tunnels and host settings missing from shared hosts on desktop * fix: remote desktop logins missing from shared hosts on desktop * fix: simplify host status to online/offline and keep it live without a refresh * chore: sync Crowdin translations for 2.9.1 --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
1425 lines
47 KiB
TypeScript
1425 lines
47 KiB
TypeScript
/* eslint-disable react-hooks/exhaustive-deps */
|
|
import React, {
|
|
useEffect,
|
|
useState,
|
|
useRef,
|
|
useCallback,
|
|
useMemo,
|
|
useReducer,
|
|
} from "react";
|
|
import CytoscapeComponent from "react-cytoscapejs";
|
|
import cytoscape from "cytoscape";
|
|
import {
|
|
Card,
|
|
Button,
|
|
Badge,
|
|
AlertDialog,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogAction,
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
Input,
|
|
Label,
|
|
Select2,
|
|
useTabsSafe,
|
|
} from "@termix/plugin-sdk/ui";
|
|
import {
|
|
useTranslation,
|
|
usePluginApi,
|
|
useHosts,
|
|
useTabs,
|
|
getHostStatusColorScheme,
|
|
useHostActions,
|
|
type PluginHostRecord,
|
|
} from "@termix/plugin-sdk/frontend";
|
|
import {
|
|
Plus,
|
|
Trash2,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
RotateCw,
|
|
AlertCircle,
|
|
Download,
|
|
ExternalLink,
|
|
Upload,
|
|
Link2,
|
|
FolderPlus,
|
|
Edit,
|
|
FolderInput,
|
|
FolderMinus,
|
|
ArrowUp,
|
|
Network,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import {
|
|
createNetworkTopologyApi,
|
|
type NetworkTopologyData,
|
|
type NetworkTopologyNode,
|
|
type NetworkTopologyEdge,
|
|
} from "./network-topology-api";
|
|
|
|
const AVAILABLE_COLORS = [
|
|
{ value: "#ef4444", label: "Red" },
|
|
{ value: "#f97316", label: "Orange" },
|
|
{ value: "#eab308", label: "Yellow" },
|
|
{ value: "#22c55e", label: "Green" },
|
|
{ value: "#3b82f6", label: "Blue" },
|
|
{ value: "#a855f7", label: "Purple" },
|
|
{ value: "#ec4899", label: "Pink" },
|
|
{ value: "#6b7280", label: "Gray" },
|
|
];
|
|
|
|
type HostStatus = "online" | "offline" | "unknown";
|
|
|
|
interface HostWithStatus extends PluginHostRecord {
|
|
status?: HostStatus;
|
|
}
|
|
|
|
interface HostMap {
|
|
[key: string]: HostWithStatus;
|
|
}
|
|
|
|
interface ContextMenuState {
|
|
visible: boolean;
|
|
x: number;
|
|
y: number;
|
|
targetId: string;
|
|
type: "node" | "group" | "edge" | null;
|
|
}
|
|
|
|
interface NetworkGraphCardProps {
|
|
isTopbarOpen?: boolean;
|
|
rightSidebarOpen?: boolean;
|
|
rightSidebarWidth?: number;
|
|
embedded?: boolean;
|
|
onOpenInNewTab?: () => void;
|
|
/** When false, pause status refresh while the surface stays mounted. */
|
|
isVisible?: boolean;
|
|
}
|
|
|
|
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();
|
|
if (!raw) return fallback;
|
|
const tmp = document.createElement("div");
|
|
tmp.style.position = "absolute";
|
|
tmp.style.visibility = "hidden";
|
|
tmp.style.backgroundColor = `var(${varName})`;
|
|
document.body.appendChild(tmp);
|
|
const resolved = getComputedStyle(tmp).backgroundColor;
|
|
document.body.removeChild(tmp);
|
|
if (
|
|
!resolved ||
|
|
resolved === "rgba(0, 0, 0, 0)" ||
|
|
resolved.includes("oklch")
|
|
)
|
|
return fallback;
|
|
return resolved;
|
|
}
|
|
|
|
const NODE_W = 220;
|
|
const NODE_H = 88;
|
|
|
|
function buildNodeSvg(
|
|
name: string,
|
|
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";
|
|
const useRealColors = getHostStatusColorScheme() === "status";
|
|
let statusColor: string;
|
|
if (isOnline) {
|
|
statusColor = useRealColors
|
|
? "rgb(16,185,129)"
|
|
: resolveCssVar("--accent-brand", "rgb(16,185,129)");
|
|
} else if (isOffline) {
|
|
statusColor = useRealColors ? "rgb(239,68,68)" : "rgba(16,185,129,0.2)";
|
|
} else {
|
|
statusColor = "rgb(100,116,139)";
|
|
}
|
|
|
|
const bg = resolveCssVar("--card", "#1e1e20");
|
|
const border = resolveCssVar("--border", "#2a2a2c");
|
|
const textPrimary = resolveCssVar("--card-foreground", "#f1f5f9");
|
|
const textSecondary = resolveCssVar("--muted-foreground", "#94a3b8");
|
|
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 3);
|
|
const W = NODE_W * dpr;
|
|
const H = NODE_H * dpr;
|
|
const s = dpr;
|
|
|
|
const esc = (str: string) =>
|
|
str.replace(
|
|
/[<>&"]/g,
|
|
(c) =>
|
|
({ "<": "<", ">": ">", "&": "&", '"': """ })[c] ?? c,
|
|
);
|
|
|
|
const tagsHtml = tags
|
|
.slice(0, 3)
|
|
.map(
|
|
(tag) =>
|
|
`<span style="background:${statusColor};color:#fff;padding:${1 * s}px ${5 * s}px;border-radius:${2 * s}px;font-size:${8 * s}px;font-weight:700;margin:0 ${1 * s}px;white-space:nowrap;">${esc(tag)}</span>`,
|
|
)
|
|
.join("");
|
|
|
|
const serverIcon = `<path d="M${2 * s} ${2 * s} h${14 * s} a${1 * s} ${1 * s} 0 0 1 ${1 * s} ${1 * s} v${5 * s} a${1 * s} ${1 * s} 0 0 1 -${1 * s} ${1 * s} h-${14 * s} a${1 * s} ${1 * s} 0 0 1 -${1 * s} -${1 * s} v-${5 * s} a${1 * s} ${1 * s} 0 0 1 ${1 * s} -${1 * s}z" fill="none" stroke="${textSecondary}" stroke-width="${0.8 * s}" opacity="0.4"/>
|
|
<circle cx="${14 * s}" cy="${5.5 * s}" r="${1.5 * s}" fill="${statusColor}" opacity="0.8"/>`;
|
|
|
|
return (
|
|
"data:image/svg+xml;utf8," +
|
|
encodeURIComponent(
|
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
|
<defs>
|
|
<filter id="shadow" x="-10%" y="-10%" width="120%" height="130%">
|
|
<feDropShadow dx="0" dy="${1 * s}" stdDeviation="${2 * s}" flood-color="#000" flood-opacity="0.25"/>
|
|
</filter>
|
|
<clipPath id="nameClip">
|
|
<rect x="${14 * s}" y="${10 * s}" width="${175 * s}" height="${20 * s}"/>
|
|
</clipPath>
|
|
</defs>
|
|
<rect x="0" y="0" width="${W}" height="${H}" rx="${3 * s}" fill="${bg}" stroke="${border}" stroke-width="${s}" filter="url(#shadow)"/>
|
|
<rect x="0" y="0" width="${5 * s}" height="${H}" rx="${3 * s}" fill="${statusColor}"/>
|
|
<rect x="0" y="0" width="${5 * s}" height="${H}" fill="${statusColor}"/>
|
|
<g transform="translate(${W - 20 * s} ${3 * s})">${serverIcon}</g>
|
|
<text x="${14 * s}" y="${28 * s}" font-family="-apple-system,BlinkMacSystemFont,system-ui,sans-serif" font-size="${13 * s}" font-weight="700" fill="${textPrimary}" xml:space="preserve" clip-path="url(#nameClip)">${esc(name)}</text>
|
|
<text x="${14 * s}" y="${46 * s}" font-family="ui-monospace,monospace,sans-serif" font-size="${11 * s}" fill="${textSecondary}" xml:space="preserve">${esc(ip)}</text>
|
|
${tagsHtml ? `<foreignObject x="${12 * s}" y="${56 * s}" width="${196 * s}" height="${24 * s}"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;gap:${2 * s}px;flex-wrap:nowrap;overflow:hidden;">${tagsHtml}</div></foreignObject>` : ""}
|
|
</svg>`,
|
|
)
|
|
);
|
|
}
|
|
|
|
export const NetworkGraphCard = React.memo(function NetworkGraphCard({
|
|
embedded = true,
|
|
onOpenInNewTab,
|
|
isVisible = true,
|
|
}: NetworkGraphCardProps): React.ReactElement {
|
|
const { t } = useTranslation();
|
|
const hostActions = useHostActions();
|
|
const { addTab } = useTabsSafe();
|
|
const pluginApi = usePluginApi();
|
|
const api = useMemo(() => createNetworkTopologyApi(pluginApi), [pluginApi]);
|
|
const { hosts: liveHosts } = useHosts();
|
|
|
|
// Gate Cytoscape mounting on the container actually having non-zero dimensions.
|
|
// 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);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
|
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
|
|
|
const [showAddNodeDialog, setShowAddNodeDialog] = useState(false);
|
|
const [showAddEdgeDialog, setShowAddEdgeDialog] = useState(false);
|
|
const [showAddGroupDialog, setShowAddGroupDialog] = useState(false);
|
|
const [showEditGroupDialog, setShowEditGroupDialog] = useState(false);
|
|
const [showNodeDetail, setShowNodeDetail] = useState(false);
|
|
const [showMoveNodeDialog, setShowMoveNodeDialog] = useState(false);
|
|
|
|
const [selectedHostForAddNode, setSelectedHostForAddNode] = useState("");
|
|
const [selectedGroupForAddNode, setSelectedGroupForAddNode] =
|
|
useState("ROOT");
|
|
const [newGroupName, setNewGroupName] = useState("");
|
|
const [newGroupColor, setNewGroupColor] = useState("#3b82f6");
|
|
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
|
const [selectedGroupForMove, setSelectedGroupForMove] = useState("ROOT");
|
|
const [selectedHostForEdge, setSelectedHostForEdge] = useState("");
|
|
const [targetHostForEdge, setTargetHostForEdge] = useState("");
|
|
const [selectedNodeForDetail, setSelectedNodeForDetail] =
|
|
useState<HostWithStatus | null>(null);
|
|
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
|
visible: false,
|
|
x: 0,
|
|
y: 0,
|
|
targetId: "",
|
|
type: null,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (containerReady) return;
|
|
const el = cyContainerRef.current;
|
|
if (!el) return;
|
|
const ro = new ResizeObserver((entries) => {
|
|
const { width, height } = entries[0].contentRect;
|
|
if (width > 0 && height > 0) {
|
|
setContainerReady(true);
|
|
ro.disconnect();
|
|
}
|
|
});
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, [containerReady]);
|
|
|
|
const cyRef = useRef<cytoscape.Core | null>(null);
|
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const cyContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// hostMap tracks the live host list, so status/tags update without polling.
|
|
useEffect(() => {
|
|
const newMap: HostMap = {};
|
|
(liveHosts as HostWithStatus[]).forEach((h) => (newMap[String(h.id)] = h));
|
|
setHostMap(newMap);
|
|
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) return;
|
|
const onClickOutside = (e: MouseEvent) => {
|
|
if (
|
|
contextMenuRef.current &&
|
|
!contextMenuRef.current.contains(e.target as Node)
|
|
)
|
|
setContextMenu((p) => (p.visible ? { ...p, visible: false } : p));
|
|
};
|
|
document.addEventListener("mousedown", onClickOutside, true);
|
|
|
|
const themeObserver = new MutationObserver(() => {
|
|
clearThemeCaches();
|
|
if (cyRef.current) applyStyle(cyRef.current);
|
|
});
|
|
themeObserver.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ["class", "data-theme"],
|
|
});
|
|
|
|
return () => {
|
|
document.removeEventListener("mousedown", onClickOutside, true);
|
|
themeObserver.disconnect();
|
|
};
|
|
}, [isVisible]);
|
|
|
|
const loadData = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const newMap: HostMap = {};
|
|
(liveHosts as HostWithStatus[]).forEach(
|
|
(h) => (newMap[String(h.id)] = h),
|
|
);
|
|
setHostMap(newMap);
|
|
|
|
let nodes: NetworkTopologyNode[] = [];
|
|
let edges: NetworkTopologyEdge[] = [];
|
|
try {
|
|
const topo = await api.get();
|
|
if (topo?.nodes && Array.isArray(topo.nodes)) {
|
|
nodes = topo.nodes.map((node) => {
|
|
const h = newMap[node.data.id];
|
|
return {
|
|
data: {
|
|
id: node.data.id,
|
|
label: h?.name || node.data.label || "Unknown",
|
|
ip: h ? `${h.ip}:${h.port}` : node.data.ip || "",
|
|
status: h?.status || "unknown",
|
|
tags: h?.tags || [],
|
|
parent: node.data.parent,
|
|
color: node.data.color,
|
|
},
|
|
position: node.position || { x: 0, y: 0 },
|
|
};
|
|
});
|
|
edges = topo.edges || [];
|
|
}
|
|
} catch {
|
|
/* start with empty topology */
|
|
}
|
|
|
|
const nodeIds = new Set(nodes.map((n) => n.data.id));
|
|
const validEdges = edges.filter(
|
|
(e) => nodeIds.has(e.data.source) && nodeIds.has(e.data.target),
|
|
);
|
|
setElements([...nodes, ...validEdges]);
|
|
} catch {
|
|
/* ignore */
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [api, liveHosts]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) return;
|
|
void loadData();
|
|
// Only the initial load per visibility change; hostMap stays live via the
|
|
// liveHosts effect above.
|
|
}, [isVisible]);
|
|
|
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
const saveCurrentLayout = useCallback(async () => {
|
|
if (!cyRef.current) return;
|
|
try {
|
|
const nodes = cyRef.current.nodes().map((n) => ({
|
|
data: {
|
|
id: n.data("id"),
|
|
label: n.data("label"),
|
|
ip: n.data("ip"),
|
|
status: n.data("status"),
|
|
tags: n.data("tags") || [],
|
|
parent: n.data("parent"),
|
|
color: n.data("color"),
|
|
},
|
|
position: n.position(),
|
|
}));
|
|
const edges = cyRef.current.edges().map((e) => ({
|
|
data: {
|
|
id: e.data("id"),
|
|
source: e.data("source"),
|
|
target: e.data("target"),
|
|
},
|
|
}));
|
|
await api.save({ nodes, edges } as NetworkTopologyData);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, [api]);
|
|
|
|
const debouncedSave = useCallback(() => {
|
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
|
saveTimeoutRef.current = setTimeout(() => void saveCurrentLayout(), 1000);
|
|
}, [saveCurrentLayout]);
|
|
|
|
useEffect(() => {
|
|
if (!cyRef.current || loading || elements.length === 0) return;
|
|
const hasPositions = elements.some(
|
|
(el) =>
|
|
"position" in el &&
|
|
el.position &&
|
|
(el.position.x !== 0 || el.position.y !== 0),
|
|
);
|
|
if (!hasPositions) {
|
|
cyRef.current
|
|
.layout({
|
|
name: "cose",
|
|
animate: false,
|
|
randomize: true,
|
|
componentSpacing: 100,
|
|
nodeOverlap: 20,
|
|
})
|
|
.run();
|
|
} else {
|
|
cyRef.current.fit();
|
|
}
|
|
}, [loading]);
|
|
|
|
const applyStyle = useCallback((cy: cytoscape.Core) => {
|
|
const edgeColor = resolveCssVar("--border", "#4a4a4e");
|
|
const foreground = resolveCssVar("--foreground", "#f1f5f9");
|
|
cy.style()
|
|
.selector("node")
|
|
.style({
|
|
label: "",
|
|
width: `${NODE_W}px`,
|
|
height: `${NODE_H}px`,
|
|
shape: "rectangle",
|
|
"border-width": "0px",
|
|
"background-opacity": 0,
|
|
"background-image": (ele) =>
|
|
buildNodeSvg(
|
|
ele.data("label") || "",
|
|
ele.data("ip") || "",
|
|
ele.data("tags") || [],
|
|
ele.data("status") || "unknown",
|
|
),
|
|
"background-fit": "contain",
|
|
})
|
|
.selector("node:parent")
|
|
.style({
|
|
"background-image": "none",
|
|
"background-color": (ele) => ele.data("color") || "#1e3a8a",
|
|
"background-opacity": 0.08,
|
|
"border-color": (ele) => ele.data("color") || "#3b82f6",
|
|
"border-width": "1.5px",
|
|
"border-style": "dashed",
|
|
label: "data(label)",
|
|
"text-valign": "top",
|
|
"text-halign": "center",
|
|
"text-margin-y": -6,
|
|
color: foreground,
|
|
"font-size": "13px",
|
|
"font-weight": "bold",
|
|
shape: "rectangle",
|
|
padding: "20px",
|
|
})
|
|
.selector("edge")
|
|
.style({
|
|
width: "1.5px",
|
|
"line-color": edgeColor,
|
|
"curve-style": "bezier",
|
|
"target-arrow-shape": "none",
|
|
})
|
|
.selector("edge:selected")
|
|
.style({ "line-color": "#f59145", width: "2.5px" })
|
|
.selector("node:selected")
|
|
.style({
|
|
"overlay-color": "#f59145",
|
|
"overlay-opacity": 0.06,
|
|
"overlay-padding": "6px",
|
|
})
|
|
.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();
|
|
});
|
|
} else {
|
|
cy.nodes().forEach((n) => {
|
|
n.grabify();
|
|
});
|
|
}
|
|
applyStyle(cy);
|
|
|
|
cy.on("tap", "node", (evt) => {
|
|
setContextMenu((p) => (p.visible ? { ...p, visible: false } : p));
|
|
setSelectedEdgeId(null);
|
|
setSelectedNodeId(evt.target.id());
|
|
});
|
|
cy.on("tap", "edge", (evt) => {
|
|
evt.stopPropagation();
|
|
setSelectedEdgeId(evt.target.id());
|
|
setSelectedNodeId(null);
|
|
});
|
|
cy.on("tap", (evt) => {
|
|
if (evt.target === cy) {
|
|
setContextMenu((p) => (p.visible ? { ...p, visible: false } : p));
|
|
setSelectedNodeId(null);
|
|
setSelectedEdgeId(null);
|
|
}
|
|
});
|
|
cy.on("cxttap", "node", (evt) => {
|
|
evt.preventDefault();
|
|
evt.stopPropagation();
|
|
const node = evt.target;
|
|
const nodeId = node.id();
|
|
const isGroup = node.isParent() || String(nodeId).startsWith("group-");
|
|
if (isGroup && embedded) return;
|
|
setContextMenu({
|
|
visible: true,
|
|
x: evt.originalEvent.clientX,
|
|
y: evt.originalEvent.clientY,
|
|
targetId: nodeId,
|
|
type: isGroup ? "group" : "node",
|
|
});
|
|
});
|
|
cy.on("zoom pan", () =>
|
|
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());
|
|
});
|
|
},
|
|
[applyStyle, debouncedSave, embedded],
|
|
);
|
|
|
|
// Zoom centered on the viewport midpoint, no panning
|
|
const zoomIn = useCallback(() => {
|
|
const cy = cyRef.current;
|
|
if (!cy || !cyContainerRef.current) return;
|
|
const rect = cyContainerRef.current.getBoundingClientRect();
|
|
cy.zoom({
|
|
level: cy.zoom() * 1.25,
|
|
renderedPosition: { x: rect.width / 2, y: rect.height / 2 },
|
|
});
|
|
}, []);
|
|
|
|
const zoomOut = useCallback(() => {
|
|
const cy = cyRef.current;
|
|
if (!cy || !cyContainerRef.current) return;
|
|
const rect = cyContainerRef.current.getBoundingClientRect();
|
|
cy.zoom({
|
|
level: cy.zoom() / 1.25,
|
|
renderedPosition: { x: rect.width / 2, y: rect.height / 2 },
|
|
});
|
|
}, []);
|
|
|
|
const hideMenu = () => setContextMenu((p) => ({ ...p, visible: false }));
|
|
|
|
const shellTabs = useTabs();
|
|
const fireOpen = (hostId: string, type?: string) => {
|
|
const host = liveHosts.find((candidate) => String(candidate.id) === hostId);
|
|
if (host) shellTabs.connectHost(host, type);
|
|
};
|
|
|
|
const handleContextAction = (action: string) => {
|
|
hideMenu();
|
|
const targetId = contextMenu.targetId;
|
|
if (!cyRef.current) return;
|
|
if (action === "details") {
|
|
const h = hostMap[targetId];
|
|
if (h) {
|
|
setSelectedNodeForDetail(h);
|
|
setShowNodeDetail(true);
|
|
}
|
|
} else if (action === "connect") {
|
|
fireOpen(targetId);
|
|
} else if (action === "move") {
|
|
setSelectedNodeId(targetId);
|
|
const node = cyRef.current.$id(targetId);
|
|
setSelectedGroupForMove(node.data("parent") || "ROOT");
|
|
setShowMoveNodeDialog(true);
|
|
} else if (action === "removeFromGroup") {
|
|
cyRef.current.$id(targetId).move({ parent: null });
|
|
debouncedSave();
|
|
} else if (action === "editGroup") {
|
|
const node = cyRef.current.$id(targetId);
|
|
setEditingGroupId(targetId);
|
|
setNewGroupName(node.data("label"));
|
|
setNewGroupColor(node.data("color") || "#3b82f6");
|
|
setShowEditGroupDialog(true);
|
|
} else if (action === "addHostToGroup") {
|
|
setSelectedGroupForAddNode(targetId);
|
|
setSelectedHostForAddNode("");
|
|
setShowAddNodeDialog(true);
|
|
} else if (action === "delete") {
|
|
cyRef.current.$id(targetId).remove();
|
|
debouncedSave();
|
|
}
|
|
};
|
|
|
|
const handleConnectAction = (appType: string) => {
|
|
hideMenu();
|
|
fireOpen(contextMenu.targetId, appType);
|
|
};
|
|
|
|
const handleConfirmAddNode = async () => {
|
|
if (!cyRef.current || !selectedHostForAddNode) return;
|
|
try {
|
|
if (cyRef.current.$id(selectedHostForAddNode).length > 0) {
|
|
setError(t("networkGraph.hostAlreadyExists"));
|
|
return;
|
|
}
|
|
const h = hostMap[selectedHostForAddNode];
|
|
const parent =
|
|
selectedGroupForAddNode === "ROOT"
|
|
? undefined
|
|
: selectedGroupForAddNode;
|
|
cyRef.current.add({
|
|
data: {
|
|
id: selectedHostForAddNode,
|
|
label: h?.name || h?.ip || selectedHostForAddNode,
|
|
ip: h ? `${h.ip}:${h.port}` : "",
|
|
status: h?.status || "unknown",
|
|
tags: h?.tags || [],
|
|
parent,
|
|
},
|
|
position: {
|
|
x: 100 + Math.random() * 200,
|
|
y: 100 + Math.random() * 200,
|
|
},
|
|
});
|
|
setShowAddNodeDialog(false);
|
|
await saveCurrentLayout();
|
|
} catch {
|
|
setError(t("networkGraph.failedToAddNode"));
|
|
}
|
|
};
|
|
|
|
const handleAddGroup = async () => {
|
|
if (!cyRef.current || !newGroupName) return;
|
|
const groupId = `group-${Date.now()}`;
|
|
cyRef.current.add({
|
|
data: { id: groupId, label: newGroupName, color: newGroupColor },
|
|
});
|
|
setShowAddGroupDialog(false);
|
|
setNewGroupName("");
|
|
await saveCurrentLayout();
|
|
};
|
|
|
|
const handleUpdateGroup = async () => {
|
|
if (!cyRef.current || !editingGroupId || !newGroupName) return;
|
|
const g = cyRef.current.$id(editingGroupId);
|
|
g.data("label", newGroupName);
|
|
g.data("color", newGroupColor);
|
|
await saveCurrentLayout();
|
|
setShowEditGroupDialog(false);
|
|
setEditingGroupId(null);
|
|
};
|
|
|
|
const handleMoveNodeToGroup = async () => {
|
|
if (!cyRef.current || !selectedNodeId) return;
|
|
cyRef.current.$id(selectedNodeId).move({
|
|
parent: selectedGroupForMove === "ROOT" ? null : selectedGroupForMove,
|
|
});
|
|
await saveCurrentLayout();
|
|
setShowMoveNodeDialog(false);
|
|
};
|
|
|
|
const handleAddEdge = async () => {
|
|
if (!cyRef.current || !selectedHostForEdge || !targetHostForEdge) return;
|
|
if (selectedHostForEdge === targetHostForEdge)
|
|
return setError(t("networkGraph.sourceDifferentFromTarget"));
|
|
const edgeId = `${selectedHostForEdge}-${targetHostForEdge}`;
|
|
if (cyRef.current.$id(edgeId).length > 0)
|
|
return setError(t("networkGraph.connectionExists"));
|
|
cyRef.current.add({
|
|
data: {
|
|
id: edgeId,
|
|
source: selectedHostForEdge,
|
|
target: targetHostForEdge,
|
|
},
|
|
});
|
|
await saveCurrentLayout();
|
|
setShowAddEdgeDialog(false);
|
|
};
|
|
|
|
const handleRemoveSelected = () => {
|
|
if (!cyRef.current) return;
|
|
if (selectedNodeId) {
|
|
cyRef.current.$id(selectedNodeId).remove();
|
|
setSelectedNodeId(null);
|
|
} else if (selectedEdgeId) {
|
|
cyRef.current.$id(selectedEdgeId).remove();
|
|
setSelectedEdgeId(null);
|
|
}
|
|
debouncedSave();
|
|
};
|
|
|
|
const handleExport = () => {
|
|
if (!cyRef.current) return;
|
|
const json = JSON.stringify(cyRef.current.json().elements, null, 2);
|
|
const a = document.createElement("a");
|
|
a.href = URL.createObjectURL(
|
|
new Blob([json], { type: "application/json" }),
|
|
);
|
|
a.download = "network.json";
|
|
a.click();
|
|
};
|
|
|
|
const handleOpenInNewTab = () => {
|
|
if (onOpenInNewTab) {
|
|
onOpenInNewTab();
|
|
} else {
|
|
addTab({
|
|
type: "network_graph",
|
|
title: t("dashboard.networkGraph"),
|
|
});
|
|
}
|
|
};
|
|
|
|
// 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(
|
|
() =>
|
|
graphElements
|
|
.filter(
|
|
(el) =>
|
|
!el.data.source && !el.data.target && !el.data.ip && el.data.id,
|
|
)
|
|
.map((el) => ({
|
|
id: el.data.id!,
|
|
label: el.data.label || el.data.id!,
|
|
})),
|
|
[graphElements],
|
|
);
|
|
|
|
const availableNodesForConnection = useMemo(
|
|
() =>
|
|
graphElements
|
|
.filter((el) => !el.data.source && !el.data.target)
|
|
.map((el) => ({
|
|
id: el.data.id!,
|
|
label: el.data.label || el.data.id!,
|
|
})),
|
|
[graphElements],
|
|
);
|
|
|
|
const availableHostsForAdd = useMemo(() => {
|
|
const hostList = liveHosts as HostWithStatus[];
|
|
if (!cyRef.current) return hostList;
|
|
const existing = new Set(graphElements.map((e) => e.data.id));
|
|
return hostList.filter((h) => !existing.has(String(h.id)));
|
|
}, [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";
|
|
|
|
const contextMenuEl = contextMenu.visible ? (
|
|
<div
|
|
ref={contextMenuRef}
|
|
className="fixed z-[300] min-w-[170px] shadow-2xl rounded-none bg-card border border-border"
|
|
style={{ top: contextMenu.y, left: contextMenu.x }}
|
|
>
|
|
{contextMenu.type === "node" && (
|
|
<>
|
|
{hostActions
|
|
.filter((action) => !!action.tabType && !action.items)
|
|
.filter((action) => {
|
|
const host = hostMap[contextMenu.targetId];
|
|
return !!host && action.when(host);
|
|
})
|
|
.map((action) => {
|
|
const Icon = action.icon;
|
|
return (
|
|
<button
|
|
key={action.id}
|
|
onClick={() => handleConnectAction(action.tabType!)}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
|
|
>
|
|
<Icon className="size-3 shrink-0" />
|
|
{t(action.titleKey)}
|
|
</button>
|
|
);
|
|
})}
|
|
{!embedded && (
|
|
<>
|
|
<div className="h-px bg-border mx-2 my-0.5" />
|
|
<button
|
|
onClick={() => handleContextAction("move")}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
|
|
>
|
|
<FolderInput className="size-3 shrink-0" />
|
|
{t("networkGraph.moveToGroup")}
|
|
</button>
|
|
{cyRef.current?.$id(contextMenu.targetId).parent().length ? (
|
|
<button
|
|
onClick={() => handleContextAction("removeFromGroup")}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
|
|
>
|
|
<FolderMinus className="size-3 shrink-0" />
|
|
{t("networkGraph.removeFromGroup")}
|
|
</button>
|
|
) : null}
|
|
<div className="h-px bg-border mx-2 my-0.5" />
|
|
<button
|
|
onClick={() => handleContextAction("delete")}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-destructive/10 text-destructive transition-colors"
|
|
>
|
|
<Trash2 className="size-3 shrink-0" />
|
|
{t("networkGraph.delete")}
|
|
</button>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
{contextMenu.type === "group" && !embedded && (
|
|
<>
|
|
<button
|
|
onClick={() => handleContextAction("addHostToGroup")}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
|
|
>
|
|
<FolderPlus className="size-3 shrink-0" />
|
|
{t("networkGraph.addHostHere")}
|
|
</button>
|
|
<button
|
|
onClick={() => handleContextAction("editGroup")}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
|
|
>
|
|
<Edit className="size-3 shrink-0" />
|
|
{t("networkGraph.editGroup")}
|
|
</button>
|
|
<div className="h-px bg-border mx-2 my-0.5" />
|
|
<button
|
|
onClick={() => handleContextAction("delete")}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-destructive/10 text-destructive transition-colors"
|
|
>
|
|
<Trash2 className="size-3 shrink-0" />
|
|
{t("networkGraph.delete")}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : null;
|
|
|
|
const cytoscapeEl = (
|
|
<div
|
|
ref={cyContainerRef}
|
|
className="relative flex-1 min-h-0 w-full overflow-hidden bg-background"
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
>
|
|
{loading && (
|
|
<div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80">
|
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
)}
|
|
{contextMenuEl}
|
|
{containerReady && (
|
|
<CytoscapeComponent
|
|
elements={elements}
|
|
style={{ width: "100%", height: "100%", background: "transparent" }}
|
|
layout={{ name: "preset" }}
|
|
cy={handleNodeInit}
|
|
wheelSensitivity={1.5}
|
|
minZoom={0.2}
|
|
maxZoom={3}
|
|
/>
|
|
)}
|
|
{!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">
|
|
{t("networkGraph.noNodes")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const dialogs = (
|
|
<>
|
|
<AlertDialog open={!!error} onOpenChange={() => setError(null)}>
|
|
<AlertDialogContent className="bg-card border border-border">
|
|
<div className="flex items-start gap-3">
|
|
<AlertCircle className="h-5 w-5 text-destructive shrink-0 mt-0.5" />
|
|
<AlertDialogDescription className="text-foreground flex-1">
|
|
{error}
|
|
</AlertDialogDescription>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<AlertDialogAction onClick={() => setError(null)}>
|
|
OK
|
|
</AlertDialogAction>
|
|
</div>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Add Host dialog */}
|
|
<Dialog open={showAddNodeDialog} onOpenChange={setShowAddNodeDialog}>
|
|
<DialogContent className="bg-card border border-border">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("networkGraph.addHost")}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label>{t("networkGraph.selectHost")}</Label>
|
|
<Select2
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
value={selectedHostForAddNode}
|
|
onChange={(e) => setSelectedHostForAddNode(e.target.value)}
|
|
>
|
|
<option value="">{t("networkGraph.chooseHost")}</option>
|
|
{availableHostsForAdd.map((h) => (
|
|
<option key={h.id} value={String(h.id)}>
|
|
{h.name || h.ip}
|
|
</option>
|
|
))}
|
|
</Select2>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>{t("networkGraph.parentGroup")}</Label>
|
|
<Select2
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
value={selectedGroupForAddNode}
|
|
onChange={(e) => setSelectedGroupForAddNode(e.target.value)}
|
|
>
|
|
<option value="ROOT">{t("networkGraph.noGroup")}</option>
|
|
{availableGroups.map((g) => (
|
|
<option key={g.id} value={g.id}>
|
|
{g.label}
|
|
</option>
|
|
))}
|
|
</Select2>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowAddNodeDialog(false)}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
onClick={handleConfirmAddNode}
|
|
disabled={!selectedHostForAddNode}
|
|
>
|
|
{t("common.add")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Add / Edit Group dialog */}
|
|
<Dialog
|
|
open={showAddGroupDialog || showEditGroupDialog}
|
|
onOpenChange={(o) => {
|
|
if (!o) {
|
|
setShowAddGroupDialog(false);
|
|
setShowEditGroupDialog(false);
|
|
}
|
|
}}
|
|
>
|
|
<DialogContent className="bg-card border border-border">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{showEditGroupDialog
|
|
? t("networkGraph.editGroup")
|
|
: t("networkGraph.createGroup")}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label>{t("networkGraph.groupName")}</Label>
|
|
<Input
|
|
value={newGroupName}
|
|
onChange={(e) => setNewGroupName(e.target.value)}
|
|
placeholder={t("networkGraph.groupName")}
|
|
/>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>{t("networkGraph.color")}</Label>
|
|
<div className="grid grid-cols-4 gap-2">
|
|
{AVAILABLE_COLORS.map((c) => (
|
|
<button
|
|
key={c.value}
|
|
type="button"
|
|
onClick={() => setNewGroupColor(c.value)}
|
|
className={`h-9 rounded border-2 transition-all ${
|
|
newGroupColor === c.value
|
|
? "border-accent-brand ring-1 ring-accent-brand"
|
|
: "border-border hover:border-muted-foreground"
|
|
}`}
|
|
style={{ backgroundColor: c.value }}
|
|
title={c.label}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowAddGroupDialog(false);
|
|
setShowEditGroupDialog(false);
|
|
}}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button
|
|
onClick={showEditGroupDialog ? handleUpdateGroup : handleAddGroup}
|
|
disabled={!newGroupName}
|
|
>
|
|
{showEditGroupDialog ? t("common.update") : t("common.create")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Move to Group dialog */}
|
|
<Dialog open={showMoveNodeDialog} onOpenChange={setShowMoveNodeDialog}>
|
|
<DialogContent className="bg-card border border-border">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("networkGraph.moveToGroup")}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label>{t("networkGraph.selectGroup")}</Label>
|
|
<Select2
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
value={selectedGroupForMove}
|
|
onChange={(e) => setSelectedGroupForMove(e.target.value)}
|
|
>
|
|
<option value="ROOT">{t("networkGraph.noGroup")}</option>
|
|
{availableGroups
|
|
.filter((g) => g.id !== selectedNodeId)
|
|
.map((g) => (
|
|
<option key={g.id} value={g.id}>
|
|
{g.label}
|
|
</option>
|
|
))}
|
|
</Select2>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowMoveNodeDialog(false)}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button onClick={handleMoveNodeToGroup}>
|
|
{t("networkGraph.move")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Add Edge dialog */}
|
|
<Dialog open={showAddEdgeDialog} onOpenChange={setShowAddEdgeDialog}>
|
|
<DialogContent className="bg-card border border-border">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("networkGraph.addConnection")}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="grid gap-2">
|
|
<Label>{t("networkGraph.source")}</Label>
|
|
<Select2
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
value={selectedHostForEdge}
|
|
onChange={(e) => setSelectedHostForEdge(e.target.value)}
|
|
>
|
|
<option value="">
|
|
{t("networkGraph.selectSourcePlaceholder")}
|
|
</option>
|
|
{availableNodesForConnection.map((el) => (
|
|
<option key={el.id} value={el.id}>
|
|
{el.label}
|
|
</option>
|
|
))}
|
|
</Select2>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>{t("networkGraph.target")}</Label>
|
|
<Select2
|
|
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
|
value={targetHostForEdge}
|
|
onChange={(e) => setTargetHostForEdge(e.target.value)}
|
|
>
|
|
<option value="">
|
|
{t("networkGraph.selectTargetPlaceholder")}
|
|
</option>
|
|
{availableNodesForConnection.map((el) => (
|
|
<option key={el.id} value={el.id}>
|
|
{el.label}
|
|
</option>
|
|
))}
|
|
</Select2>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setShowAddEdgeDialog(false)}
|
|
>
|
|
{t("common.cancel")}
|
|
</Button>
|
|
<Button onClick={handleAddEdge}>{t("networkGraph.connect")}</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Node Detail dialog */}
|
|
<Dialog open={showNodeDetail} onOpenChange={setShowNodeDetail}>
|
|
<DialogContent className="bg-card border border-border">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("networkGraph.hostDetails")}</DialogTitle>
|
|
</DialogHeader>
|
|
{selectedNodeForDetail && (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
<span className="font-semibold text-muted-foreground">
|
|
{t("networkGraph.name")}
|
|
</span>
|
|
<span>{selectedNodeForDetail.name}</span>
|
|
<span className="font-semibold text-muted-foreground">
|
|
{t("networkGraph.ip")}
|
|
</span>
|
|
<span>{selectedNodeForDetail.ip}</span>
|
|
<span className="font-semibold text-muted-foreground">
|
|
{t("networkGraph.status")}
|
|
</span>
|
|
<span className="capitalize">
|
|
{selectedNodeForDetail.status || t("networkGraph.unknown")}
|
|
</span>
|
|
</div>
|
|
{selectedNodeForDetail.tags &&
|
|
selectedNodeForDetail.tags.length > 0 && (
|
|
<div className="flex gap-1 flex-wrap">
|
|
{selectedNodeForDetail.tags.map((tag) => (
|
|
<Badge key={tag} variant="outline" className="text-xs">
|
|
{tag}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setShowNodeDetail(false)}>
|
|
{t("common.close")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".json"
|
|
className="hidden"
|
|
onChange={async (e) => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = async (evt) => {
|
|
try {
|
|
const json = JSON.parse(evt.target?.result as string);
|
|
await api.save({
|
|
nodes: json.nodes,
|
|
edges: json.edges,
|
|
});
|
|
await loadData();
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
} catch {
|
|
setError(t("networkGraph.invalidFile"));
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
|
|
if (!embedded) {
|
|
return (
|
|
<div className="h-full w-full flex flex-col bg-background">
|
|
<div className="flex items-center gap-1 px-4 py-2 border-b border-border shrink-0 flex-wrap">
|
|
<div className="flex items-center gap-0.5">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedHostForAddNode("");
|
|
setSelectedGroupForAddNode("ROOT");
|
|
setShowAddNodeDialog(true);
|
|
}}
|
|
className="h-7 px-2 text-xs gap-1.5"
|
|
>
|
|
<Plus className="size-3" />
|
|
{t("networkGraph.addHost")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setNewGroupName("");
|
|
setNewGroupColor("#3b82f6");
|
|
setShowAddGroupDialog(true);
|
|
}}
|
|
className="h-7 px-2 text-xs gap-1.5"
|
|
>
|
|
<FolderPlus className="size-3" />
|
|
{t("networkGraph.addGroup")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowAddEdgeDialog(true)}
|
|
className="h-7 px-2 text-xs gap-1.5"
|
|
>
|
|
<Link2 className="size-3" />
|
|
{t("networkGraph.addLink")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleRemoveSelected}
|
|
disabled={!selectedNodeId && !selectedEdgeId}
|
|
className="h-7 px-2 text-xs gap-1.5 text-destructive hover:text-destructive disabled:opacity-30"
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</Button>
|
|
</div>
|
|
<div className="w-px h-5 bg-border mx-1" />
|
|
<div className="flex items-center gap-0.5">
|
|
<button
|
|
onClick={zoomIn}
|
|
title={t("networkGraph.zoomIn")}
|
|
className={btnCls}
|
|
>
|
|
<ZoomIn className="size-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={zoomOut}
|
|
title={t("networkGraph.zoomOut")}
|
|
className={btnCls}
|
|
>
|
|
<ZoomOut className="size-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => cyRef.current?.fit()}
|
|
title={t("networkGraph.resetView")}
|
|
className={btnCls}
|
|
>
|
|
<RotateCw className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
<div className="w-px h-5 bg-border mx-1" />
|
|
<div className="flex items-center gap-0.5">
|
|
<button
|
|
onClick={handleExport}
|
|
title={t("networkGraph.exportJSON")}
|
|
className={btnCls}
|
|
>
|
|
<Download className="size-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
title={t("networkGraph.importJSON")}
|
|
className={btnCls}
|
|
>
|
|
<Upload className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
<div className="w-px h-5 bg-border mx-1" />
|
|
<a
|
|
href="https://docs.termix.site/features/dashboard/network-graph"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title={t("hosts.docsLink")}
|
|
className={btnCls}
|
|
>
|
|
<ExternalLink className="size-3.5" />
|
|
</a>
|
|
</div>
|
|
{cytoscapeEl}
|
|
{dialogs}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* embedded card */
|
|
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">
|
|
<div className="flex items-center gap-2">
|
|
<Network className="size-3.5 text-muted-foreground" />
|
|
<span className="text-xs text-muted-foreground uppercase tracking-widest font-semibold">
|
|
{t("dashboard.networkGraph")}
|
|
</span>
|
|
{!loading && (
|
|
<span className="text-[10px] text-muted-foreground/60">
|
|
{t("dashboardTab.nodes", { count: nodeCount })}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-0.5">
|
|
<button
|
|
onClick={zoomIn}
|
|
title={t("networkGraph.zoomIn")}
|
|
className={btnCls}
|
|
>
|
|
<ZoomIn className="size-3" />
|
|
</button>
|
|
<button
|
|
onClick={zoomOut}
|
|
title={t("networkGraph.zoomOut")}
|
|
className={btnCls}
|
|
>
|
|
<ZoomOut className="size-3" />
|
|
</button>
|
|
<button
|
|
onClick={() => cyRef.current?.fit()}
|
|
title={t("networkGraph.resetView")}
|
|
className={btnCls}
|
|
>
|
|
<RotateCw className="size-3" />
|
|
</button>
|
|
<button
|
|
onClick={handleOpenInNewTab}
|
|
title={t("common.openInNewTab")}
|
|
className={btnCls}
|
|
>
|
|
<ArrowUp className="size-3" />
|
|
</button>
|
|
<a
|
|
href="https://docs.termix.site/features/dashboard/network-graph"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title={t("hosts.docsLink")}
|
|
className={btnCls}
|
|
>
|
|
<ExternalLink className="size-3" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
{cytoscapeEl}
|
|
{dialogs}
|
|
</Card>
|
|
);
|
|
});
|