mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-15 21:03:47 +00:00
feat: improve dashboard and commpand pallete
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "2.2.1",
|
"version": "2.3.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "termix",
|
"name": "termix",
|
||||||
"version": "2.2.1",
|
"version": "2.3.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.15.2",
|
"axios": "^1.15.2",
|
||||||
|
|||||||
@@ -150,7 +150,8 @@ export type TabType =
|
|||||||
| "user-profile"
|
| "user-profile"
|
||||||
| "admin-settings"
|
| "admin-settings"
|
||||||
| "docker"
|
| "docker"
|
||||||
| "tunnel";
|
| "tunnel"
|
||||||
|
| "network_graph";
|
||||||
|
|
||||||
export type TunnelStatusValue =
|
export type TunnelStatusValue =
|
||||||
| "CONNECTED"
|
| "CONNECTED"
|
||||||
|
|||||||
+1
-2
@@ -119,6 +119,7 @@ const SINGLETON_TAB_LABELS: Partial<Record<TabType, string>> = {
|
|||||||
"host-manager": "Host Manager",
|
"host-manager": "Host Manager",
|
||||||
docker: "Docker",
|
docker: "Docker",
|
||||||
tunnel: "Tunnels",
|
tunnel: "Tunnels",
|
||||||
|
network_graph: "Network Graph",
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── AppShell ────────────────────────────────────────────────────────────────
|
// ─── AppShell ────────────────────────────────────────────────────────────────
|
||||||
@@ -592,8 +593,6 @@ export function AppShell({
|
|||||||
"host-manager",
|
"host-manager",
|
||||||
"user-profile",
|
"user-profile",
|
||||||
"admin-settings",
|
"admin-settings",
|
||||||
"docker",
|
|
||||||
"tunnel",
|
|
||||||
].includes(type)
|
].includes(type)
|
||||||
) {
|
) {
|
||||||
openSingletonTab(type, pendingEvent);
|
openSingletonTab(type, pendingEvent);
|
||||||
|
|||||||
+248
-271
@@ -12,7 +12,6 @@ import {
|
|||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Network,
|
Network,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Terminal,
|
Terminal,
|
||||||
@@ -20,7 +19,6 @@ import {
|
|||||||
User,
|
User,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import CytoscapeComponent from "react-cytoscapejs";
|
|
||||||
import { Kbd } from "@/components/kbd";
|
import { Kbd } from "@/components/kbd";
|
||||||
import { DASHBOARD_CARDS } from "@/lib/theme";
|
import { DASHBOARD_CARDS } from "@/lib/theme";
|
||||||
import type { DashboardCardId, TabType, Host } from "@/types/ui-types";
|
import type { DashboardCardId, TabType, Host } from "@/types/ui-types";
|
||||||
@@ -33,16 +31,21 @@ import {
|
|||||||
getTunnelStatuses,
|
getTunnelStatuses,
|
||||||
getCredentials,
|
getCredentials,
|
||||||
resetRecentActivity,
|
resetRecentActivity,
|
||||||
|
getAllServerStatuses,
|
||||||
|
getServerMetricsById,
|
||||||
|
registerMetricsViewer,
|
||||||
|
sendMetricsHeartbeat,
|
||||||
} from "@/main-axios";
|
} from "@/main-axios";
|
||||||
import type { RecentActivityItem, SSHHostWithStatus } from "@/main-axios";
|
import type { RecentActivityItem, SSHHostWithStatus } from "@/main-axios";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
|
||||||
|
|
||||||
function sshHostToHost(h: SSHHostWithStatus): Host {
|
function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||||
return {
|
return {
|
||||||
id: String(h.id),
|
id: String(h.id),
|
||||||
name: h.name,
|
name: h.name,
|
||||||
user: h.username,
|
username: h.username,
|
||||||
address: h.ip,
|
ip: h.ip,
|
||||||
port: h.port,
|
port: h.port,
|
||||||
folder: h.folder ?? "",
|
folder: h.folder ?? "",
|
||||||
online: h.status === "online",
|
online: h.status === "online",
|
||||||
@@ -365,9 +368,11 @@ function QuickActionsCard({
|
|||||||
|
|
||||||
function HostStatusCard({
|
function HostStatusCard({
|
||||||
hosts,
|
hosts,
|
||||||
|
hostMetrics,
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
}: {
|
}: {
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
|
hostMetrics: Map<string, { cpu: number | null; ram: number | null }>;
|
||||||
onOpenTab: (host: Host, type: TabType) => void;
|
onOpenTab: (host: Host, type: TabType) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -391,79 +396,89 @@ function HostStatusCard({
|
|||||||
{t("dashboardTab.noHostsConfigured")}
|
{t("dashboardTab.noHostsConfigured")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hosts.map((host, i) => (
|
{hosts.map((host, i) => {
|
||||||
<div
|
const metrics = hostMetrics.get(host.id);
|
||||||
key={i}
|
const cpu = metrics?.cpu ?? null;
|
||||||
onClick={() => onOpenTab(host, "stats")}
|
const ram = metrics?.ram ?? null;
|
||||||
className="flex items-center justify-between px-4 py-2.5 border-b border-border last:border-0 hover:bg-muted/50 cursor-pointer"
|
const hasMetrics = cpu !== null || ram !== null;
|
||||||
>
|
return (
|
||||||
<div className="flex items-center gap-2.5">
|
<div
|
||||||
<span
|
key={i}
|
||||||
className={`size-1.5 rounded-full shrink-0 ${host.online ? "bg-accent-brand" : "bg-muted-foreground/40"}`}
|
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 flex-col">
|
>
|
||||||
<span className="text-xs font-semibold">{host.name}</span>
|
<div className="flex items-center gap-2.5">
|
||||||
<span className="text-[10px] text-muted-foreground font-mono">
|
<span
|
||||||
{host.ip}
|
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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</Card>
|
</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 ─────────────────────────────────────────────────────────────────
|
// ─── CardItem ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CardItem({
|
function CardItem({
|
||||||
@@ -775,6 +608,7 @@ function CardItem({
|
|||||||
onOpenSingletonTab,
|
onOpenSingletonTab,
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
hosts,
|
hosts,
|
||||||
|
hostMetrics,
|
||||||
uptimeFormatted,
|
uptimeFormatted,
|
||||||
versionText,
|
versionText,
|
||||||
versionStatus,
|
versionStatus,
|
||||||
@@ -795,6 +629,7 @@ function CardItem({
|
|||||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||||
onOpenTab: (host: Host, type: TabType) => void;
|
onOpenTab: (host: Host, type: TabType) => void;
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
|
hostMetrics: Map<string, { cpu: number | null; ram: number | null }>;
|
||||||
uptimeFormatted: string;
|
uptimeFormatted: string;
|
||||||
versionText: string;
|
versionText: string;
|
||||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||||
@@ -874,7 +709,11 @@ function CardItem({
|
|||||||
<QuickActionsCard onOpenSingletonTab={onOpenSingletonTab} />
|
<QuickActionsCard onOpenSingletonTab={onOpenSingletonTab} />
|
||||||
)}
|
)}
|
||||||
{slot.id === "host_status" && (
|
{slot.id === "host_status" && (
|
||||||
<HostStatusCard hosts={hosts} onOpenTab={onOpenTab} />
|
<HostStatusCard
|
||||||
|
hosts={hosts}
|
||||||
|
hostMetrics={hostMetrics}
|
||||||
|
onOpenTab={onOpenTab}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{slot.id === "recent_activity" && (
|
{slot.id === "recent_activity" && (
|
||||||
<RecentActivityCard
|
<RecentActivityCard
|
||||||
@@ -884,7 +723,12 @@ function CardItem({
|
|||||||
onClear={onClearActivity}
|
onClear={onClearActivity}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{slot.id === "network_graph" && <NetworkGraphCard hosts={hosts} />}
|
{slot.id === "network_graph" && (
|
||||||
|
<NetworkGraphCard
|
||||||
|
embedded={true}
|
||||||
|
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{editMode && !isFlex && (
|
{editMode && !isFlex && (
|
||||||
<div
|
<div
|
||||||
@@ -981,6 +825,7 @@ type PanelColumnProps = {
|
|||||||
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
onOpenSingletonTab: (type: TabType, pendingEvent?: string) => void;
|
||||||
onOpenTab: (host: Host, type: TabType) => void;
|
onOpenTab: (host: Host, type: TabType) => void;
|
||||||
hosts: Host[];
|
hosts: Host[];
|
||||||
|
hostMetrics: Map<string, { cpu: number | null; ram: number | null }>;
|
||||||
uptimeFormatted: string;
|
uptimeFormatted: string;
|
||||||
versionText: string;
|
versionText: string;
|
||||||
versionStatus: "up_to_date" | "requires_update" | "beta";
|
versionStatus: "up_to_date" | "requires_update" | "beta";
|
||||||
@@ -1006,6 +851,7 @@ function PanelColumn({
|
|||||||
onOpenSingletonTab,
|
onOpenSingletonTab,
|
||||||
onOpenTab,
|
onOpenTab,
|
||||||
hosts,
|
hosts,
|
||||||
|
hostMetrics,
|
||||||
uptimeFormatted,
|
uptimeFormatted,
|
||||||
versionText,
|
versionText,
|
||||||
versionStatus,
|
versionStatus,
|
||||||
@@ -1057,6 +903,7 @@ function PanelColumn({
|
|||||||
onOpenSingletonTab={onOpenSingletonTab}
|
onOpenSingletonTab={onOpenSingletonTab}
|
||||||
onOpenTab={onOpenTab}
|
onOpenTab={onOpenTab}
|
||||||
hosts={hosts}
|
hosts={hosts}
|
||||||
|
hostMetrics={hostMetrics}
|
||||||
uptimeFormatted={uptimeFormatted}
|
uptimeFormatted={uptimeFormatted}
|
||||||
versionText={versionText}
|
versionText={versionText}
|
||||||
versionStatus={versionStatus}
|
versionStatus={versionStatus}
|
||||||
@@ -1120,10 +967,45 @@ export function DashboardTab({
|
|||||||
onOpenTab: (host: Host, type: TabType) => void;
|
onOpenTab: (host: Host, type: TabType) => void;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
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 [editMode, setEditMode] = useState(false);
|
||||||
const [dragState, setDragState] = useState<DragState>(null);
|
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 [hosts, setHosts] = useState<Host[]>([]);
|
||||||
const [uptimeFormatted, setUptimeFormatted] = useState("");
|
const [uptimeFormatted, setUptimeFormatted] = useState("");
|
||||||
@@ -1135,11 +1017,70 @@ export function DashboardTab({
|
|||||||
const [credentialCount, setCredentialCount] = useState(0);
|
const [credentialCount, setCredentialCount] = useState(0);
|
||||||
const [activeTunnelCount, setActiveTunnelCount] = useState(0);
|
const [activeTunnelCount, setActiveTunnelCount] = useState(0);
|
||||||
const [activity, setActivity] = useState<RecentActivityItem[]>([]);
|
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(() => {
|
useEffect(() => {
|
||||||
getSSHHosts()
|
let mounted = true;
|
||||||
.then((raw) => setHosts(raw.map(sshHostToHost)))
|
const load = async () => {
|
||||||
.catch(() => {});
|
const raw = await getSSHHosts().catch(() => []);
|
||||||
|
const mapped = raw.map(sshHostToHost);
|
||||||
|
if (mounted) setHosts(mapped);
|
||||||
|
fetchMetrics(mapped).catch(() => {});
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
|
||||||
getUptime()
|
getUptime()
|
||||||
.then((u) => setUptimeFormatted(u.formatted))
|
.then((u) => setUptimeFormatted(u.formatted))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -1178,7 +1119,29 @@ export function DashboardTab({
|
|||||||
setActiveTunnelCount(active);
|
setActiveTunnelCount(active);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.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 () => {
|
const handleClearActivity = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -1280,11 +1243,11 @@ export function DashboardTab({
|
|||||||
? Math.max(...panelSlots.map((s) => s.order)) + 1
|
? Math.max(...panelSlots.map((s) => s.order)) + 1
|
||||||
: 0;
|
: 0;
|
||||||
const defaultHeight: number | null =
|
const defaultHeight: number | null =
|
||||||
id === "host_status" ||
|
|
||||||
id === "recent_activity" ||
|
|
||||||
id === "network_graph"
|
id === "network_graph"
|
||||||
? null
|
? 350
|
||||||
: 150;
|
: id === "host_status" || id === "recent_activity"
|
||||||
|
? null
|
||||||
|
: 150;
|
||||||
return [...prev, { id, panel, order: maxOrder, height: defaultHeight }];
|
return [...prev, { id, panel, order: maxOrder, height: defaultHeight }];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1294,12 +1257,19 @@ export function DashboardTab({
|
|||||||
);
|
);
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setSlots(DEFAULT_SLOTS);
|
setSlots(DEFAULT_SLOTS);
|
||||||
setMainWidthPct(72);
|
setMainWidthPct(68);
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem("dashboardTab.slots");
|
||||||
|
localStorage.removeItem("dashboardTab.mainWidthPct");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columnProps = {
|
const columnProps = {
|
||||||
hosts,
|
hosts,
|
||||||
|
hostMetrics,
|
||||||
uptimeFormatted,
|
uptimeFormatted,
|
||||||
versionText,
|
versionText,
|
||||||
versionStatus,
|
versionStatus,
|
||||||
@@ -1397,7 +1367,11 @@ export function DashboardTab({
|
|||||||
<QuickActionsCard onOpenSingletonTab={onOpenSingletonTab} />
|
<QuickActionsCard onOpenSingletonTab={onOpenSingletonTab} />
|
||||||
)}
|
)}
|
||||||
{slot.id === "host_status" && (
|
{slot.id === "host_status" && (
|
||||||
<HostStatusCard hosts={hosts} onOpenTab={onOpenTab} />
|
<HostStatusCard
|
||||||
|
hosts={hosts}
|
||||||
|
hostMetrics={hostMetrics}
|
||||||
|
onOpenTab={onOpenTab}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{slot.id === "recent_activity" && (
|
{slot.id === "recent_activity" && (
|
||||||
<RecentActivityCard
|
<RecentActivityCard
|
||||||
@@ -1408,7 +1382,10 @@ export function DashboardTab({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{slot.id === "network_graph" && (
|
{slot.id === "network_graph" && (
|
||||||
<NetworkGraphCard hosts={hosts} />
|
<NetworkGraphCard
|
||||||
|
embedded={true}
|
||||||
|
onOpenInNewTab={() => onOpenSingletonTab("network_graph")}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+1035
-1269
File diff suppressed because it is too large
Load Diff
@@ -2870,7 +2870,8 @@
|
|||||||
"fileManager": "File Manager",
|
"fileManager": "File Manager",
|
||||||
"tunnel": "Tunnel",
|
"tunnel": "Tunnel",
|
||||||
"docker": "Docker",
|
"docker": "Docker",
|
||||||
"serverStats": "Server Stats"
|
"serverStats": "Server Stats",
|
||||||
|
"noNodes": "No nodes yet — add hosts to build your topology"
|
||||||
},
|
},
|
||||||
"rbac": {
|
"rbac": {
|
||||||
"shareHost": "Share Host",
|
"shareHost": "Share Host",
|
||||||
|
|||||||
+189
-151
@@ -13,30 +13,23 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
Terminal,
|
Terminal,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
|
FolderSearch,
|
||||||
Box,
|
Box,
|
||||||
Globe,
|
Globe,
|
||||||
Plus,
|
Plus,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
DollarSign,
|
|
||||||
Search,
|
Search,
|
||||||
Activity,
|
Activity,
|
||||||
Network,
|
Network,
|
||||||
MoreHorizontal,
|
|
||||||
Edit3,
|
|
||||||
User,
|
User,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Monitor,
|
Monitor,
|
||||||
Clock,
|
Clock,
|
||||||
|
Folder,
|
||||||
|
Pencil,
|
||||||
} from "lucide-react";
|
} 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 { getRecentActivity, type RecentActivityItem } from "@/main-axios";
|
||||||
import type { Host } from "@/types/ui-types";
|
import type { Host } from "@/types/ui-types";
|
||||||
|
|
||||||
@@ -69,6 +62,29 @@ const ACTIVITY_TAB_TYPE: Record<string, string> = {
|
|||||||
rdp: "rdp",
|
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({
|
export function CommandPalette({
|
||||||
isOpen,
|
isOpen,
|
||||||
setIsOpen,
|
setIsOpen,
|
||||||
@@ -108,6 +124,24 @@ export function CommandPalette({
|
|||||||
h.username.toLowerCase().includes(search.toLowerCase()),
|
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) => {
|
const handleAction = (action: () => void) => {
|
||||||
action();
|
action();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
@@ -283,145 +317,135 @@ export function CommandPalette({
|
|||||||
|
|
||||||
<CommandGroup heading="Servers & Hosts" className="px-2">
|
<CommandGroup heading="Servers & Hosts" className="px-2">
|
||||||
{filteredHosts.length > 0 ? (
|
{filteredHosts.length > 0 ? (
|
||||||
filteredHosts.map((host, i) => {
|
groupedHosts.map(({ folder, hosts: groupHosts }) => (
|
||||||
const actions = [
|
<div key={folder ?? "__root__"}>
|
||||||
host.enableSsh &&
|
{folder && (
|
||||||
host.enableTerminal !== false && {
|
<div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-semibold text-muted-foreground/60 uppercase tracking-wide">
|
||||||
type: "terminal",
|
<Folder className="size-3" />
|
||||||
icon: <Terminal className="size-3" />,
|
{folder}
|
||||||
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",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col flex-1 min-w-0">
|
)}
|
||||||
<div className="flex items-center gap-2">
|
{groupHosts.map((host, i) => (
|
||||||
<span className="text-sm font-semibold truncate">
|
<CommandItem
|
||||||
{host.name}
|
key={i}
|
||||||
</span>
|
onSelect={() =>
|
||||||
{host.online && (
|
handleAction(() => onOpenTab("terminal", host.name))
|
||||||
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
|
}
|
||||||
)}
|
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>
|
</div>
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
{host.username}@{host.ip}
|
<div className="flex items-center gap-2">
|
||||||
</span>
|
<span className="text-sm font-semibold truncate">
|
||||||
</div>
|
{host.name}
|
||||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
</span>
|
||||||
{actions.map((action) => (
|
{host.online && (
|
||||||
<Button
|
<span className="size-1.5 rounded-full bg-accent-brand animate-pulse shrink-0" />
|
||||||
key={action.type}
|
)}
|
||||||
variant="ghost"
|
</div>
|
||||||
size="icon"
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
title={action.label}
|
{host.username}@{host.ip}
|
||||||
className="size-7 rounded-none hover:bg-accent-brand/20 hover:text-accent-brand"
|
</span>
|
||||||
onClick={(e) => {
|
</div>
|
||||||
e.stopPropagation();
|
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
handleAction(() =>
|
{host.enableSsh &&
|
||||||
onOpenTab(action.type as any, host.name),
|
getSshActions(host).map(
|
||||||
);
|
({ type, icon: Icon, label }) => (
|
||||||
}}
|
<button
|
||||||
>
|
key={type}
|
||||||
{action.icon}
|
title={label}
|
||||||
</Button>
|
onClick={(e) => {
|
||||||
))}
|
e.stopPropagation();
|
||||||
<DropdownMenu>
|
handleAction(() =>
|
||||||
<DropdownMenuTrigger asChild>
|
onOpenTab(type as any, host.name),
|
||||||
<Button
|
);
|
||||||
variant="ghost"
|
}}
|
||||||
size="icon"
|
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
|
||||||
className="size-7 rounded-none hover:bg-muted"
|
>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<Icon className="size-3.5" />
|
||||||
>
|
</button>
|
||||||
<MoreHorizontal className="size-3" />
|
),
|
||||||
</Button>
|
)}
|
||||||
</DropdownMenuTrigger>
|
{host.enableSsh &&
|
||||||
<DropdownMenuContent
|
(host.enableRdp ||
|
||||||
align="end"
|
host.enableVnc ||
|
||||||
className="rounded-none border-border bg-card w-40"
|
host.enableTelnet) && (
|
||||||
>
|
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
|
||||||
<DropdownMenuItem
|
)}
|
||||||
className="rounded-none text-xs font-semibold hover:bg-accent-brand/10 hover:text-accent-brand cursor-pointer"
|
{host.enableRdp && (
|
||||||
|
<button
|
||||||
|
title="RDP"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsOpen(false);
|
handleAction(() => onOpenTab("rdp", host.name));
|
||||||
onOpenTab("host-manager");
|
|
||||||
setTimeout(() => {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("host-manager:edit-host", {
|
|
||||||
detail: host.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}, 100);
|
|
||||||
}}
|
}}
|
||||||
|
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
|
<Monitor className="size-3" />
|
||||||
</DropdownMenuItem>
|
RDP
|
||||||
</DropdownMenuContent>
|
</button>
|
||||||
</DropdownMenu>
|
)}
|
||||||
</div>
|
{host.enableVnc && (
|
||||||
</CommandItem>
|
<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">
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||||
No hosts found matching “{search}”
|
No hosts found matching “{search}”
|
||||||
@@ -432,29 +456,43 @@ export function CommandPalette({
|
|||||||
<CommandSeparator className="my-2" />
|
<CommandSeparator className="my-2" />
|
||||||
|
|
||||||
<CommandGroup heading="Links" className="px-2">
|
<CommandGroup heading="Links" className="px-2">
|
||||||
<div className="grid grid-cols-2 gap-1">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
<CommandItem
|
<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"
|
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" />
|
<Globe className="size-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">GitHub</span>
|
<span className="text-sm font-medium">GitHub</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<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"
|
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" />
|
<MessagesSquare className="size-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">Discord</span>
|
<span className="text-sm font-medium">Discord</span>
|
||||||
</CommandItem>
|
</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" />
|
<LifeBuoy className="size-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">Support</span>
|
<span className="text-sm font-medium">Support</span>
|
||||||
</CommandItem>
|
</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>
|
</div>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { ServerStats } from "@/features/server-stats/ServerStats";
|
|||||||
import GuacamoleApp from "@/features/guacamole/GuacamoleApp";
|
import GuacamoleApp from "@/features/guacamole/GuacamoleApp";
|
||||||
import { DashboardTab } from "@/dashboard/DashboardTab";
|
import { DashboardTab } from "@/dashboard/DashboardTab";
|
||||||
import { TunnelTab } from "@/features/tunnel/TunnelTab";
|
import { TunnelTab } from "@/features/tunnel/TunnelTab";
|
||||||
|
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
|
||||||
import type { Tab, TabType, Host } from "@/types/ui-types";
|
import type { Tab, TabType, Host } from "@/types/ui-types";
|
||||||
import type { SSHHost } from "@/types";
|
import type { SSHHost } from "@/types";
|
||||||
|
|
||||||
@@ -103,6 +104,8 @@ export function tabIcon(type: TabType) {
|
|||||||
return <Box className="size-3.5" />;
|
return <Box className="size-3.5" />;
|
||||||
case "tunnel":
|
case "tunnel":
|
||||||
return <Network className="size-3.5" />;
|
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} />;
|
return <GuacamoleApp hostId={host.id} />;
|
||||||
|
|
||||||
|
case "network_graph":
|
||||||
|
return <NetworkGraphCard embedded={false} />;
|
||||||
|
|
||||||
case "host-manager":
|
case "host-manager":
|
||||||
case "user-profile":
|
case "user-profile":
|
||||||
case "admin-settings":
|
case "admin-settings":
|
||||||
|
|||||||
Reference in New Issue
Block a user