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",
"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",
+2 -1
View File
@@ -150,7 +150,8 @@ export type TabType =
| "user-profile"
| "admin-settings"
| "docker"
| "tunnel";
| "tunnel"
| "network_graph";
export type TunnelStatusValue =
| "CONNECTED"
+1 -2
View File
@@ -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
View File
@@ -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>
))}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -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
View File
@@ -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 &ldquo;{search}&rdquo;
@@ -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>
+6
View File
@@ -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":