mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 04:43:36 +00:00
feat: improve dashboard and commpand pallete
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "termix",
|
||||
"version": "2.2.1",
|
||||
"version": "2.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "termix",
|
||||
"version": "2.2.1",
|
||||
"version": "2.3.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"axios": "^1.15.2",
|
||||
|
||||
@@ -150,7 +150,8 @@ export type TabType =
|
||||
| "user-profile"
|
||||
| "admin-settings"
|
||||
| "docker"
|
||||
| "tunnel";
|
||||
| "tunnel"
|
||||
| "network_graph";
|
||||
|
||||
export type TunnelStatusValue =
|
||||
| "CONNECTED"
|
||||
|
||||
+1
-2
@@ -119,6 +119,7 @@ const SINGLETON_TAB_LABELS: Partial<Record<TabType, string>> = {
|
||||
"host-manager": "Host Manager",
|
||||
docker: "Docker",
|
||||
tunnel: "Tunnels",
|
||||
network_graph: "Network Graph",
|
||||
};
|
||||
|
||||
// ─── AppShell ────────────────────────────────────────────────────────────────
|
||||
@@ -592,8 +593,6 @@ export function AppShell({
|
||||
"host-manager",
|
||||
"user-profile",
|
||||
"admin-settings",
|
||||
"docker",
|
||||
"tunnel",
|
||||
].includes(type)
|
||||
) {
|
||||
openSingletonTab(type, pendingEvent);
|
||||
|
||||
+248
-271
@@ -12,7 +12,6 @@ import {
|
||||
LayoutDashboard,
|
||||
Network,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Settings,
|
||||
Terminal,
|
||||
@@ -20,7 +19,6 @@ import {
|
||||
User,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import CytoscapeComponent from "react-cytoscapejs";
|
||||
import { Kbd } from "@/components/kbd";
|
||||
import { DASHBOARD_CARDS } from "@/lib/theme";
|
||||
import type { DashboardCardId, TabType, Host } from "@/types/ui-types";
|
||||
@@ -33,16 +31,21 @@ import {
|
||||
getTunnelStatuses,
|
||||
getCredentials,
|
||||
resetRecentActivity,
|
||||
getAllServerStatuses,
|
||||
getServerMetricsById,
|
||||
registerMetricsViewer,
|
||||
sendMetricsHeartbeat,
|
||||
} from "@/main-axios";
|
||||
import type { RecentActivityItem, SSHHostWithStatus } from "@/main-axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
|
||||
|
||||
function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
return {
|
||||
id: String(h.id),
|
||||
name: h.name,
|
||||
user: h.username,
|
||||
address: h.ip,
|
||||
username: h.username,
|
||||
ip: h.ip,
|
||||
port: h.port,
|
||||
folder: h.folder ?? "",
|
||||
online: h.status === "online",
|
||||
@@ -365,9 +368,11 @@ function QuickActionsCard({
|
||||
|
||||
function HostStatusCard({
|
||||
hosts,
|
||||
hostMetrics,
|
||||
onOpenTab,
|
||||
}: {
|
||||
hosts: Host[];
|
||||
hostMetrics: Map<string, { cpu: number | null; ram: number | null }>;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -391,79 +396,89 @@ function HostStatusCard({
|
||||
{t("dashboardTab.noHostsConfigured")}
|
||||
</div>
|
||||
)}
|
||||
{hosts.map((host, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onOpenTab(host, "stats")}
|
||||
className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${host.online ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold">{host.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{host.ip}
|
||||
{hosts.map((host, i) => {
|
||||
const metrics = hostMetrics.get(host.id);
|
||||
const cpu = metrics?.cpu ?? null;
|
||||
const ram = metrics?.ram ?? null;
|
||||
const hasMetrics = cpu !== null || ram !== null;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => onOpenTab(host, "stats")}
|
||||
className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`size-1.5 rounded-full shrink-0 ${host.online ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs font-semibold">{host.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{host.ip}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{host.online && hasMetrics ? (
|
||||
<div className="flex items-center gap-3">
|
||||
{cpu !== null && (
|
||||
<div className="flex flex-col gap-0.5 w-16">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("dashboard.cpu")}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-accent-brand">
|
||||
{cpu.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-0.5 bg-muted w-full">
|
||||
<div
|
||||
className="h-full bg-accent-brand"
|
||||
style={{ width: `${cpu}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{ram !== null && (
|
||||
<div className="flex flex-col gap-0.5 w-16">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("dashboard.ram")}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-accent-brand">
|
||||
{ram.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-0.5 bg-muted w-full">
|
||||
<div
|
||||
className="h-full bg-accent-brand"
|
||||
style={{ width: `${ram}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-muted-foreground w-16 text-center">
|
||||
—
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground w-16 text-center">
|
||||
—
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${host.online ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{host.online
|
||||
? t("dashboardTab.online")
|
||||
: t("dashboardTab.offline")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{host.online ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-col gap-0.5 w-16">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("dashboard.cpu")}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-accent-brand">
|
||||
{host.cpu ?? 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-0.5 bg-muted w-full">
|
||||
<div
|
||||
className="h-full bg-accent-brand"
|
||||
style={{ width: `${host.cpu ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 w-16">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{t("dashboard.ram")}
|
||||
</span>
|
||||
<span className="text-[10px] font-bold text-accent-brand">
|
||||
{host.ram ?? 0}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-0.5 bg-muted w-full">
|
||||
<div
|
||||
className="h-full bg-accent-brand"
|
||||
style={{ width: `${host.ram ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-muted-foreground w-16 text-center">
|
||||
—
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground w-16 text-center">
|
||||
—
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span
|
||||
className={`text-[10px] px-2 py-0.5 font-semibold border ${host.online ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" : "border-border text-muted-foreground"}`}
|
||||
>
|
||||
{host.online
|
||||
? t("dashboardTab.online")
|
||||
: t("dashboardTab.offline")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -579,188 +594,6 @@ function RecentActivityCard({
|
||||
);
|
||||
}
|
||||
|
||||
function NetworkGraphCard({ hosts }: { hosts: Host[] }) {
|
||||
const { t } = useTranslation();
|
||||
const cyRef = useRef<any>(null);
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
node: any;
|
||||
} | null>(null);
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const elements = hosts.map((h, i) => ({
|
||||
data: {
|
||||
id: h.id,
|
||||
label: h.name,
|
||||
ip: `${h.ip}:${h.port ?? 22}`,
|
||||
status: h.online ? "online" : "offline",
|
||||
},
|
||||
position: { x: 120 + (i % 4) * 160, y: 80 + Math.floor(i / 4) * 100 },
|
||||
}));
|
||||
|
||||
const buildNodeStyle = useCallback((ele: any) => {
|
||||
const isOnline = ele.data("status") === "online";
|
||||
const name = ele.data("label") || "";
|
||||
const ip = ele.data("ip") || "";
|
||||
const statusColor = isOnline ? "rgb(251,146,60)" : "rgb(100,116,139)";
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="160" height="72" viewBox="0 0 160 72">
|
||||
<defs><filter id="sh" x="-15%" y="-15%" width="130%" height="130%">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#000" flood-opacity="0.4"/>
|
||||
</filter></defs>
|
||||
<rect x="2" y="2" width="156" height="68" rx="4" fill="#09090b" stroke="${statusColor}" stroke-width="1.5" filter="url(#sh)"/>
|
||||
<circle cx="18" cy="36" r="4" fill="${statusColor}" opacity="0.9"/>
|
||||
<text x="32" y="30" font-family="monospace" font-size="12" font-weight="700" fill="#f1f5f9">${name}</text>
|
||||
<text x="32" y="48" font-family="monospace" font-size="10" fill="#64748b">${ip}</text>
|
||||
</svg>`;
|
||||
return "data:image/svg+xml;utf8," + encodeURIComponent(svg);
|
||||
}, []);
|
||||
|
||||
const handleCyInit = useCallback(
|
||||
(cy: any) => {
|
||||
cyRef.current = cy;
|
||||
cy.style()
|
||||
.selector("node")
|
||||
.style({
|
||||
label: "",
|
||||
width: "160px",
|
||||
height: "72px",
|
||||
shape: "round-rectangle",
|
||||
"border-width": "0px",
|
||||
"background-opacity": 0,
|
||||
"background-image": buildNodeStyle,
|
||||
"background-fit": "contain",
|
||||
})
|
||||
.selector("edge")
|
||||
.style({
|
||||
width: "1.5px",
|
||||
"line-color": "#2a2a2c",
|
||||
"curve-style": "bezier",
|
||||
"target-arrow-shape": "none",
|
||||
})
|
||||
.selector("node:selected")
|
||||
.style({
|
||||
"overlay-color": "#fb923c",
|
||||
"overlay-opacity": 0.08,
|
||||
"overlay-padding": "4px",
|
||||
})
|
||||
.update();
|
||||
cy.nodes().ungrabify();
|
||||
cy.on("tap", (evt: any) => {
|
||||
if (evt.target === cy) setContextMenu(null);
|
||||
});
|
||||
cy.on("cxttap tap", "node", (evt: any) => {
|
||||
evt.stopPropagation();
|
||||
const node = evt.target;
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x: evt.originalEvent.clientX,
|
||||
y: evt.originalEvent.clientY,
|
||||
node: node.data(),
|
||||
});
|
||||
});
|
||||
cy.on("zoom pan", () => setContextMenu(null));
|
||||
},
|
||||
[buildNodeStyle],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (
|
||||
contextMenuRef.current &&
|
||||
!contextMenuRef.current.contains(e.target as Node)
|
||||
)
|
||||
setContextMenu(null);
|
||||
};
|
||||
document.addEventListener("mousedown", handler, true);
|
||||
return () => document.removeEventListener("mousedown", handler, true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col overflow-hidden w-full h-full py-0 gap-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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("dashboardTab.nodes", { count: hosts.length })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-auto py-0.5 px-2"
|
||||
onClick={() => cyRef.current?.fit()}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex-1 min-h-0 overflow-hidden">
|
||||
{contextMenu?.visible && (
|
||||
<div
|
||||
ref={contextMenuRef}
|
||||
className="fixed z-[200] min-w-[160px] shadow-2xl p-1 flex flex-col gap-0.5 bg-card border border-border"
|
||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
||||
>
|
||||
<div className="px-3 py-1.5 border-b border-border mb-0.5">
|
||||
<span className="text-xs font-bold font-mono">
|
||||
{contextMenu.node.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground block">
|
||||
{contextMenu.node.ip}
|
||||
</span>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted transition-colors text-left w-full">
|
||||
<Terminal className="size-3" />
|
||||
{t("networkGraph.terminal")}
|
||||
</button>
|
||||
<button className="flex items-center gap-2 px-3 py-1.5 text-xs hover:bg-muted transition-colors text-left w-full">
|
||||
<Server className="size-3" />
|
||||
{t("networkGraph.serverStats")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{hosts.length > 0 ? (
|
||||
<CytoscapeComponent
|
||||
elements={elements}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
layout={
|
||||
{ name: "grid", rows: Math.ceil(Math.sqrt(hosts.length)) } as any
|
||||
}
|
||||
cy={handleCyInit}
|
||||
wheelSensitivity={1.5}
|
||||
minZoom={0.3}
|
||||
maxZoom={2.5}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground/40 h-full">
|
||||
{t("dashboardTab.noHostsToDisplay")}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 left-3 flex items-center gap-3 pointer-events-none">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-1.5 rounded-full bg-accent-brand inline-block" />
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{t("dashboardTab.onlineLower")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="size-1.5 rounded-full bg-muted-foreground/50 inline-block" />
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{t("dashboardTab.offlineLower")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CardItem ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function CardItem({
|
||||
@@ -775,6 +608,7 @@ function CardItem({
|
||||
onOpenSingletonTab,
|
||||
onOpenTab,
|
||||
hosts,
|
||||
hostMetrics,
|
||||
uptimeFormatted,
|
||||
versionText,
|
||||
versionStatus,
|
||||
@@ -795,6 +629,7 @@ function CardItem({
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
hosts: Host[];
|
||||
hostMetrics: Map<string, { cpu: number | null; ram: number | null }>;
|
||||
uptimeFormatted: string;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||
@@ -874,7 +709,11 @@ function CardItem({
|
||||
<QuickActionsCard onOpenSingletonTab={onOpenSingletonTab} />
|
||||
)}
|
||||
{slot.id === "host_status" && (
|
||||
<HostStatusCard hosts={hosts} onOpenTab={onOpenTab} />
|
||||
<HostStatusCard
|
||||
hosts={hosts}
|
||||
hostMetrics={hostMetrics}
|
||||
onOpenTab={onOpenTab}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "recent_activity" && (
|
||||
<RecentActivityCard
|
||||
@@ -884,7 +723,12 @@ function CardItem({
|
||||
onClear={onClearActivity}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "network_graph" && <NetworkGraphCard hosts={hosts} />}
|
||||
{slot.id === "network_graph" && (
|
||||
<NetworkGraphCard
|
||||
embedded={true}
|
||||
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{editMode && !isFlex && (
|
||||
<div
|
||||
@@ -981,6 +825,7 @@ type PanelColumnProps = {
|
||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
hosts: Host[];
|
||||
hostMetrics: Map<string, { cpu: number | null; ram: number | null }>;
|
||||
uptimeFormatted: string;
|
||||
versionText: string;
|
||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||
@@ -1006,6 +851,7 @@ function PanelColumn({
|
||||
onOpenSingletonTab,
|
||||
onOpenTab,
|
||||
hosts,
|
||||
hostMetrics,
|
||||
uptimeFormatted,
|
||||
versionText,
|
||||
versionStatus,
|
||||
@@ -1057,6 +903,7 @@ function PanelColumn({
|
||||
onOpenSingletonTab={onOpenSingletonTab}
|
||||
onOpenTab={onOpenTab}
|
||||
hosts={hosts}
|
||||
hostMetrics={hostMetrics}
|
||||
uptimeFormatted={uptimeFormatted}
|
||||
versionText={versionText}
|
||||
versionStatus={versionStatus}
|
||||
@@ -1120,10 +967,45 @@ export function DashboardTab({
|
||||
onOpenTab: (host: Host, type: TabType) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [slots, setSlots] = useState<CardSlot[]>(DEFAULT_SLOTS);
|
||||
|
||||
const [slots, setSlots] = useState<CardSlot[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("dashboardTab.slots");
|
||||
if (saved) return JSON.parse(saved) as CardSlot[];
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return DEFAULT_SLOTS;
|
||||
});
|
||||
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [dragState, setDragState] = useState<DragState>(null);
|
||||
const [mainWidthPct, setMainWidthPct] = useState(68);
|
||||
|
||||
const [mainWidthPct, setMainWidthPct] = useState(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem("dashboardTab.mainWidthPct");
|
||||
if (saved) return Number(saved);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 68;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("dashboardTab.slots", JSON.stringify(slots));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [slots]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem("dashboardTab.mainWidthPct", String(mainWidthPct));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [mainWidthPct]);
|
||||
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [uptimeFormatted, setUptimeFormatted] = useState("");
|
||||
@@ -1135,11 +1017,70 @@ export function DashboardTab({
|
||||
const [credentialCount, setCredentialCount] = useState(0);
|
||||
const [activeTunnelCount, setActiveTunnelCount] = useState(0);
|
||||
const [activity, setActivity] = useState<RecentActivityItem[]>([]);
|
||||
const [hostMetrics, setHostMetrics] = useState<
|
||||
Map<string, { cpu: number | null; ram: number | null }>
|
||||
>(new Map());
|
||||
const viewerSessionsRef = useRef<Map<number, string>>(new Map());
|
||||
|
||||
const fetchMetrics = useCallback(async (hostList: Host[]) => {
|
||||
let statuses: Record<number, { status?: string }> = {};
|
||||
try {
|
||||
statuses = (await getAllServerStatuses()) as Record<
|
||||
number,
|
||||
{ status?: string }
|
||||
>;
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
|
||||
const newSessions = new Map<number, string>(viewerSessionsRef.current);
|
||||
const results = await Promise.all(
|
||||
hostList.map(async (host) => {
|
||||
const hostId = Number(host.id);
|
||||
const knownStatus = statuses?.[hostId]?.status;
|
||||
if (knownStatus === "offline") return null;
|
||||
if (host.authType === "none" || host.authType === "opkssh") return null;
|
||||
|
||||
try {
|
||||
const existing = newSessions.get(hostId);
|
||||
if (!existing) {
|
||||
const reg = await registerMetricsViewer(hostId);
|
||||
if (reg.skipped) return null;
|
||||
if (reg.success && reg.viewerSessionId) {
|
||||
newSessions.set(hostId, reg.viewerSessionId);
|
||||
}
|
||||
}
|
||||
const metrics = await getServerMetricsById(hostId);
|
||||
if (!metrics) return null;
|
||||
return {
|
||||
id: host.id,
|
||||
cpu: metrics.cpu?.percent ?? null,
|
||||
ram: metrics.memory?.percent ?? null,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
viewerSessionsRef.current = newSessions;
|
||||
const map = new Map<string, { cpu: number | null; ram: number | null }>();
|
||||
for (const r of results) {
|
||||
if (r) map.set(r.id, { cpu: r.cpu, ram: r.ram });
|
||||
}
|
||||
setHostMetrics(map);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getSSHHosts()
|
||||
.then((raw) => setHosts(raw.map(sshHostToHost)))
|
||||
.catch(() => {});
|
||||
let mounted = true;
|
||||
const load = async () => {
|
||||
const raw = await getSSHHosts().catch(() => []);
|
||||
const mapped = raw.map(sshHostToHost);
|
||||
if (mounted) setHosts(mapped);
|
||||
fetchMetrics(mapped).catch(() => {});
|
||||
};
|
||||
load();
|
||||
|
||||
getUptime()
|
||||
.then((u) => setUptimeFormatted(u.formatted))
|
||||
.catch(() => {});
|
||||
@@ -1178,7 +1119,29 @@ export function DashboardTab({
|
||||
setActiveTunnelCount(active);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const metricsInterval = setInterval(async () => {
|
||||
const raw = await getSSHHosts().catch(() => []);
|
||||
const mapped = raw.map(sshHostToHost);
|
||||
if (mounted) setHosts(mapped);
|
||||
fetchMetrics(mapped).catch(() => {});
|
||||
}, 30000);
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
clearInterval(metricsInterval);
|
||||
};
|
||||
}, [fetchMetrics]);
|
||||
|
||||
useEffect(() => {
|
||||
if (viewerSessionsRef.current.size === 0) return;
|
||||
const heartbeat = setInterval(async () => {
|
||||
for (const [, sessionId] of viewerSessionsRef.current) {
|
||||
sendMetricsHeartbeat(sessionId).catch(() => {});
|
||||
}
|
||||
}, 30000);
|
||||
return () => clearInterval(heartbeat);
|
||||
}, [hostMetrics]);
|
||||
|
||||
const handleClearActivity = async () => {
|
||||
try {
|
||||
@@ -1280,11 +1243,11 @@ export function DashboardTab({
|
||||
? Math.max(...panelSlots.map((s) => s.order)) + 1
|
||||
: 0;
|
||||
const defaultHeight: number | null =
|
||||
id === "host_status" ||
|
||||
id === "recent_activity" ||
|
||||
id === "network_graph"
|
||||
? null
|
||||
: 150;
|
||||
? 350
|
||||
: id === "host_status" || id === "recent_activity"
|
||||
? null
|
||||
: 150;
|
||||
return [...prev, { id, panel, order: maxOrder, height: defaultHeight }];
|
||||
});
|
||||
};
|
||||
@@ -1294,12 +1257,19 @@ export function DashboardTab({
|
||||
);
|
||||
const handleReset = () => {
|
||||
setSlots(DEFAULT_SLOTS);
|
||||
setMainWidthPct(72);
|
||||
setMainWidthPct(68);
|
||||
setEditMode(false);
|
||||
try {
|
||||
localStorage.removeItem("dashboardTab.slots");
|
||||
localStorage.removeItem("dashboardTab.mainWidthPct");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const columnProps = {
|
||||
hosts,
|
||||
hostMetrics,
|
||||
uptimeFormatted,
|
||||
versionText,
|
||||
versionStatus,
|
||||
@@ -1397,7 +1367,11 @@ export function DashboardTab({
|
||||
<QuickActionsCard onOpenSingletonTab={onOpenSingletonTab} />
|
||||
)}
|
||||
{slot.id === "host_status" && (
|
||||
<HostStatusCard hosts={hosts} onOpenTab={onOpenTab} />
|
||||
<HostStatusCard
|
||||
hosts={hosts}
|
||||
hostMetrics={hostMetrics}
|
||||
onOpenTab={onOpenTab}
|
||||
/>
|
||||
)}
|
||||
{slot.id === "recent_activity" && (
|
||||
<RecentActivityCard
|
||||
@@ -1408,7 +1382,10 @@ export function DashboardTab({
|
||||
/>
|
||||
)}
|
||||
{slot.id === "network_graph" && (
|
||||
<NetworkGraphCard hosts={hosts} />
|
||||
<NetworkGraphCard
|
||||
embedded={true}
|
||||
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
+1035
-1269
File diff suppressed because it is too large
Load Diff
@@ -2870,7 +2870,8 @@
|
||||
"fileManager": "File Manager",
|
||||
"tunnel": "Tunnel",
|
||||
"docker": "Docker",
|
||||
"serverStats": "Server Stats"
|
||||
"serverStats": "Server Stats",
|
||||
"noNodes": "No nodes yet — add hosts to build your topology"
|
||||
},
|
||||
"rbac": {
|
||||
"shareHost": "Share Host",
|
||||
|
||||
+189
-151
@@ -13,30 +13,23 @@ import {
|
||||
Settings,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
FolderSearch,
|
||||
Box,
|
||||
Globe,
|
||||
Plus,
|
||||
MessagesSquare,
|
||||
LifeBuoy,
|
||||
DollarSign,
|
||||
Search,
|
||||
Activity,
|
||||
Network,
|
||||
MoreHorizontal,
|
||||
Edit3,
|
||||
User,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Monitor,
|
||||
Clock,
|
||||
Folder,
|
||||
Pencil,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu";
|
||||
import { getRecentActivity, type RecentActivityItem } from "@/main-axios";
|
||||
import type { Host } from "@/types/ui-types";
|
||||
|
||||
@@ -69,6 +62,29 @@ const ACTIVITY_TAB_TYPE: Record<string, string> = {
|
||||
rdp: "rdp",
|
||||
};
|
||||
|
||||
function getSshActions(host: Host) {
|
||||
const metricsEnabled = host.statsConfig?.metricsEnabled !== false;
|
||||
return [
|
||||
host.enableTerminal !== false && {
|
||||
type: "terminal",
|
||||
icon: Terminal,
|
||||
label: "Terminal",
|
||||
},
|
||||
host.enableFileManager && {
|
||||
type: "files",
|
||||
icon: FolderSearch,
|
||||
label: "Files",
|
||||
},
|
||||
host.enableDocker && { type: "docker", icon: Box, label: "Docker" },
|
||||
host.enableTunnel && { type: "tunnel", icon: Network, label: "Tunnels" },
|
||||
metricsEnabled && { type: "stats", icon: Activity, label: "Stats" },
|
||||
].filter(Boolean) as {
|
||||
type: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
@@ -108,6 +124,24 @@ export function CommandPalette({
|
||||
h.username.toLowerCase().includes(search.toLowerCase()),
|
||||
);
|
||||
|
||||
// Group hosts by folder; ungrouped hosts appear first under an implicit root group
|
||||
const groupedHosts: { folder: string | null; hosts: Host[] }[] = [];
|
||||
const folderMap = new Map<string, Host[]>();
|
||||
const ungrouped: Host[] = [];
|
||||
for (const h of filteredHosts) {
|
||||
if (h.folder) {
|
||||
if (!folderMap.has(h.folder)) folderMap.set(h.folder, []);
|
||||
folderMap.get(h.folder)!.push(h);
|
||||
} else {
|
||||
ungrouped.push(h);
|
||||
}
|
||||
}
|
||||
if (ungrouped.length > 0)
|
||||
groupedHosts.push({ folder: null, hosts: ungrouped });
|
||||
for (const [folder, fhosts] of folderMap) {
|
||||
groupedHosts.push({ folder, hosts: fhosts });
|
||||
}
|
||||
|
||||
const handleAction = (action: () => void) => {
|
||||
action();
|
||||
setIsOpen(false);
|
||||
@@ -283,145 +317,135 @@ export function CommandPalette({
|
||||
|
||||
<CommandGroup heading="Servers & Hosts" className="px-2">
|
||||
{filteredHosts.length > 0 ? (
|
||||
filteredHosts.map((host, i) => {
|
||||
const actions = [
|
||||
host.enableSsh &&
|
||||
host.enableTerminal !== false && {
|
||||
type: "terminal",
|
||||
icon: <Terminal className="size-3" />,
|
||||
label: "Terminal",
|
||||
},
|
||||
host.enableSsh &&
|
||||
host.enableFileManager && {
|
||||
type: "files",
|
||||
icon: <FolderOpen className="size-3" />,
|
||||
label: "Files",
|
||||
},
|
||||
host.enableSsh &&
|
||||
host.enableDocker && {
|
||||
type: "docker",
|
||||
icon: <Box className="size-3" />,
|
||||
label: "Docker",
|
||||
},
|
||||
host.enableSsh &&
|
||||
host.enableTunnel && {
|
||||
type: "tunnel",
|
||||
icon: <Network className="size-3" />,
|
||||
label: "Tunnels",
|
||||
},
|
||||
host.enableSsh && {
|
||||
type: "stats",
|
||||
icon: <Activity className="size-3" />,
|
||||
label: "Stats",
|
||||
},
|
||||
host.enableRdp && {
|
||||
type: "rdp",
|
||||
icon: <Monitor className="size-3" />,
|
||||
label: "RDP",
|
||||
},
|
||||
host.enableVnc && {
|
||||
type: "vnc",
|
||||
icon: <Monitor className="size-3" />,
|
||||
label: "VNC",
|
||||
},
|
||||
host.enableTelnet && {
|
||||
type: "telnet",
|
||||
icon: <Terminal className="size-3" />,
|
||||
label: "Telnet",
|
||||
},
|
||||
].filter(Boolean) as {
|
||||
type: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
}[];
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={i}
|
||||
onSelect={() =>
|
||||
handleAction(() => onOpenTab("terminal", host.name))
|
||||
}
|
||||
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors shrink-0">
|
||||
<Server
|
||||
className={cn(
|
||||
"size-4",
|
||||
host.online
|
||||
? "text-accent-brand"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
groupedHosts.map(({ folder, hosts: groupHosts }) => (
|
||||
<div key={folder ?? "__root__"}>
|
||||
{folder && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wide">
|
||||
<Folder className="size-3" />
|
||||
{folder}
|
||||
</div>
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold truncate">
|
||||
{host.name}
|
||||
</span>
|
||||
{host.online && (
|
||||
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
|
||||
)}
|
||||
)}
|
||||
{groupHosts.map((host, i) => (
|
||||
<CommandItem
|
||||
key={i}
|
||||
onSelect={() =>
|
||||
handleAction(() => onOpenTab("terminal", host.name))
|
||||
}
|
||||
className="group flex items-center gap-3 px-3 py-2.5 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<div className="size-8 rounded-none bg-muted flex items-center justify-center group-hover:bg-accent-brand/20 transition-colors shrink-0">
|
||||
<Server
|
||||
className={cn(
|
||||
"size-4",
|
||||
host.online
|
||||
? "text-accent-brand"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{host.username}@{host.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action.type}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={action.label}
|
||||
className="size-7 rounded-none hover:bg-accent-brand/20 hover:text-accent-brand"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(() =>
|
||||
onOpenTab(action.type as any, host.name),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{action.icon}
|
||||
</Button>
|
||||
))}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7 rounded-none hover:bg-muted"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreHorizontal className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="rounded-none border-border bg-card w-40"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="rounded-none text-xs font-semibold hover:bg-accent-brand/10 hover:text-accent-brand cursor-pointer"
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold truncate">
|
||||
{host.name}
|
||||
</span>
|
||||
{host.online && (
|
||||
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{host.username}@{host.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{host.enableSsh &&
|
||||
getSshActions(host).map(
|
||||
({ type, icon: Icon, label }) => (
|
||||
<button
|
||||
key={type}
|
||||
title={label}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(() =>
|
||||
onOpenTab(type as any, host.name),
|
||||
);
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
{host.enableSsh &&
|
||||
(host.enableRdp ||
|
||||
host.enableVnc ||
|
||||
host.enableTelnet) && (
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
)}
|
||||
{host.enableRdp && (
|
||||
<button
|
||||
title="RDP"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(false);
|
||||
onOpenTab("host-manager");
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:edit-host", {
|
||||
detail: host.id,
|
||||
}),
|
||||
);
|
||||
}, 100);
|
||||
handleAction(() => onOpenTab("rdp", host.name));
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
|
||||
>
|
||||
<Edit3 className="size-3.5 mr-2" /> Edit Host
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})
|
||||
<Monitor className="size-3" />
|
||||
RDP
|
||||
</button>
|
||||
)}
|
||||
{host.enableVnc && (
|
||||
<button
|
||||
title="VNC"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(() => onOpenTab("vnc", host.name));
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
|
||||
>
|
||||
<Monitor className="size-3" />
|
||||
VNC
|
||||
</button>
|
||||
)}
|
||||
{host.enableTelnet && (
|
||||
<button
|
||||
title="Telnet"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction(() =>
|
||||
onOpenTab("telnet", host.name),
|
||||
);
|
||||
}}
|
||||
className="flex items-center gap-1 px-2 h-6 rounded text-xs font-medium text-muted-foreground/70 hover:text-foreground hover:bg-muted-foreground/10 transition-colors border border-border/40"
|
||||
>
|
||||
<Terminal className="size-3" />
|
||||
Telnet
|
||||
</button>
|
||||
)}
|
||||
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||
<button
|
||||
title="Edit Host"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsOpen(false);
|
||||
onOpenTab("host-manager");
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("host-manager:edit-host", {
|
||||
detail: host.id,
|
||||
}),
|
||||
);
|
||||
}, 100);
|
||||
}}
|
||||
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
No hosts found matching “{search}”
|
||||
@@ -432,29 +456,43 @@ export function CommandPalette({
|
||||
<CommandSeparator className="my-2" />
|
||||
|
||||
<CommandGroup heading="Links" className="px-2">
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
<CommandItem
|
||||
onSelect={() => window.open("https://github.com", "_blank")}
|
||||
onSelect={() =>
|
||||
window.open(
|
||||
"https://github.com/Termix-SSH/Termix",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<Globe className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">GitHub</span>
|
||||
</CommandItem>
|
||||
<CommandItem
|
||||
onSelect={() => window.open("https://discord.com", "_blank")}
|
||||
onSelect={() =>
|
||||
window.open(
|
||||
"https://discord.com/invite/jVQGdvHDrf",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<MessagesSquare className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Discord</span>
|
||||
</CommandItem>
|
||||
<CommandItem className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer">
|
||||
<CommandItem
|
||||
onSelect={() =>
|
||||
window.open(
|
||||
"https://github.com/Termix-SSH/Support/issues/new",
|
||||
"_blank",
|
||||
)
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer"
|
||||
>
|
||||
<LifeBuoy className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Support</span>
|
||||
</CommandItem>
|
||||
<CommandItem className="flex items-center gap-3 px-3 py-2 rounded-none hover:bg-accent-brand/10 cursor-pointer">
|
||||
<DollarSign className="size-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">Donate</span>
|
||||
</CommandItem>
|
||||
</div>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ServerStats } from "@/features/server-stats/ServerStats";
|
||||
import GuacamoleApp from "@/features/guacamole/GuacamoleApp";
|
||||
import { DashboardTab } from "@/dashboard/DashboardTab";
|
||||
import { TunnelTab } from "@/features/tunnel/TunnelTab";
|
||||
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
|
||||
import type { Tab, TabType, Host } from "@/types/ui-types";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
@@ -103,6 +104,8 @@ export function tabIcon(type: TabType) {
|
||||
return <Box className="size-3.5" />;
|
||||
case "tunnel":
|
||||
return <Network className="size-3.5" />;
|
||||
case "network_graph":
|
||||
return <Network className="size-3.5" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +201,9 @@ export function renderTabContent(
|
||||
);
|
||||
return <GuacamoleApp hostId={host.id} />;
|
||||
|
||||
case "network_graph":
|
||||
return <NetworkGraphCard embedded={false} />;
|
||||
|
||||
case "host-manager":
|
||||
case "user-profile":
|
||||
case "admin-settings":
|
||||
|
||||
Reference in New Issue
Block a user