feat: improve dashboard and commpand pallete

This commit is contained in:
LukeGus
2026-05-13 01:05:37 -05:00
parent 835b98a102
commit 6212a24567
8 changed files with 1485 additions and 1697 deletions
+2 -2
View File
@@ -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",
+2 -1
View File
@@ -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
View File
@@ -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);
+183 -206
View File
@@ -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,7 +396,12 @@ function HostStatusCard({
{t("dashboardTab.noHostsConfigured")} {t("dashboardTab.noHostsConfigured")}
</div> </div>
)} )}
{hosts.map((host, i) => ( {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 <div
key={i} key={i}
onClick={() => onOpenTab(host, "stats")} onClick={() => onOpenTab(host, "stats")}
@@ -409,40 +419,44 @@ function HostStatusCard({
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{host.online ? ( {host.online && hasMetrics ? (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{cpu !== null && (
<div className="flex flex-col gap-0.5 w-16"> <div className="flex flex-col gap-0.5 w-16">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{t("dashboard.cpu")} {t("dashboard.cpu")}
</span> </span>
<span className="text-[10px] font-bold text-accent-brand"> <span className="text-[10px] font-bold text-accent-brand">
{host.cpu ?? 0}% {cpu.toFixed(0)}%
</span> </span>
</div> </div>
<div className="h-0.5 bg-muted w-full"> <div className="h-0.5 bg-muted w-full">
<div <div
className="h-full bg-accent-brand" className="h-full bg-accent-brand"
style={{ width: `${host.cpu ?? 0}%` }} style={{ width: `${cpu}%` }}
/> />
</div> </div>
</div> </div>
)}
{ram !== null && (
<div className="flex flex-col gap-0.5 w-16"> <div className="flex flex-col gap-0.5 w-16">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{t("dashboard.ram")} {t("dashboard.ram")}
</span> </span>
<span className="text-[10px] font-bold text-accent-brand"> <span className="text-[10px] font-bold text-accent-brand">
{host.ram ?? 0}% {ram.toFixed(0)}%
</span> </span>
</div> </div>
<div className="h-0.5 bg-muted w-full"> <div className="h-0.5 bg-muted w-full">
<div <div
className="h-full bg-accent-brand" className="h-full bg-accent-brand"
style={{ width: `${host.ram ?? 0}%` }} style={{ width: `${ram}%` }}
/> />
</div> </div>
</div> </div>
)}
</div> </div>
) : ( ) : (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -463,7 +477,8 @@ function HostStatusCard({
</span> </span>
</div> </div>
</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,9 +1243,9 @@ 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"
? 350
: id === "host_status" || id === "recent_activity"
? null ? null
: 150; : 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>
))} ))}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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",
+141 -103
View File
@@ -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,59 +317,15 @@ 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", </div>
}, )}
host.enableSsh && {groupHosts.map((host, i) => (
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 <CommandItem
key={i} key={i}
onSelect={() => onSelect={() =>
@@ -367,40 +357,74 @@ export function CommandPalette({
</span> </span>
</div> </div>
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
{actions.map((action) => ( {host.enableSsh &&
<Button getSshActions(host).map(
key={action.type} ({ type, icon: Icon, label }) => (
variant="ghost" <button
size="icon" key={type}
title={action.label} title={label}
className="size-7 rounded-none hover:bg-accent-brand/20 hover:text-accent-brand"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleAction(() => handleAction(() =>
onOpenTab(action.type as any, host.name), 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"
> >
{action.icon} <Icon className="size-3.5" />
</Button> </button>
))} ),
<DropdownMenu> )}
<DropdownMenuTrigger asChild> {host.enableSsh &&
<Button (host.enableRdp ||
variant="ghost" host.enableVnc ||
size="icon" host.enableTelnet) && (
className="size-7 rounded-none hover:bg-muted" <div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
onClick={(e) => e.stopPropagation()} )}
{host.enableRdp && (
<button
title="RDP"
onClick={(e) => {
e.stopPropagation();
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"
> >
<MoreHorizontal className="size-3" /> <Monitor className="size-3" />
</Button> RDP
</DropdownMenuTrigger> </button>
<DropdownMenuContent )}
align="end" {host.enableVnc && (
className="rounded-none border-border bg-card w-40" <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"
> >
<DropdownMenuItem <Monitor className="size-3" />
className="rounded-none text-xs font-semibold hover:bg-accent-brand/10 hover:text-accent-brand cursor-pointer" 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) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsOpen(false); setIsOpen(false);
@@ -413,15 +437,15 @@ export function CommandPalette({
); );
}, 100); }, 100);
}} }}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
> >
<Edit3 className="size-3.5 mr-2" /> Edit Host <Pencil className="size-3.5" />
</DropdownMenuItem> </button>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</CommandItem> </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 &ldquo;{search}&rdquo; No hosts found matching &ldquo;{search}&rdquo;
@@ -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>
+6
View File
@@ -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":