mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-09-13 10:49:03 +00:00
d1a8dcf3c6
* chore(deps-dev): bump the dev-minor-updates group across 1 directory with 12 updates (#910) Bumps the dev-minor-updates group with 12 updates in the / directory: | Package | From | To | | --- | --- | --- | | [@radix-ui/react-select](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/select) | `2.2.6` | `2.3.1` | | [@radix-ui/react-slider](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slider) | `1.3.6` | `1.4.1` | | [@radix-ui/react-slot](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/slot) | `1.2.5` | `1.3.0` | | [@radix-ui/react-switch](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/switch) | `1.2.6` | `1.3.1` | | [cytoscape](https://github.com/cytoscape/cytoscape.js) | `3.33.4` | `3.34.0` | | [electron](https://github.com/electron/electron) | `42.2.0` | `42.4.1` | | [electron-builder](https://github.com/electron-userland/electron-builder/tree/HEAD/packages/electron-builder) | `26.8.1` | `26.15.3` | | [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.16.0` | `1.20.0` | | [radix-ui](https://github.com/radix-ui/primitives/tree/HEAD/packages/react/radix-ui) | `1.4.3` | `1.6.0` | | [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.76.1` | `7.79.0` | | [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.1` | | [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.60.0` | `8.61.1` | Updates `@radix-ui/react-select` from 2.2.6 to 2.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/select/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/select) Updates `@radix-ui/react-slider` from 1.3.6 to 1.4.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slider/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slider) Updates `@radix-ui/react-slot` from 1.2.5 to 1.3.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/slot/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/slot) Updates `@radix-ui/react-switch` from 1.2.6 to 1.3.1 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/switch/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/switch) Updates `cytoscape` from 3.33.4 to 3.34.0 - [Release notes](https://github.com/cytoscape/cytoscape.js/releases) - [Commits](https://github.com/cytoscape/cytoscape.js/compare/v3.33.4...v3.34.0) Updates `electron` from 42.2.0 to 42.4.1 - [Release notes](https://github.com/electron/electron/releases) - [Commits](https://github.com/electron/electron/compare/v42.2.0...v42.4.1) Updates `electron-builder` from 26.8.1 to 26.15.3 - [Release notes](https://github.com/electron-userland/electron-builder/releases) - [Changelog](https://github.com/electron-userland/electron-builder/blob/master/packages/electron-builder/CHANGELOG.md) - [Commits](https://github.com/electron-userland/electron-builder/commits/electron-builder@26.15.3/packages/electron-builder) Updates `lucide-react` from 1.16.0 to 1.20.0 - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.20.0/packages/lucide-react) Updates `radix-ui` from 1.4.3 to 1.6.0 - [Changelog](https://github.com/radix-ui/primitives/blob/main/packages/react/radix-ui/CHANGELOG.md) - [Commits](https://github.com/radix-ui/primitives/commits/HEAD/packages/react/radix-ui) Updates `react-hook-form` from 7.76.1 to 7.79.0 - [Release notes](https://github.com/react-hook-form/react-hook-form/releases) - [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md) - [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.76.1...v7.79.0) Updates `sharp` from 0.34.5 to 0.35.1 - [Release notes](https://github.com/lovell/sharp/releases) - [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.1) Updates `typescript-eslint` from 8.60.0 to 8.61.1 - [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases) - [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md) - [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.61.1/packages/typescript-eslint) --- updated-dependencies: - dependency-name: "@radix-ui/react-select" dependency-version: 2.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slider" dependency-version: 1.4.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-slot" dependency-version: 1.3.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: "@radix-ui/react-switch" dependency-version: 1.3.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: cytoscape dependency-version: 3.34.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron dependency-version: 42.4.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: electron-builder dependency-version: 26.15.3 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: lucide-react dependency-version: 1.18.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: radix-ui dependency-version: 1.6.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: react-hook-form dependency-version: 7.79.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: sharp dependency-version: 0.35.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates - dependency-name: typescript-eslint dependency-version: 8.61.1 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump node in /docker in the docker-major-updates group (#914) Bumps the docker-major-updates group in /docker with 1 update: node. Updates `node` from 24-slim to 26-slim --- updated-dependencies: - dependency-name: node dependency-version: 26-slim dependency-type: direct:production dependency-group: docker-major-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * ci(deps): bump the github-actions group with 2 updates (#915) Bumps the github-actions group with 2 updates: [actions/checkout](https://github.com/actions/checkout) and [actions/setup-node](https://github.com/actions/setup-node). Updates `actions/checkout` from 5 to 6 - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/checkout/compare/v5...v6) Updates `actions/setup-node` from 4 to 6 - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major dependency-group: github-actions ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * chore(deps): bump the prod-minor-updates group across 1 directory with 5 updates (#916) Bumps the prod-minor-updates group with 5 updates in the / directory: | Package | From | To | | --- | --- | --- | | [axios](https://github.com/axios/axios) | `1.17.0` | `1.18.0` | | [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) | `12.10.0` | `12.11.1` | | [body-parser](https://github.com/expressjs/body-parser) | `2.2.2` | `2.3.0` | | [multer](https://github.com/expressjs/multer) | `2.1.1` | `2.2.0` | | [undici](https://github.com/nodejs/undici) | `8.4.0` | `8.5.0` | Updates `axios` from 1.17.0 to 1.18.0 - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.17.0...v1.18.0) Updates `better-sqlite3` from 12.10.0 to 12.11.1 - [Release notes](https://github.com/WiseLibs/better-sqlite3/releases) - [Commits](https://github.com/WiseLibs/better-sqlite3/compare/v12.10.0...v12.11.1) Updates `body-parser` from 2.2.2 to 2.3.0 - [Release notes](https://github.com/expressjs/body-parser/releases) - [Changelog](https://github.com/expressjs/body-parser/blob/master/HISTORY.md) - [Commits](https://github.com/expressjs/body-parser/compare/v2.2.2...v2.3.0) Updates `multer` from 2.1.1 to 2.2.0 - [Release notes](https://github.com/expressjs/multer/releases) - [Changelog](https://github.com/expressjs/multer/blob/main/CHANGELOG.md) - [Commits](https://github.com/expressjs/multer/compare/v2.1.1...v2.2.0) Updates `undici` from 8.4.0 to 8.5.0 - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v8.4.0...v8.5.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.18.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: better-sqlite3 dependency-version: 12.11.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: body-parser dependency-version: 2.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: multer dependency-version: 2.2.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates - dependency-name: undici dependency-version: 8.5.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: prod-minor-updates ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: add unlink button for dual auth * fix: general bug fixes * fix: general qol/small feature additions * fix: 100mb max upload size * fix: terminal syntax not handling carriage returns or shell prompt lines properly * feat: continued general improvements * fix: terminal outputting success right after folder path * chore: update release notes * feat: continued fixes and improvements * chore: lint, format, and bump version to 2.4.1 * chore: sync Crowdin translations for 2.4.1 * fix: rebase dev branch onto main before Crowdin download * fix: use merge instead of rebase to sync main before Crowdin --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1391 lines
47 KiB
TypeScript
1391 lines
47 KiB
TypeScript
/* eslint-disable react-hooks/exhaustive-deps */
|
|
import React, {
|
|
useEffect,
|
|
useState,
|
|
useRef,
|
|
useCallback,
|
|
useMemo,
|
|
useReducer,
|
|
} from "react";
|
|
import { Card } from "@/components/card";
|
|
import CytoscapeComponent from "react-cytoscapejs";
|
|
import cytoscape from "cytoscape";
|
|
import {
|
|
getSSHHosts,
|
|
getNetworkTopology,
|
|
saveNetworkTopology,
|
|
type SSHHostWithStatus,
|
|
type NetworkTopologyEdge,
|
|
type NetworkTopologyNode,
|
|
} from "@/main-axios";
|
|
import { Button } from "@/components/button";
|
|
import { Badge } from "@/components/badge";
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogAction,
|
|
} from "@/components/alert-dialog";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogFooter,
|
|
} from "@/components/dialog";
|
|
import { Input } from "@/components/input";
|
|
import { Label } from "@/components/label";
|
|
import {
|
|
Plus,
|
|
Trash2,
|
|
ZoomIn,
|
|
ZoomOut,
|
|
RotateCw,
|
|
AlertCircle,
|
|
Download,
|
|
ExternalLink,
|
|
Upload,
|
|
Link2,
|
|
FolderPlus,
|
|
Edit,
|
|
FolderInput,
|
|
FolderMinus,
|
|
Terminal,
|
|
ArrowUp,
|
|
Network,
|
|
FolderOpen,
|
|
Container,
|
|
Server,
|
|
ArrowDownUp,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useTabsSafe } from "@/shell/TabContext";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
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" },
|
|
];
|
|
|
|
interface HostMap {
|
|
[key: string]: SSHHostWithStatus;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
|
|
|
|
function resolveCssVar(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 isOnline = status === "online";
|
|
const isOffline = status === "offline";
|
|
const useRealColors = localStorage.getItem("statusColorScheme") === "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 function NetworkGraphCard({
|
|
embedded = true,
|
|
onOpenInNewTab,
|
|
}: NetworkGraphCardProps): React.ReactElement {
|
|
const { t } = useTranslation();
|
|
const { addTab } = useTabsSafe();
|
|
|
|
// 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);
|
|
|
|
const [elements, setElements] = useState<NetworkElement[]>([]);
|
|
const [hosts, setHosts] = useState<SSHHostWithStatus[]>([]);
|
|
const [hostMap, setHostMap] = useState<HostMap>({});
|
|
const hostMapRef = useRef<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<SSHHostWithStatus | null>(null);
|
|
|
|
const [contextMenu, setContextMenu] = useState<ContextMenuState>({
|
|
visible: false,
|
|
x: 0,
|
|
y: 0,
|
|
targetId: "",
|
|
type: null,
|
|
});
|
|
|
|
const [, forceUpdate] = useReducer((x: number) => x + 1, 0);
|
|
|
|
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 statusIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const cyContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
useEffect(() => {
|
|
hostMapRef.current = hostMap;
|
|
}, [hostMap]);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
statusIntervalRef.current = setInterval(updateHostStatuses, 30000);
|
|
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(() => {
|
|
if (cyRef.current) applyStyle(cyRef.current);
|
|
});
|
|
themeObserver.observe(document.documentElement, {
|
|
attributes: true,
|
|
attributeFilter: ["class", "data-theme"],
|
|
});
|
|
|
|
return () => {
|
|
if (statusIntervalRef.current) clearInterval(statusIntervalRef.current);
|
|
document.removeEventListener("mousedown", onClickOutside, true);
|
|
themeObserver.disconnect();
|
|
};
|
|
}, []);
|
|
|
|
const loadData = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const hostsData = await getSSHHosts();
|
|
const hostsArray = Array.isArray(hostsData) ? hostsData : [];
|
|
setHosts(hostsArray);
|
|
const newMap: HostMap = {};
|
|
hostsArray.forEach((h) => (newMap[String(h.id)] = h));
|
|
setHostMap(newMap);
|
|
|
|
let nodes: NetworkTopologyNode[] = [];
|
|
let edges: NetworkTopologyEdge[] = [];
|
|
try {
|
|
const topo = await getNetworkTopology();
|
|
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);
|
|
}
|
|
};
|
|
|
|
const updateHostStatuses = useCallback(async () => {
|
|
if (!cyRef.current) return;
|
|
try {
|
|
const updated = await getSSHHosts();
|
|
const updatedMap: HostMap = {};
|
|
updated.forEach((h) => (updatedMap[String(h.id)] = h));
|
|
cyRef.current.nodes().forEach((node) => {
|
|
if (node.isParent()) return;
|
|
const h = updatedMap[node.data("id")];
|
|
if (h) {
|
|
node.data("status", h.status);
|
|
node.data("tags", h.tags || []);
|
|
}
|
|
});
|
|
setHostMap(updatedMap);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}, []);
|
|
|
|
const debouncedSave = useCallback(() => {
|
|
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current);
|
|
saveTimeoutRef.current = setTimeout(() => saveCurrentLayout(), 1000);
|
|
}, []);
|
|
|
|
const saveCurrentLayout = 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 saveNetworkTopology({ nodes, edges });
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
};
|
|
|
|
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();
|
|
}, []);
|
|
|
|
const handleNodeInit = useCallback(
|
|
(cy: cytoscape.Core) => {
|
|
cyRef.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("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 fireOpen = (hostId: string, type: string) => {
|
|
window.dispatchEvent(
|
|
new CustomEvent("termix:open-tab", { detail: { hostId, 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, "terminal");
|
|
} 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 hasTunnelConnections = (h: SSHHostWithStatus | undefined) => {
|
|
if (!h?.tunnelConnections) return false;
|
|
try {
|
|
const arr = Array.isArray(h.tunnelConnections)
|
|
? h.tunnelConnections
|
|
: JSON.parse(h.tunnelConnections as string);
|
|
return Array.isArray(arr) && arr.length > 0;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
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,
|
|
},
|
|
});
|
|
applyStyle(cyRef.current);
|
|
await saveCurrentLayout();
|
|
setElements([...(cyRef.current.elements().jsons() as NetworkElement[])]);
|
|
forceUpdate();
|
|
setShowAddNodeDialog(false);
|
|
} 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 },
|
|
});
|
|
await saveCurrentLayout();
|
|
setElements([...(cyRef.current.elements().jsons() as NetworkElement[])]);
|
|
forceUpdate();
|
|
setShowAddGroupDialog(false);
|
|
setNewGroupName("");
|
|
};
|
|
|
|
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"),
|
|
});
|
|
}
|
|
};
|
|
|
|
const availableGroups = useMemo(
|
|
() =>
|
|
elements
|
|
.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!,
|
|
})),
|
|
[elements],
|
|
);
|
|
|
|
const availableNodesForConnection = useMemo(
|
|
() =>
|
|
elements
|
|
.filter((el) => !el.data.source && !el.data.target)
|
|
.map((el) => ({
|
|
id: el.data.id!,
|
|
label: el.data.label || el.data.id!,
|
|
})),
|
|
[elements],
|
|
);
|
|
|
|
const availableHostsForAdd = useMemo(() => {
|
|
if (!cyRef.current) return hosts;
|
|
const existing = new Set(elements.map((e) => e.data.id));
|
|
return hosts.filter((h) => !existing.has(String(h.id)));
|
|
}, [hosts, elements]);
|
|
|
|
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" && (
|
|
<>
|
|
{hostMap[contextMenu.targetId]?.enableTerminal && (
|
|
<button
|
|
onClick={() => handleConnectAction("terminal")}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
|
|
>
|
|
<Terminal className="size-3 shrink-0" />
|
|
{t("networkGraph.terminal")}
|
|
</button>
|
|
)}
|
|
{hostMap[contextMenu.targetId]?.enableFileManager && (
|
|
<button
|
|
onClick={() => handleConnectAction("files")}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
|
|
>
|
|
<FolderOpen className="size-3 shrink-0" />
|
|
{t("networkGraph.fileManager")}
|
|
</button>
|
|
)}
|
|
{hostMap[contextMenu.targetId]?.enableTunnel &&
|
|
hasTunnelConnections(hostMap[contextMenu.targetId]) && (
|
|
<button
|
|
onClick={() => handleConnectAction("tunnel")}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
|
|
>
|
|
<ArrowDownUp className="size-3 shrink-0" />
|
|
{t("networkGraph.tunnel")}
|
|
</button>
|
|
)}
|
|
{hostMap[contextMenu.targetId]?.enableDocker && (
|
|
<button
|
|
onClick={() => handleConnectAction("docker")}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
|
|
>
|
|
<Container className="size-3 shrink-0" />
|
|
{t("networkGraph.docker")}
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => handleConnectAction("host-metrics")}
|
|
className="flex items-center gap-2 px-3 py-2 text-xs w-full text-left hover:bg-muted transition-colors"
|
|
>
|
|
<Server className="size-3 shrink-0" />
|
|
{t("networkGraph.hostMetrics")}
|
|
</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 && elements.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>
|
|
<select
|
|
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>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>{t("networkGraph.parentGroup")}</Label>
|
|
<select
|
|
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>
|
|
))}
|
|
</select>
|
|
</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={cn(
|
|
"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>
|
|
<select
|
|
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>
|
|
))}
|
|
</select>
|
|
</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>
|
|
<select
|
|
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>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<Label>{t("networkGraph.target")}</Label>
|
|
<select
|
|
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>
|
|
))}
|
|
</select>
|
|
</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 saveNetworkTopology({
|
|
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 = elements.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>
|
|
);
|
|
}
|