feat: continued general UI improvements

This commit is contained in:
LukeGus
2026-05-26 22:05:58 -05:00
parent 0fe3f8d126
commit 9c12a84a91
15 changed files with 867 additions and 505 deletions
+16 -3
View File
@@ -2697,9 +2697,22 @@ app.post(
res.json({ message: "Connection request received", tunnelName }); res.json({ message: "Connection request received", tunnelName });
operation.finally(() => { operation
pendingTunnelOperations.delete(tunnelName); .catch((err) => {
}); tunnelLogger.error("Tunnel operation failed", err, {
operation: "tunnel_operation_failed",
tunnelName,
});
broadcastTunnelStatus(tunnelName, {
connected: false,
status: CONNECTION_STATES.FAILED,
reason: err instanceof Error ? err.message : "Unknown error",
});
tunnelConnecting.delete(tunnelName);
})
.finally(() => {
pendingTunnelOperations.delete(tunnelName);
});
} catch (error) { } catch (error) {
tunnelLogger.error("Failed to process tunnel connect", error, { tunnelLogger.error("Failed to process tunnel connect", error, {
operation: "tunnel_connect", operation: "tunnel_connect",
+2 -1
View File
@@ -145,7 +145,8 @@ function App() {
}) })
.catch(() => {}); .catch(() => {});
} }
setPhase("idle-app"); setPhase("fading-in");
timerRef.current = setTimeout(() => setPhase("idle-app"), 450);
}) })
.catch(() => { .catch(() => {
clearStoredAuth(); clearStoredAuth();
+143 -105
View File
@@ -20,6 +20,7 @@ import { UserProfilePanel } from "@/sidebar/UserProfilePanel";
import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel"; import { AdminSettingsPanel } from "@/sidebar/AdminSettingsPanel";
import { CredentialsPanel } from "@/sidebar/CredentialsPanel"; import { CredentialsPanel } from "@/sidebar/CredentialsPanel";
import { SplitView } from "@/shell/SplitView"; import { SplitView } from "@/shell/SplitView";
import { renderTabContent } from "@/shell/tabUtils";
import { TabBar } from "@/shell/TabBar"; import { TabBar } from "@/shell/TabBar";
import type { import type {
Tab, Tab,
@@ -111,7 +112,6 @@ function buildHostTree(hosts: SSHHostWithStatus[]): HostFolder {
return root; return root;
} }
export { tabIcon, renderTabContent } from "@/shell/tabUtils"; export { tabIcon, renderTabContent } from "@/shell/tabUtils";
import { renderTabContent } from "@/shell/tabUtils";
// ─── AppShell ──────────────────────────────────────────────────────────────── // ─── AppShell ────────────────────────────────────────────────────────────────
@@ -303,7 +303,7 @@ export function AppShell({
// ─── Tab management ────────────────────────────────────────────────────── // ─── Tab management ──────────────────────────────────────────────────────
function openTab(host: Host, type: TabType) { const openTab = useCallback(function openTab(host: Host, type: TabType) {
const tabId = `${host.name}-${type}-${Date.now()}`; const tabId = `${host.name}-${type}-${Date.now()}`;
const ref = type === "terminal" ? createRef() : undefined; const ref = type === "terminal" ? createRef() : undefined;
if (ref) terminalRefs.current.set(tabId, ref); if (ref) terminalRefs.current.set(tabId, ref);
@@ -327,7 +327,7 @@ export function AppShell({
return [...next, { id: tabId, type, label, host, terminalRef: ref }]; return [...next, { id: tabId, type, label, host, terminalRef: ref }];
}); });
setActiveTabId(tabId); setActiveTabId(tabId);
} }, []);
function connectHost(host: Host, preferredType?: TabType) { function connectHost(host: Host, preferredType?: TabType) {
const type: TabType = const type: TabType =
@@ -344,47 +344,52 @@ export function AppShell({
openTab(host, type); openTab(host, type);
} }
function openSingletonTab(type: TabType, pendingEvent?: string) { const openSingletonTab = useCallback(
if (type === "host-manager") { function openSingletonTab(type: TabType, pendingEvent?: string) {
if (pendingEvent === "host-manager:add-credential") { if (type === "host-manager") {
setSidebarOpen(true); if (pendingEvent === "host-manager:add-credential") {
setRailView("credentials"); setSidebarOpen(true);
setTimeout( setRailView("credentials");
() =>
window.dispatchEvent(
new CustomEvent("host-manager:add-credential"),
),
0,
);
} else {
setSidebarOpen(true);
setRailView("hosts");
if (pendingEvent) {
setTimeout( setTimeout(
() => window.dispatchEvent(new CustomEvent(pendingEvent)), () =>
window.dispatchEvent(
new CustomEvent("host-manager:add-credential"),
),
0, 0,
); );
} else {
setSidebarOpen(true);
setRailView("hosts");
if (pendingEvent) {
setTimeout(
() => window.dispatchEvent(new CustomEvent(pendingEvent)),
0,
);
}
} }
return;
} }
return; if (type === "user-profile" || type === "admin-settings") {
} setSidebarEditing(false);
if (type === "user-profile" || type === "admin-settings") { setRailView(type as RailView);
handleRailClick(type as RailView); setSidebarOpen(true);
return; return;
} }
const id = type; const id = type;
setTabs((prev) => { setTabs((prev) => {
if (prev.find((t) => t.id === id)) return prev; if (prev.find((t) => t.id === id)) return prev;
const singletonLabels: Partial<Record<TabType, string>> = { const singletonLabels: Partial<Record<TabType, string>> = {
"host-manager": t("nav.hostManager"), "host-manager": t("nav.hostManager"),
docker: t("nav.docker"), docker: t("nav.docker"),
tunnel: t("nav.tunnels"), tunnel: t("nav.tunnels"),
network_graph: t("nav.networkGraph"), network_graph: t("nav.networkGraph"),
}; };
return [...prev, { id, type, label: singletonLabels[type] ?? type }]; return [...prev, { id, type, label: singletonLabels[type] ?? type }];
}); });
setActiveTabId(id); setActiveTabId(id);
} },
[t],
);
const SESSION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"]; const SESSION_TAB_TYPES: TabType[] = ["terminal", "rdp", "vnc", "telnet"];
@@ -483,6 +488,24 @@ export function AppShell({
[sidebarWidth], [sidebarWidth],
); );
// Resize all terminals in panes + active terminal when split mode or sidebar changes
const resizeAllTerminals = useCallback(() => {
const id = requestAnimationFrame(() => {
tabs.forEach((tab) => {
if (!tab.terminalRef) return;
const ref = tab.terminalRef.current as any;
ref?.fit?.();
ref?.notifyResize?.();
});
});
return id;
}, [tabs]);
useEffect(() => {
const id = resizeAllTerminals();
return () => cancelAnimationFrame(id);
}, [splitMode, sidebarWidth, sidebarOpen]);
const activeTab = tabs.find((t) => t.id === activeTabId)!; const activeTab = tabs.find((t) => t.id === activeTabId)!;
const isSplit = splitMode !== "none"; const isSplit = splitMode !== "none";
const terminalTabs = tabs.filter((t) => t.type === "terminal"); const terminalTabs = tabs.filter((t) => t.type === "terminal");
@@ -502,13 +525,17 @@ export function AppShell({
hostTree={realHostTree ?? undefined} hostTree={realHostTree ?? undefined}
loading={hostsLoading} loading={hostsLoading}
onEditingChange={setSidebarEditing} onEditingChange={setSidebarEditing}
active={railView === "hosts"}
/> />
</div> </div>
<div <div
className={`flex flex-col flex-1 min-h-0 ${railView === "credentials" ? "" : "hidden"}`} className={`flex flex-col flex-1 min-h-0 ${railView === "credentials" ? "" : "hidden"}`}
> >
<CredentialsPanel onEditingChange={setSidebarEditing} /> <CredentialsPanel
onEditingChange={setSidebarEditing}
active={railView === "credentials"}
/>
</div> </div>
{railView === "quick-connect" && ( {railView === "quick-connect" && (
@@ -615,6 +642,7 @@ export function AppShell({
profileDropdownOpen={profileDropdownOpen} profileDropdownOpen={profileDropdownOpen}
onProfileDropdownChange={setProfileDropdownOpen} onProfileDropdownChange={setProfileDropdownOpen}
onRailClick={handleRailClick} onRailClick={handleRailClick}
onOpenTab={openSingletonTab}
onLogout={onLogout} onLogout={onLogout}
/> />
@@ -677,72 +705,82 @@ export function AppShell({
onReorderTabs={setTabs} onReorderTabs={setTabs}
/> />
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden"> <div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
{isSplit && !isMobile ? ( {/* Split view — always mounted when not mobile, hidden via CSS when inactive */}
<SplitView {!isMobile && (
tabs={tabs} <div
paneTabIds={paneTabIds} className="absolute inset-0"
splitMode={splitMode} style={{
onOpenSingletonTab={openSingletonTab} display: isSplit ? "flex" : "none",
onOpenTab={openTab} flexDirection: "column",
/> }}
) : ( >
<> <SplitView
{/* Terminal tabs: always in DOM, absolutely positioned so xterm always has real dimensions */} tabs={tabs}
{tabs paneTabIds={paneTabIds}
.filter((tab) => tab.type === "terminal") splitMode={splitMode}
.map((tab) => { onOpenSingletonTab={openSingletonTab}
const visible = tab.id === activeTabId; onOpenTab={openTab}
return ( onTerminalResize={resizeAllTerminals}
<div />
key={tab.id} </div>
className="absolute inset-0 overflow-hidden"
style={{
visibility: visible ? "visible" : "hidden",
pointerEvents: visible ? "auto" : "none",
zIndex: visible ? 1 : 0,
}}
>
{renderTabContent(
tab,
openSingletonTab,
openTab,
closeTab,
visible,
)}
</div>
);
})}
{/* Non-terminal tabs: absolutely positioned above terminals when active */}
{tabs
.filter((tab) => tab.type !== "terminal")
.map((tab) => {
const visible = tab.id === activeTabId;
return (
<div
key={tab.id}
className="flex flex-col overflow-hidden bg-background"
style={
visible
? {
position: "absolute",
inset: 0,
zIndex: 2,
}
: { display: "none" }
}
>
{renderTabContent(
tab,
openSingletonTab,
openTab,
closeTab,
visible,
)}
</div>
);
})}
</>
)} )}
{/* Normal tab view — always mounted, hidden via CSS when split is active */}
<div
className="absolute inset-0 flex flex-col"
style={{ display: isSplit && !isMobile ? "none" : "flex" }}
>
{/* Terminal tabs: always in DOM, visibility-toggled so xterm keeps its dimensions */}
{tabs
.filter((tab) => tab.type === "terminal")
.map((tab) => {
const visible = tab.id === activeTabId;
return (
<div
key={tab.id}
className="absolute inset-0 overflow-hidden"
style={{
visibility: visible ? "visible" : "hidden",
pointerEvents: visible ? "auto" : "none",
zIndex: visible ? 1 : 0,
}}
>
{renderTabContent(
tab,
openSingletonTab,
openTab,
closeTab,
visible,
)}
</div>
);
})}
{/* Non-terminal tabs */}
{tabs
.filter((tab) => tab.type !== "terminal")
.map((tab) => {
const visible = tab.id === activeTabId;
return (
<div
key={tab.id}
className="flex flex-col overflow-hidden bg-background"
style={
visible
? { position: "absolute", inset: 0, zIndex: 2 }
: { display: "none" }
}
>
{renderTabContent(
tab,
openSingletonTab,
openTab,
closeTab,
visible,
)}
</div>
);
})}
</div>
</div> </div>
</div> </div>
+55 -20
View File
@@ -111,6 +111,9 @@ function resolveCssVar(varName: string, fallback: string): string {
return resolved && resolved !== "rgba(0, 0, 0, 0)" ? resolved : fallback; return resolved && resolved !== "rgba(0, 0, 0, 0)" ? resolved : fallback;
} }
const NODE_W = 220;
const NODE_H = 88;
function buildNodeSvg( function buildNodeSvg(
name: string, name: string,
ip: string, ip: string,
@@ -130,30 +133,50 @@ function buildNodeSvg(
const textPrimary = resolveCssVar("--card-foreground", "#f1f5f9"); const textPrimary = resolveCssVar("--card-foreground", "#f1f5f9");
const textSecondary = resolveCssVar("--muted-foreground", "#94a3b8"); const textSecondary = resolveCssVar("--muted-foreground", "#94a3b8");
const esc = (s: string) => const dpr = Math.min(window.devicePixelRatio || 1, 3);
s.replace( const W = NODE_W * dpr;
const H = NODE_H * dpr;
const s = dpr;
const esc = (str: string) =>
str.replace(
/[<>&"]/g, /[<>&"]/g,
(c) => (c) =>
({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" })[c] ?? c, ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;" })[c] ?? c,
); );
const tagsHtml = tags const tagsHtml = tags
.slice(0, 2) .slice(0, 3)
.map( .map(
(tag) => (tag) =>
`<span style="background:${statusColor};color:#fff;padding:1px 6px;border-radius:0;font-size:8px;font-weight:700;margin:0 1px;">${esc(tag)}</span>`, `<span style="background:${statusColor};color:#fff;padding:${1 * s}px ${5 * s}px;border-radius:${2 * s}px;font-size:${8 * s}px;font-weight:700;margin:0 ${1 * s}px;white-space:nowrap;">${esc(tag)}</span>`,
) )
.join(""); .join("");
const serverIcon = `<path d="M${2 * s} ${2 * s} h${14 * s} a${1 * s} ${1 * s} 0 0 1 ${1 * s} ${1 * s} v${5 * s} a${1 * s} ${1 * s} 0 0 1 -${1 * s} ${1 * s} h-${14 * s} a${1 * s} ${1 * s} 0 0 1 -${1 * s} -${1 * s} v-${5 * s} a${1 * s} ${1 * s} 0 0 1 ${1 * s} -${1 * s}z" fill="none" stroke="${textSecondary}" stroke-width="${0.8 * s}" opacity="0.4"/>
<circle cx="${14 * s}" cy="${5.5 * s}" r="${1.5 * s}" fill="${statusColor}" opacity="0.8"/>`;
return ( return (
"data:image/svg+xml;utf8," + "data:image/svg+xml;utf8," +
encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="180" height="80" viewBox="0 0 180 80"> encodeURIComponent(
<rect x="0" y="0" width="180" height="80" rx="0" fill="${bg}" stroke="${border}" stroke-width="1"/> `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
<rect x="0" y="0" width="4" height="80" fill="${statusColor}"/> <defs>
<text x="14" y="28" font-family="monospace,sans-serif" font-size="12" font-weight="700" fill="${textPrimary}" xml:space="preserve">${esc(name).substring(0, 18)}</text> <filter id="shadow" x="-10%" y="-10%" width="120%" height="130%">
<text x="14" y="46" font-family="monospace,sans-serif" font-size="10" fill="${textSecondary}" xml:space="preserve">${esc(ip)}</text> <feDropShadow dx="0" dy="${1 * s}" stdDeviation="${2 * s}" flood-color="#000" flood-opacity="0.25"/>
${tagsHtml ? `<foreignObject x="12" y="54" width="160" height="20"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;gap:2px;">${tagsHtml}</div></foreignObject>` : ""} </filter>
</svg>`) <clipPath id="nameClip">
<rect x="${14 * s}" y="${10 * s}" width="${175 * s}" height="${20 * s}"/>
</clipPath>
</defs>
<rect x="0" y="0" width="${W}" height="${H}" rx="${3 * s}" fill="${bg}" stroke="${border}" stroke-width="${s}" filter="url(#shadow)"/>
<rect x="0" y="0" width="${5 * s}" height="${H}" rx="${3 * s}" fill="${statusColor}"/>
<rect x="0" y="0" width="${5 * s}" height="${H}" fill="${statusColor}"/>
<g transform="translate(${W - 20 * s} ${3 * s})">${serverIcon}</g>
<text x="${14 * s}" y="${28 * s}" font-family="-apple-system,BlinkMacSystemFont,system-ui,sans-serif" font-size="${13 * s}" font-weight="700" fill="${textPrimary}" xml:space="preserve" clip-path="url(#nameClip)">${esc(name)}</text>
<text x="${14 * s}" y="${46 * s}" font-family="ui-monospace,monospace,sans-serif" font-size="${11 * s}" fill="${textSecondary}" xml:space="preserve">${esc(ip)}</text>
${tagsHtml ? `<foreignObject x="${12 * s}" y="${56 * s}" width="${196 * s}" height="${24 * s}"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;gap:${2 * s}px;flex-wrap:nowrap;overflow:hidden;">${tagsHtml}</div></foreignObject>` : ""}
</svg>`,
)
); );
} }
@@ -225,9 +248,19 @@ export function NetworkGraphCard({
setContextMenu((p) => (p.visible ? { ...p, visible: false } : p)); setContextMenu((p) => (p.visible ? { ...p, visible: false } : p));
}; };
document.addEventListener("mousedown", onClickOutside, true); document.addEventListener("mousedown", onClickOutside, true);
const themeObserver = new MutationObserver(() => {
if (cyRef.current) applyStyle(cyRef.current);
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "data-theme"],
});
return () => { return () => {
if (statusIntervalRef.current) clearInterval(statusIntervalRef.current); if (statusIntervalRef.current) clearInterval(statusIntervalRef.current);
document.removeEventListener("mousedown", onClickOutside, true); document.removeEventListener("mousedown", onClickOutside, true);
themeObserver.disconnect();
}; };
}, []); }, []);
@@ -357,12 +390,14 @@ export function NetworkGraphCard({
}, [loading]); }, [loading]);
const applyStyle = useCallback((cy: cytoscape.Core) => { const applyStyle = useCallback((cy: cytoscape.Core) => {
const edgeColor = resolveCssVar("--border", "#4a4a4e");
const mutedFg = resolveCssVar("--muted-foreground", "#94a3b8");
cy.style() cy.style()
.selector("node") .selector("node")
.style({ .style({
label: "", label: "",
width: "180px", width: `${NODE_W}px`,
height: "80px", height: `${NODE_H}px`,
shape: "rectangle", shape: "rectangle",
"border-width": "0px", "border-width": "0px",
"background-opacity": 0, "background-opacity": 0,
@@ -387,16 +422,16 @@ export function NetworkGraphCard({
"text-valign": "top", "text-valign": "top",
"text-halign": "center", "text-halign": "center",
"text-margin-y": -6, "text-margin-y": -6,
color: "#94a3b8", color: mutedFg,
"font-size": "13px", "font-size": "13px",
"font-weight": "bold", "font-weight": "bold",
shape: "rectangle", shape: "rectangle",
padding: "12px", padding: "20px",
}) })
.selector("edge") .selector("edge")
.style({ .style({
width: "1.5px", width: "1.5px",
"line-color": "#3a3a3c", "line-color": edgeColor,
"curve-style": "bezier", "curve-style": "bezier",
"target-arrow-shape": "none", "target-arrow-shape": "none",
}) })
@@ -816,18 +851,18 @@ export function NetworkGraphCard({
const cytoscapeEl = ( const cytoscapeEl = (
<div <div
ref={cyContainerRef} ref={cyContainerRef}
className="relative flex-1 min-h-0 w-full overflow-hidden bg-card" className="relative flex-1 min-h-0 w-full overflow-hidden bg-background"
onContextMenu={(e) => e.preventDefault()} onContextMenu={(e) => e.preventDefault()}
> >
{loading && ( {loading && (
<div className="absolute inset-0 z-10 flex items-center justify-center bg-card/80"> <div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80">
<Loader2 className="size-5 animate-spin text-muted-foreground" /> <Loader2 className="size-5 animate-spin text-muted-foreground" />
</div> </div>
)} )}
{contextMenuEl} {contextMenuEl}
<CytoscapeComponent <CytoscapeComponent
elements={elements} elements={elements}
style={{ width: "100%", height: "100%" }} style={{ width: "100%", height: "100%", background: "transparent" }}
layout={{ name: "preset" }} layout={{ name: "preset" }}
cy={handleNodeInit} cy={handleNodeInit}
wheelSensitivity={1.5} wheelSensitivity={1.5}
@@ -1153,7 +1188,7 @@ export function NetworkGraphCard({
if (!embedded) { if (!embedded) {
return ( return (
<div className="h-full w-full flex flex-col bg-card"> <div className="h-full w-full flex flex-col bg-background">
<div className="flex items-center gap-1 px-4 py-2 border-b border-border shrink-0 flex-wrap"> <div className="flex items-center gap-1 px-4 py-2 border-b border-border shrink-0 flex-wrap">
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<Button <Button
+49 -11
View File
@@ -94,6 +94,7 @@ function DockerManagerInner({
const [hasConnectionError, setHasConnectionError] = React.useState(false); const [hasConnectionError, setHasConnectionError] = React.useState(false);
const [search, setSearch] = React.useState(""); const [search, setSearch] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all"); const [statusFilter, setStatusFilter] = React.useState("all");
const [retryCount, setRetryCount] = React.useState(0);
const activityLoggedRef = React.useRef(false); const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false); const activityLoggingRef = React.useRef(false);
@@ -278,7 +279,7 @@ function DockerManagerInner({
}); });
} }
}; };
}, [currentHostConfig?.id, currentHostConfig?.enableDocker]); }, [currentHostConfig?.id, currentHostConfig?.enableDocker, retryCount]);
React.useEffect(() => { React.useEffect(() => {
if (!sessionId || !isVisible) return; if (!sessionId || !isVisible) return;
@@ -539,6 +540,15 @@ function DockerManagerInner({
onClose?.(); onClose?.();
}; };
const handleRetry = () => {
initializingRef.current = false;
setSessionId(null);
setHasConnectionError(false);
setDockerValidation(null);
clearLogs();
setRetryCount((c) => c + 1);
};
const topMarginPx = isTopbarOpen ? 74 : 16; const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = 8; const leftMarginPx = 8;
const bottomMarginPx = 8; const bottomMarginPx = 8;
@@ -610,21 +620,32 @@ function DockerManagerInner({
} }
if (dockerValidation && !dockerValidation.available) { if (dockerValidation && !dockerValidation.available) {
const isError =
hasConnectionError || (!!dockerValidation && !dockerValidation.available);
return ( return (
<div style={wrapperStyle} className={`${containerClass} relative`}> <div style={wrapperStyle} className={`${containerClass} relative`}>
{!isConnectionLogExpanded && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10">
<Box className="size-12 opacity-20" />
<p className="text-sm text-muted-foreground opacity-60">
{t("docker.connectionFailed")}
</p>
<Button
variant="outline"
size="default"
onClick={handleRetry}
className="gap-2 font-semibold"
>
<RefreshCw className="size-3.5" />
{t("terminal.retry")}
</Button>
</div>
)}
<ConnectionLog <ConnectionLog
isConnecting={isConnecting} isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available} isConnected={!!sessionId && !!dockerValidation?.available}
hasConnectionError={ hasConnectionError={isError}
hasConnectionError || position={isError ? "top" : "bottom"}
(!!dockerValidation && !dockerValidation.available)
}
position={
hasConnectionError ||
(!!dockerValidation && !dockerValidation.available)
? "top"
: "bottom"
}
/> />
</div> </div>
); );
@@ -770,6 +791,23 @@ function DockerManagerInner({
visible={isConnecting && !isConnectionLogExpanded} visible={isConnecting && !isConnectionLogExpanded}
message={t("docker.connecting")} message={t("docker.connecting")}
/> />
{hasConnectionError && !isConnectionLogExpanded && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 z-10">
<Box className="size-12 opacity-20" />
<p className="text-sm text-muted-foreground opacity-60">
{t("docker.connectionFailed")}
</p>
<Button
variant="outline"
size="default"
onClick={handleRetry}
className="gap-2 font-semibold"
>
<RefreshCw className="size-3.5" />
{t("terminal.retry")}
</Button>
</div>
)}
<ConnectionLog <ConnectionLog
isConnecting={isConnecting} isConnecting={isConnecting}
isConnected={!!sessionId && !!dockerValidation?.available} isConnected={!!sessionId && !!dockerValidation?.available}
@@ -2981,6 +2981,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
prompt={totpPrompt} prompt={totpPrompt}
onSubmit={handleTotpSubmit} onSubmit={handleTotpSubmit}
onCancel={handleTotpCancel} onCancel={handleTotpCancel}
backgroundColor="var(--bg-canvas)"
/> />
<WarpgateDialog <WarpgateDialog
@@ -2990,6 +2991,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onContinue={handleWarpgateContinue} onContinue={handleWarpgateContinue}
onCancel={handleWarpgateCancel} onCancel={handleWarpgateCancel}
onOpenUrl={handleWarpgateOpenUrl} onOpenUrl={handleWarpgateOpenUrl}
backgroundColor="var(--bg-canvas)"
/> />
{currentHost && ( {currentHost && (
@@ -3004,6 +3006,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
username: currentHost.username, username: currentHost.username,
name: currentHost.name, name: currentHost.name,
}} }}
backgroundColor="var(--bg-canvas)"
/> />
)} )}
+31 -16
View File
@@ -405,6 +405,11 @@ function ServerStatsInner({
try { try {
if (!totpVerified) { if (!totpVerified) {
addLog({
type: "info",
stage: "stats_connecting",
message: `Connecting to ${currentHostConfig.username}@${currentHostConfig.ip}:${currentHostConfig.port}`,
});
const result = await startMetricsPolling(currentHostConfig.id); const result = await startMetricsPolling(currentHostConfig.id);
if (cancelled) return; if (cancelled) return;
@@ -459,10 +464,10 @@ function ServerStatsInner({
if (data) { if (data) {
setMetrics(data); setMetrics(data);
setServerStatus("online");
if (!hasExistingMetrics) { if (!hasExistingMetrics) {
setIsLoadingMetrics(false); setIsLoadingMetrics(false);
logServerActivity(); logServerActivity();
setTimeout(() => clearLogs(), 1000);
} }
} }
@@ -567,17 +572,24 @@ function ServerStatsInner({
const handleRefresh = async () => { const handleRefresh = async () => {
if (!currentHostConfig?.id) return; if (!currentHostConfig?.id) return;
if (hasConnectionError) {
setHasConnectionError(false);
clearLogs();
return;
}
try { try {
setIsRefreshing(true); setIsRefreshing(true);
const res = await getServerStatusById(currentHostConfig.id); const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(res?.status === "online" ? "online" : "offline"); setServerStatus(res?.status === "online" ? "online" : "offline");
const data = await getServerMetricsById(currentHostConfig.id); const data = await getServerMetricsById(currentHostConfig.id);
if (data) setMetrics(data); if (data) {
setShowStatsUI(true); setMetrics(data);
setShowStatsUI(true);
}
} catch { } catch {
setServerStatus("offline"); setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} finally { } finally {
setIsRefreshing(false); setIsRefreshing(false);
} }
@@ -598,7 +610,7 @@ function ServerStatsInner({
}} }}
> >
<div className="flex-1 overflow-y-auto overflow-x-hidden flex flex-col"> <div className="flex-1 overflow-y-auto overflow-x-hidden flex flex-col">
{!totpRequired && !isLoadingMetrics && ( {!totpRequired && !isLoadingMetrics && !hasConnectionError && (
<div className="mx-3 mt-3 flex items-center justify-between border border-border bg-card px-3 py-3 shrink-0"> <div className="mx-3 mt-3 flex items-center justify-between border border-border bg-card px-3 py-3 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0"> <div className="size-10 border border-border bg-muted flex items-center justify-center shrink-0">
@@ -725,16 +737,19 @@ function ServerStatsInner({
showStatsUI && showStatsUI &&
!isLoadingMetrics && !isLoadingMetrics &&
!metrics && !metrics &&
serverStatus === "offline" && ( serverStatus === "offline" &&
!hasConnectionError && (
<div className="flex-1 flex items-center justify-center py-20"> <div className="flex-1 flex items-center justify-center py-20">
<div className="text-center opacity-40"> <div className="text-center">
<Server className="size-16 mx-auto mb-4" /> <div className="opacity-40">
<p className="text-xl font-bold uppercase tracking-widest"> <Server className="size-16 mx-auto mb-4" />
{t("serverStats.serverOffline")} <p className="text-xl font-bold uppercase tracking-widest">
</p> {t("serverStats.serverOffline")}
<p className="text-sm font-semibold"> </p>
{t("serverStats.cannotFetchMetrics")} <p className="text-sm font-semibold">
</p> {t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div> </div>
</div> </div>
)} )}
@@ -767,7 +782,7 @@ function ServerStatsInner({
/> />
<ConnectionLog <ConnectionLog
isConnecting={isLoadingMetrics} isConnecting={isLoadingMetrics}
isConnected={serverStatus === "online"} isConnected={serverStatus === "online" && !hasConnectionError}
hasConnectionError={hasConnectionError} hasConnectionError={hasConnectionError}
position={hasConnectionError ? "top" : "bottom"} position={hasConnectionError ? "top" : "bottom"}
/> />
+4 -1
View File
@@ -1183,6 +1183,8 @@
"connecting": "Connecting...", "connecting": "Connecting...",
"connectionFailed": "Failed to connect to server", "connectionFailed": "Failed to connect to server",
"naCpus": "N/A CPU(s)", "naCpus": "N/A CPU(s)",
"cpuCores_one": "{{count}} Core",
"cpuCores_other": "{{count}} Cores",
"cpuUsage": "CPU Usage", "cpuUsage": "CPU Usage",
"memoryUsage": "Memory Usage", "memoryUsage": "Memory Usage",
"diskUsage": "Disk Usage", "diskUsage": "Disk Usage",
@@ -1229,7 +1231,8 @@
"loadAvg": "Load Avg", "loadAvg": "Load Avg",
"swap": "Swap", "swap": "Swap",
"architecture": "Architecture", "architecture": "Architecture",
"refresh": "Refresh" "refresh": "Refresh",
"retry": "Retry"
}, },
"auth": { "auth": {
"tagline": "SSH SERVER MANAGER", "tagline": "SSH SERVER MANAGER",
+282 -99
View File
@@ -1,15 +1,13 @@
import { useState, useRef, useEffect } from "react"; import React, { useState, useRef, useEffect, memo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { splitDragState, notifyDragEnd } from "@/lib/splitDragging"; import { splitDragState, notifyDragEnd } from "@/lib/splitDragging";
import { renderTabContent, tabIcon } from "@/shell/tabUtils"; import { renderTabContent, tabIcon } from "@/shell/tabUtils";
import type { Tab, TabType, Host, SplitMode } from "@/types/ui-types"; import type { Tab, TabType, Host, SplitMode } from "@/types/ui-types";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── useSplitSizes ────────────────────────────────────────────────────────────
type RowColSizes = number[][]; type RowColSizes = number[][];
// ─── useSplitSizes ────────────────────────────────────────────────────────────
function defaultSizes(mode: SplitMode): { function defaultSizes(mode: SplitMode): {
rowSizes: number[]; rowSizes: number[];
rowColSizes: RowColSizes; rowColSizes: RowColSizes;
@@ -73,7 +71,6 @@ function useSplitSizes(splitMode: SplitMode) {
splitDragState.active = true; splitDragState.active = true;
setIsDragging(true); setIsDragging(true);
} }
function endDrag() { function endDrag() {
splitDragState.active = false; splitDragState.active = false;
setIsDragging(false); setIsDragging(false);
@@ -87,11 +84,13 @@ function useSplitSizes(splitMode: SplitMode) {
startDrag(); startDrag();
const totalH = container.getBoundingClientRect().height; const totalH = container.getBoundingClientRect().height;
const startY = e.clientY; const startY = e.clientY;
const a = rowSizes[rowIdx]; const a = rowSizes[rowIdx],
const b = rowSizes[rowIdx + 1]; b = rowSizes[rowIdx + 1];
function onMove(ev: MouseEvent) { function onMove(ev: MouseEvent) {
const delta = ((ev.clientY - startY) / totalH) * 100; const na = Math.max(
const na = Math.max(10, Math.min(a + b - 10, a + delta)); 10,
Math.min(a + b - 10, a + ((ev.clientY - startY) / totalH) * 100),
);
setRowSizes((prev) => { setRowSizes((prev) => {
const n = [...prev]; const n = [...prev];
n[rowIdx] = na; n[rowIdx] = na;
@@ -114,11 +113,16 @@ function useSplitSizes(splitMode: SplitMode) {
startDrag(); startDrag();
const totalH = container.getBoundingClientRect().height; const totalH = container.getBoundingClientRect().height;
const startY = e.touches[0].clientY; const startY = e.touches[0].clientY;
const a = rowSizes[rowIdx]; const a = rowSizes[rowIdx],
const b = rowSizes[rowIdx + 1]; b = rowSizes[rowIdx + 1];
function onMove(ev: TouchEvent) { function onMove(ev: TouchEvent) {
const delta = ((ev.touches[0].clientY - startY) / totalH) * 100; const na = Math.max(
const na = Math.max(10, Math.min(a + b - 10, a + delta)); 10,
Math.min(
a + b - 10,
a + ((ev.touches[0].clientY - startY) / totalH) * 100,
),
);
setRowSizes((prev) => { setRowSizes((prev) => {
const n = [...prev]; const n = [...prev];
n[rowIdx] = na; n[rowIdx] = na;
@@ -143,11 +147,13 @@ function useSplitSizes(splitMode: SplitMode) {
const totalW = container.getBoundingClientRect().width; const totalW = container.getBoundingClientRect().width;
const startX = e.clientX; const startX = e.clientX;
const cols = rowColSizes[rowIdx]; const cols = rowColSizes[rowIdx];
const a = cols[colIdx]; const a = cols[colIdx],
const b = cols[colIdx + 1]; b = cols[colIdx + 1];
function onMove(ev: MouseEvent) { function onMove(ev: MouseEvent) {
const delta = ((ev.clientX - startX) / totalW) * 100; const na = Math.max(
const na = Math.max(10, Math.min(a + b - 10, a + delta)); 10,
Math.min(a + b - 10, a + ((ev.clientX - startX) / totalW) * 100),
);
setRowColSizes((prev) => { setRowColSizes((prev) => {
const next = prev.map((r) => [...r]); const next = prev.map((r) => [...r]);
next[rowIdx][colIdx] = na; next[rowIdx][colIdx] = na;
@@ -175,11 +181,16 @@ function useSplitSizes(splitMode: SplitMode) {
const totalW = container.getBoundingClientRect().width; const totalW = container.getBoundingClientRect().width;
const startX = e.touches[0].clientX; const startX = e.touches[0].clientX;
const cols = rowColSizes[rowIdx]; const cols = rowColSizes[rowIdx];
const a = cols[colIdx]; const a = cols[colIdx],
const b = cols[colIdx + 1]; b = cols[colIdx + 1];
function onMove(ev: TouchEvent) { function onMove(ev: TouchEvent) {
const delta = ((ev.touches[0].clientX - startX) / totalW) * 100; const na = Math.max(
const na = Math.max(10, Math.min(a + b - 10, a + delta)); 10,
Math.min(
a + b - 10,
a + ((ev.touches[0].clientX - startX) / totalW) * 100,
),
);
setRowColSizes((prev) => { setRowColSizes((prev) => {
const next = prev.map((r) => [...r]); const next = prev.map((r) => [...r]);
next[rowIdx][colIdx] = na; next[rowIdx][colIdx] = na;
@@ -219,12 +230,14 @@ function ColDivider({
onTouchStart: (e: React.TouchEvent) => void; onTouchStart: (e: React.TouchEvent) => void;
}) { }) {
return ( return (
<div <div className="relative w-0 shrink-0 z-10">
onMouseDown={onMouseDown} <div
onTouchStart={onTouchStart} onMouseDown={onMouseDown}
className="relative w-3 shrink-0 cursor-col-resize z-10 flex items-center justify-center group" onTouchStart={onTouchStart}
> className="absolute inset-y-0 -left-2 -right-2 cursor-col-resize flex items-center justify-center group"
<div className="w-px h-full bg-border group-hover:bg-accent-brand/60 transition-colors" /> >
<div className="w-px h-full bg-border group-hover:bg-accent-brand/60 transition-colors pointer-events-none" />
</div>
</div> </div>
); );
} }
@@ -237,12 +250,14 @@ function RowDivider({
onTouchStart: (e: React.TouchEvent) => void; onTouchStart: (e: React.TouchEvent) => void;
}) { }) {
return ( return (
<div <div className="relative h-0 w-full shrink-0 z-10">
onMouseDown={onMouseDown} <div
onTouchStart={onTouchStart} onMouseDown={onMouseDown}
className="relative h-3 w-full shrink-0 cursor-row-resize z-10 flex flex-col items-center justify-center group" onTouchStart={onTouchStart}
> className="absolute inset-x-0 -top-2 -bottom-2 cursor-row-resize flex flex-col items-center justify-center group"
<div className="h-px w-full bg-border group-hover:bg-accent-brand/60 transition-colors" /> >
<div className="h-px w-full bg-border group-hover:bg-accent-brand/60 transition-colors pointer-events-none" />
</div>
</div> </div>
); );
} }
@@ -290,7 +305,19 @@ function EmptyPane() {
); );
} }
function Pane({ const PaneContent = memo(function PaneContent({
tab,
onOpenSingletonTab,
onOpenTab,
}: {
tab: Tab;
onOpenSingletonTab: (type: TabType) => void;
onOpenTab: (host: Host, type: TabType) => void;
}) {
return <>{renderTabContent(tab, onOpenSingletonTab, onOpenTab)}</>;
});
const Pane = memo(function Pane({
tab, tab,
paneIndex, paneIndex,
isDragging, isDragging,
@@ -308,7 +335,12 @@ function Pane({
<PaneHeader tab={tab} paneIndex={paneIndex} /> <PaneHeader tab={tab} paneIndex={paneIndex} />
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
{tab ? ( {tab ? (
renderTabContent(tab, onOpenSingletonTab, onOpenTab) <PaneContent
key={tab.id}
tab={tab}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
) : ( ) : (
<EmptyPane /> <EmptyPane />
)} )}
@@ -318,22 +350,89 @@ function Pane({
)} )}
</div> </div>
); );
} });
// ─── Row (top-level so React never sees a new component type) ─────────────────
const Row = memo(function Row({
rowIdx,
paneIndices,
rowHeight,
colWidths,
paneTabIds,
tabs,
isDragging,
onOpenSingletonTab,
onOpenTab,
onColDivider,
onColDividerTouch,
}: {
rowIdx: number;
paneIndices: number[];
rowHeight: number;
colWidths: number[];
paneTabIds: (string | null)[];
tabs: Tab[];
isDragging: boolean;
onOpenSingletonTab: (type: TabType) => void;
onOpenTab: (host: Host, type: TabType) => void;
onColDivider: (e: React.MouseEvent, rowIdx: number, colIdx: number) => void;
onColDividerTouch: (
e: React.TouchEvent,
rowIdx: number,
colIdx: number,
) => void;
}) {
const widths = colWidths ?? [];
return (
<div className="flex min-h-0 w-full" style={{ height: `${rowHeight}%` }}>
{paneIndices.map((pIdx, ci) => {
const tabId = paneTabIds[pIdx];
const tab =
tabId != null ? (tabs.find((t) => t.id === tabId) ?? null) : null;
return (
<React.Fragment key={pIdx}>
<div
className="min-w-0 min-h-0 overflow-hidden"
style={{ width: `${widths[ci] ?? 100 / paneIndices.length}%` }}
>
<Pane
tab={tab}
paneIndex={pIdx}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div>
{ci < paneIndices.length - 1 && (
<ColDivider
onMouseDown={(e) => onColDivider(e, rowIdx, ci)}
onTouchStart={(e) => onColDividerTouch(e, rowIdx, ci)}
/>
)}
</React.Fragment>
);
})}
</div>
);
});
// ─── SplitView ──────────────────────────────────────────────────────────────── // ─── SplitView ────────────────────────────────────────────────────────────────
export function SplitView({ export const SplitView = memo(function SplitView({
tabs, tabs,
paneTabIds, paneTabIds,
splitMode, splitMode,
onOpenSingletonTab, onOpenSingletonTab,
onOpenTab, onOpenTab,
onTerminalResize,
}: { }: {
tabs: Tab[]; tabs: Tab[];
paneTabIds: (string | null)[]; paneTabIds: (string | null)[];
splitMode: SplitMode; splitMode: SplitMode;
onOpenSingletonTab: (type: TabType) => void; onOpenSingletonTab: (type: TabType) => void;
onOpenTab: (host: Host, type: TabType) => void; onOpenTab: (host: Host, type: TabType) => void;
onTerminalResize?: () => void;
}) { }) {
const { const {
rowSizes, rowSizes,
@@ -347,55 +446,17 @@ export function SplitView({
onColDividerTouch, onColDividerTouch,
} = useSplitSizes(splitMode); } = useSplitSizes(splitMode);
function pane(idx: number) { useEffect(() => {
const tab = if (!isDragging) {
paneTabIds[idx] != null const id = requestAnimationFrame(() => onTerminalResize?.());
? (tabs.find((t) => t.id === paneTabIds[idx]) ?? null) return () => cancelAnimationFrame(id);
: null; }
return ( }, [isDragging, onTerminalResize]);
<Pane
tab={tab}
paneIndex={idx}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
);
}
function Row({ // Inline pane lookup for the non-Row layouts (3-way, 3-way-horizontal)
rowIdx, function tab(idx: number): Tab | null {
paneIndices, const tabId = paneTabIds[idx];
}: { return tabId != null ? (tabs.find((t) => t.id === tabId) ?? null) : null;
rowIdx: number;
paneIndices: number[];
}) {
const cols = rowColSizes[rowIdx] ?? [];
return (
<div
className="flex min-h-0 w-full"
style={{ height: `${rowSizes[rowIdx]}%` }}
>
{paneIndices.map((pIdx, ci) => (
<>
<div
key={pIdx}
className="min-w-0 min-h-0 overflow-hidden"
style={{ width: `${cols[ci]}%` }}
>
{pane(pIdx)}
</div>
{ci < paneIndices.length - 1 && (
<ColDivider
key={`cd-${rowIdx}-${ci}`}
onMouseDown={(e) => onColDivider(e, rowIdx, ci)}
onTouchStart={(e) => onColDividerTouch(e, rowIdx, ci)}
/>
)}
</>
))}
</div>
);
} }
return ( return (
@@ -411,7 +472,21 @@ export function SplitView({
Reset Reset
</button> </button>
{splitMode === "2-way" && <Row rowIdx={0} paneIndices={[0, 1]} />} {splitMode === "2-way" && (
<Row
rowIdx={0}
paneIndices={[0, 1]}
rowHeight={rowSizes[0]}
colWidths={rowColSizes[0] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
)}
{splitMode === "3-way" && ( {splitMode === "3-way" && (
<div className="flex w-full h-full min-h-0"> <div className="flex w-full h-full min-h-0">
@@ -419,7 +494,13 @@ export function SplitView({
className="min-w-0 min-h-0 overflow-hidden" className="min-w-0 min-h-0 overflow-hidden"
style={{ width: `${rowColSizes[0][0]}%` }} style={{ width: `${rowColSizes[0][0]}%` }}
> >
{pane(0)} <Pane
tab={tab(0)}
paneIndex={0}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div> </div>
<ColDivider <ColDivider
onMouseDown={(e) => onColDivider(e, 0, 0)} onMouseDown={(e) => onColDivider(e, 0, 0)}
@@ -430,7 +511,13 @@ export function SplitView({
className="min-h-0 overflow-hidden" className="min-h-0 overflow-hidden"
style={{ height: `${rowSizes[0]}%` }} style={{ height: `${rowSizes[0]}%` }}
> >
{pane(1)} <Pane
tab={tab(1)}
paneIndex={1}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div> </div>
<RowDivider <RowDivider
onMouseDown={(e) => onRowDivider(e, 0)} onMouseDown={(e) => onRowDivider(e, 0)}
@@ -440,7 +527,13 @@ export function SplitView({
className="min-h-0 overflow-hidden" className="min-h-0 overflow-hidden"
style={{ height: `${rowSizes[1]}%` }} style={{ height: `${rowSizes[1]}%` }}
> >
{pane(2)} <Pane
tab={tab(2)}
paneIndex={2}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div> </div>
</div> </div>
</div> </div>
@@ -456,14 +549,26 @@ export function SplitView({
className="min-w-0 min-h-0 overflow-hidden" className="min-w-0 min-h-0 overflow-hidden"
style={{ width: `${rowColSizes[0][0]}%` }} style={{ width: `${rowColSizes[0][0]}%` }}
> >
{pane(0)} <Pane
tab={tab(0)}
paneIndex={0}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div> </div>
<ColDivider <ColDivider
onMouseDown={(e) => onColDivider(e, 0, 0)} onMouseDown={(e) => onColDivider(e, 0, 0)}
onTouchStart={(e) => onColDividerTouch(e, 0, 0)} onTouchStart={(e) => onColDividerTouch(e, 0, 0)}
/> />
<div className="flex-1 min-w-0 min-h-0 overflow-hidden"> <div className="flex-1 min-w-0 min-h-0 overflow-hidden">
{pane(1)} <Pane
tab={tab(1)}
paneIndex={1}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div> </div>
</div> </div>
<RowDivider <RowDivider
@@ -471,43 +576,121 @@ export function SplitView({
onTouchStart={(e) => onRowDividerTouch(e, 0)} onTouchStart={(e) => onRowDividerTouch(e, 0)}
/> />
<div className="flex-1 min-h-0 overflow-hidden"> <div className="flex-1 min-h-0 overflow-hidden">
{pane(2)} <Pane
tab={tab(2)}
paneIndex={2}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
/>
</div> </div>
</div> </div>
)} )}
{splitMode === "4-way" && ( {splitMode === "4-way" && (
<div className="flex flex-col w-full h-full min-h-0"> <div className="flex flex-col w-full h-full min-h-0">
<Row rowIdx={0} paneIndices={[0, 1]} /> <Row
rowIdx={0}
paneIndices={[0, 1]}
rowHeight={rowSizes[0]}
colWidths={rowColSizes[0] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
<RowDivider <RowDivider
onMouseDown={(e) => onRowDivider(e, 0)} onMouseDown={(e) => onRowDivider(e, 0)}
onTouchStart={(e) => onRowDividerTouch(e, 0)} onTouchStart={(e) => onRowDividerTouch(e, 0)}
/> />
<Row rowIdx={1} paneIndices={[2, 3]} /> <Row
rowIdx={1}
paneIndices={[2, 3]}
rowHeight={rowSizes[1]}
colWidths={rowColSizes[1] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
</div> </div>
)} )}
{splitMode === "5-way" && ( {splitMode === "5-way" && (
<div className="flex flex-col w-full h-full min-h-0"> <div className="flex flex-col w-full h-full min-h-0">
<Row rowIdx={0} paneIndices={[0, 1, 2]} /> <Row
rowIdx={0}
paneIndices={[0, 1, 2]}
rowHeight={rowSizes[0]}
colWidths={rowColSizes[0] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
<RowDivider <RowDivider
onMouseDown={(e) => onRowDivider(e, 0)} onMouseDown={(e) => onRowDivider(e, 0)}
onTouchStart={(e) => onRowDividerTouch(e, 0)} onTouchStart={(e) => onRowDividerTouch(e, 0)}
/> />
<Row rowIdx={1} paneIndices={[3, 4]} /> <Row
rowIdx={1}
paneIndices={[3, 4]}
rowHeight={rowSizes[1]}
colWidths={rowColSizes[1] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
</div> </div>
)} )}
{splitMode === "6-way" && ( {splitMode === "6-way" && (
<div className="flex flex-col w-full h-full min-h-0"> <div className="flex flex-col w-full h-full min-h-0">
<Row rowIdx={0} paneIndices={[0, 1, 2]} /> <Row
rowIdx={0}
paneIndices={[0, 1, 2]}
rowHeight={rowSizes[0]}
colWidths={rowColSizes[0] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
<RowDivider <RowDivider
onMouseDown={(e) => onRowDivider(e, 0)} onMouseDown={(e) => onRowDivider(e, 0)}
onTouchStart={(e) => onRowDividerTouch(e, 0)} onTouchStart={(e) => onRowDividerTouch(e, 0)}
/> />
<Row rowIdx={1} paneIndices={[3, 4, 5]} /> <Row
rowIdx={1}
paneIndices={[3, 4, 5]}
rowHeight={rowSizes[1]}
colWidths={rowColSizes[1] ?? []}
paneTabIds={paneTabIds}
tabs={tabs}
isDragging={isDragging}
onOpenSingletonTab={onOpenSingletonTab}
onOpenTab={onOpenTab}
onColDivider={onColDivider}
onColDividerTouch={onColDividerTouch}
/>
</div> </div>
)} )}
</div> </div>
); );
} });
+33 -1
View File
@@ -5,6 +5,7 @@ import {
Hammer, Hammer,
KeyRound, KeyRound,
LayoutPanelLeft, LayoutPanelLeft,
Network,
Play, Play,
Server, Server,
Settings, Settings,
@@ -17,7 +18,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/dropdown-menu"; } from "@/components/dropdown-menu";
import type { SplitMode, ToolsTab } from "@/types/ui-types"; import type { SplitMode, TabType, ToolsTab } from "@/types/ui-types";
export type RailView = export type RailView =
| "hosts" | "hosts"
@@ -35,6 +36,7 @@ type RailItem =
title: string; title: string;
dot?: boolean; dot?: boolean;
} }
| { kind: "tab"; tabType: TabType; icon: React.ReactNode; title: string }
| { kind: "separator" }; | { kind: "separator" };
function buildRailButtons( function buildRailButtons(
@@ -68,6 +70,13 @@ function buildRailButtons(
dot: splitMode !== "none", dot: splitMode !== "none",
}, },
{ kind: "separator" }, { kind: "separator" },
{
kind: "tab",
tabType: "network_graph" as TabType,
icon: <Network size={16} />,
title: t("nav.networkGraph"),
},
{ kind: "separator" },
]; ];
} }
@@ -84,6 +93,7 @@ export function AppRail({
profileDropdownOpen, profileDropdownOpen,
onProfileDropdownChange, onProfileDropdownChange,
onRailClick, onRailClick,
onOpenTab,
onLogout, onLogout,
}: { }: {
railView: RailView; railView: RailView;
@@ -94,6 +104,7 @@ export function AppRail({
profileDropdownOpen: boolean; profileDropdownOpen: boolean;
onProfileDropdownChange: (open: boolean) => void; onProfileDropdownChange: (open: boolean) => void;
onRailClick: (view: RailView) => void; onRailClick: (view: RailView) => void;
onOpenTab?: (type: TabType) => void;
onLogout: () => void; onLogout: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -116,6 +127,27 @@ export function AppRail({
className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200" className="mx-auto h-px bg-border my-0.5 shrink-0 transition-[width] duration-200"
style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }} style={{ width: railExpanded ? "calc(100% - 16px)" : 20 }}
/> />
) : item.kind === "tab" ? (
<button
key={item.tabType}
onClick={() => onOpenTab?.(item.tabType)}
style={btnStyle}
className={`${btnBase} text-muted-foreground hover:text-foreground hover:bg-muted/60`}
>
<span
className="shrink-0 flex items-center justify-center"
style={{ width: 16, height: 16 }}
>
{item.icon}
</span>
<span
className={`text-xs font-medium whitespace-nowrap overflow-hidden transition-opacity duration-150 ${
railExpanded ? "opacity-100 delay-75" : "opacity-0"
}`}
>
{item.title}
</span>
</button>
) : ( ) : (
<button <button
key={item.view} key={item.view}
+3
View File
@@ -5,8 +5,10 @@ import { HostManager } from "@/sidebar/HostManager";
export function CredentialsPanel({ export function CredentialsPanel({
onEditingChange, onEditingChange,
active = true,
}: { }: {
onEditingChange?: (editing: boolean) => void; onEditingChange?: (editing: boolean) => void;
active?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -58,6 +60,7 @@ export function CredentialsPanel({
hideListHeader hideListHeader
externalSearch={managerEditing ? undefined : search} externalSearch={managerEditing ? undefined : search}
onEditingChange={handleEditingChange} onEditingChange={handleEditingChange}
active={active}
/> />
</div> </div>
</div> </div>
+7 -13
View File
@@ -4328,16 +4328,7 @@ function CredentialEditorView({
{["password", "key"].map((m) => ( {["password", "key"].map((m) => (
<button <button
key={m} key={m}
onClick={() => onClick={() => setCredField("type", m as any)}
setCredForm((p) => ({
...p,
type: m as any,
value: "",
publicKey: "",
certPublicKey: "",
passphrase: "",
}))
}
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${type === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`} className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-widest border transition-colors ${type === m ? "border-accent-brand/40 bg-accent-brand/10 text-accent-brand" : "border-border text-muted-foreground hover:text-foreground"}`}
> >
{m === "key" {m === "key"
@@ -4643,12 +4634,14 @@ export function HostManager({
onEditingChange, onEditingChange,
hideListHeader, hideListHeader,
externalSearch, externalSearch,
active = true,
}: { }: {
pendingEditId?: MutableRefObject<string | null>; pendingEditId?: MutableRefObject<string | null>;
pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>; pendingAction?: MutableRefObject<"add-host" | "add-credential" | null>;
onEditingChange?: (editing: boolean) => void; onEditingChange?: (editing: boolean) => void;
hideListHeader?: boolean; hideListHeader?: boolean;
externalSearch?: string; externalSearch?: string;
active?: boolean;
} = {}) { } = {}) {
const { t } = useTranslation(); const { t } = useTranslation();
const [editingHost, setEditingHost] = useState<Host | "new" | null>(null); const [editingHost, setEditingHost] = useState<Host | "new" | null>(null);
@@ -4760,6 +4753,7 @@ export function HostManager({
}, [pendingEditId, pendingAction]); }, [pendingEditId, pendingAction]);
useEffect(() => { useEffect(() => {
if (!active) return;
const handleAddHost = () => { const handleAddHost = () => {
setEditingHost("new"); setEditingHost("new");
setEditingCredential(null); setEditingCredential(null);
@@ -4803,7 +4797,7 @@ export function HostManager({
); );
window.removeEventListener("host-manager:edit-host", handleEditHost); window.removeEventListener("host-manager:edit-host", handleEditHost);
}; };
}, []); }, [active]);
const allHosts = hosts; const allHosts = hosts;
const filteredCredentials = credentials.filter( const filteredCredentials = credentials.filter(
@@ -4958,8 +4952,8 @@ export function HostManager({
const isEditing = !!editingHost || !!editingCredential; const isEditing = !!editingHost || !!editingCredential;
useEffect(() => { useEffect(() => {
onEditingChange?.(isEditing); if (active) onEditingChange?.(isEditing);
}, [isEditing]); }, [isEditing, active]);
return ( return (
<div className="relative flex flex-col flex-1 min-h-0 overflow-hidden"> <div className="relative flex flex-col flex-1 min-h-0 overflow-hidden">
+3 -1
View File
@@ -30,12 +30,14 @@ export function HostsPanel({
hostTree, hostTree,
loading, loading,
onEditingChange, onEditingChange,
active = true,
}: { }: {
onOpenTab: (host: Host, type: TabType) => void; onOpenTab: (host: Host, type: TabType) => void;
onEditHost: (host: Host) => void; onEditHost: (host: Host) => void;
hostTree?: HostFolder; hostTree?: HostFolder;
loading?: boolean; loading?: boolean;
onEditingChange?: (editing: boolean) => void; onEditingChange?: (editing: boolean) => void;
active?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [hostSearch, setHostSearch] = useState(""); const [hostSearch, setHostSearch] = useState("");
@@ -325,7 +327,7 @@ export function HostsPanel({
<div <div
className={managerEditing ? "flex flex-col flex-1 min-h-0" : "hidden"} className={managerEditing ? "flex flex-col flex-1 min-h-0" : "hidden"}
> >
<HostManager onEditingChange={handleEditingChange} /> <HostManager onEditingChange={handleEditingChange} active={active} />
</div> </div>
<HostShareModal <HostShareModal
+234 -233
View File
@@ -280,7 +280,7 @@ export function HostItem({
{/* Action tray — slides open on CSS hover or while menu is open */} {/* Action tray — slides open on CSS hover or while menu is open */}
<div <div
className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[200px] group-hover:opacity-100 ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${isMenuOpen && !selectionMode ? "!max-h-[200px] !opacity-100" : ""}`} className={`overflow-hidden transition-all duration-150 ease-out max-h-0 opacity-0 group-hover:max-h-[300px] group-hover:opacity-100 ${selectionMode ? "!max-h-0 !opacity-0" : ""} ${isMenuOpen && !selectionMode ? "!max-h-[300px] !opacity-100" : ""}`}
> >
{host.online && {host.online &&
((host.cpu != null && host.cpu > 0) || ((host.cpu != null && host.cpu > 0) ||
@@ -317,257 +317,258 @@ export function HostItem({
</div> </div>
)} )}
<div className="flex items-center flex-wrap gap-1 pt-1.5 pl-2 pb-1"> <div className="flex flex-col gap-0.5 pt-1.5 pl-2 pb-1">
{getSshActions(host).map(({ type, icon: Icon, label }) => ( {/* Connection buttons — wrap naturally to a second line */}
<button <div className="flex items-center flex-wrap gap-1">
key={type} {getSshActions(host).map(({ type, icon: Icon, label }) => (
title={label} <button
onClick={(e) => { key={type}
e.stopPropagation(); title={label}
onOpenTab(type); onClick={(e) => {
}} e.stopPropagation();
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors" onOpenTab(type);
> }}
<Icon className="size-3.5" /> className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
</button> >
))} <Icon className="size-3.5" />
{host.enableSsh && </button>
(host.enableRdp || host.enableVnc || host.enableTelnet) && ))}
getSshActions(host).length > 0 && ( {host.enableSsh &&
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" /> (host.enableRdp || host.enableVnc || host.enableTelnet) &&
getSshActions(host).length > 0 && (
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
)}
{host.enableRdp && (
<button
title="RDP"
onClick={(e) => {
e.stopPropagation();
onOpenTab("rdp");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Monitor className="size-3.5" />
</button>
)} )}
{host.enableRdp && ( {host.enableVnc && (
<button <button
title="RDP" title="VNC"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onOpenTab("rdp"); onOpenTab("vnc");
}} }}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors" className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
> >
<Monitor className="size-3.5" /> <Monitor className="size-3.5" />
</button> </button>
)} )}
{host.enableVnc && ( {host.enableTelnet && (
<button <button
title="VNC" title="Telnet"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onOpenTab("vnc"); onOpenTab("telnet");
}} }}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors" className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
> >
<Monitor className="size-3.5" /> <Terminal className="size-3.5" />
</button> </button>
)} )}
{host.enableTelnet && ( </div>
<button
title="Telnet" {/* Separator + management buttons row — always fixed position */}
onClick={(e) => { <div className="flex items-center gap-1 pt-0.5 border-t border-border/40 mt-0.5">
e.stopPropagation(); {onEditHost && (
onOpenTab("telnet");
}}
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
>
<Terminal className="size-3.5" />
</button>
)}
{onEditHost && (
<>
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
<button <button
title="Edit Host" title="Edit Host"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onEditHost(); onEditHost();
}} }}
className="flex items-center justify-center size-7 rounded text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors" 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" /> <Pencil className="size-3.5" />
</button> </button>
</> )}
)} {onShareHost && (
{onShareHost && (
<>
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
<button <button
title={t("hosts.shareHost")} title={t("hosts.shareHost")}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onShareHost(); onShareHost();
}} }}
className="flex items-center justify-center size-7 rounded text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors" className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
> >
<Share2 className="size-3.5" /> <Share2 className="size-3.5" />
</button> </button>
</> )}
)} <DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}>
<DropdownMenu open={isMenuOpen} onOpenChange={onMenuOpenChange}> <DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild> <button
<button title="More options"
title="More options" onClick={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()} className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors"
className="flex items-center justify-center size-7 rounded text-muted-foreground/50 hover:text-foreground hover:bg-muted-foreground/10 transition-colors" >
> <MoreHorizontal className="size-3.5" />
<MoreHorizontal className="size-3.5" /> </button>
</button> </DropdownMenuTrigger>
</DropdownMenuTrigger> <DropdownMenuContent align="end" className="text-xs">
<DropdownMenuContent align="end" className="text-xs"> <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); navigator.clipboard.writeText(
navigator.clipboard.writeText( `${host.username}@${host.ip}`,
`${host.username}@${host.ip}`, );
); toast.success(t("hosts.copiedToClipboard"));
toast.success(t("hosts.copiedToClipboard")); }}
}} >
> <Copy className="size-3.5 mr-2" />
<Copy className="size-3.5 mr-2" /> {t("hosts.copyAddress")}
{t("hosts.copyAddress")} </DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuSub>
<DropdownMenuSub> <DropdownMenuSubTrigger>
<DropdownMenuSubTrigger> <Link className="size-3.5 mr-2" />
<Link className="size-3.5 mr-2" /> {t("hosts.copyLink")}
{t("hosts.copyLink")} </DropdownMenuSubTrigger>
</DropdownMenuSubTrigger> <DropdownMenuSubContent>
<DropdownMenuSubContent> {host.enableSsh && host.enableTerminal && (
{host.enableSsh && host.enableTerminal && ( <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); navigator.clipboard.writeText(
navigator.clipboard.writeText( `${window.location.origin}?view=terminal&hostId=${host.id}`,
`${window.location.origin}?view=terminal&hostId=${host.id}`, );
); toast.success(t("hosts.terminalUrlCopied"));
toast.success(t("hosts.terminalUrlCopied")); }}
}} >
> <Terminal className="size-3.5 mr-2" />
<Terminal className="size-3.5 mr-2" /> {t("hosts.copyTerminalUrlAction")}
{t("hosts.copyTerminalUrlAction")} </DropdownMenuItem>
</DropdownMenuItem> )}
)} {host.enableSsh && host.enableFileManager && (
{host.enableSsh && host.enableFileManager && ( <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); navigator.clipboard.writeText(
navigator.clipboard.writeText( `${window.location.origin}?view=file-manager&hostId=${host.id}`,
`${window.location.origin}?view=file-manager&hostId=${host.id}`, );
); toast.success(t("hosts.fileManagerUrlCopied"));
toast.success(t("hosts.fileManagerUrlCopied")); }}
}} >
> <FolderSearch className="size-3.5 mr-2" />
<FolderSearch className="size-3.5 mr-2" /> {t("hosts.copyFileManagerUrlAction")}
{t("hosts.copyFileManagerUrlAction")} </DropdownMenuItem>
</DropdownMenuItem> )}
)} {host.enableSsh && host.enableTunnel && (
{host.enableSsh && host.enableTunnel && ( <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); navigator.clipboard.writeText(
navigator.clipboard.writeText( `${window.location.origin}?view=tunnel&hostId=${host.id}`,
`${window.location.origin}?view=tunnel&hostId=${host.id}`, );
); toast.success(t("hosts.tunnelUrlCopied"));
toast.success(t("hosts.tunnelUrlCopied")); }}
}} >
> <Network className="size-3.5 mr-2" />
<Network className="size-3.5 mr-2" /> {t("hosts.copyTunnelUrlAction")}
{t("hosts.copyTunnelUrlAction")} </DropdownMenuItem>
</DropdownMenuItem> )}
)} {host.enableSsh && host.enableDocker && (
{host.enableSsh && host.enableDocker && ( <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); navigator.clipboard.writeText(
navigator.clipboard.writeText( `${window.location.origin}?view=docker&hostId=${host.id}`,
`${window.location.origin}?view=docker&hostId=${host.id}`, );
); toast.success(t("hosts.dockerUrlCopied"));
toast.success(t("hosts.dockerUrlCopied")); }}
}} >
> <Box className="size-3.5 mr-2" />
<Box className="size-3.5 mr-2" /> {t("hosts.copyDockerUrlAction")}
{t("hosts.copyDockerUrlAction")} </DropdownMenuItem>
</DropdownMenuItem> )}
)} {host.enableSsh && metricsEnabled && (
{host.enableSsh && metricsEnabled && ( <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); navigator.clipboard.writeText(
navigator.clipboard.writeText( `${window.location.origin}?view=server-stats&hostId=${host.id}`,
`${window.location.origin}?view=server-stats&hostId=${host.id}`, );
); toast.success(t("hosts.serverStatsUrlCopied"));
toast.success(t("hosts.serverStatsUrlCopied")); }}
}} >
> <Server className="size-3.5 mr-2" />
<Server className="size-3.5 mr-2" /> {t("hosts.copyServerStatsUrlAction")}
{t("hosts.copyServerStatsUrlAction")} </DropdownMenuItem>
</DropdownMenuItem> )}
)} {host.enableRdp && (
{host.enableRdp && ( <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); navigator.clipboard.writeText(
navigator.clipboard.writeText( `${window.location.origin}?view=rdp&hostId=${host.id}`,
`${window.location.origin}?view=rdp&hostId=${host.id}`, );
); toast.success(t("hosts.rdpUrlCopied"));
toast.success(t("hosts.rdpUrlCopied")); }}
}} >
> <Monitor className="size-3.5 mr-2" />
<Monitor className="size-3.5 mr-2" /> {t("hosts.copyRdpUrlAction")}
{t("hosts.copyRdpUrlAction")} </DropdownMenuItem>
</DropdownMenuItem> )}
)} {host.enableVnc && (
{host.enableVnc && ( <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); navigator.clipboard.writeText(
navigator.clipboard.writeText( `${window.location.origin}?view=vnc&hostId=${host.id}`,
`${window.location.origin}?view=vnc&hostId=${host.id}`, );
); toast.success(t("hosts.vncUrlCopied"));
toast.success(t("hosts.vncUrlCopied")); }}
}} >
> <Monitor className="size-3.5 mr-2" />
<Monitor className="size-3.5 mr-2" /> {t("hosts.copyVncUrlAction")}
{t("hosts.copyVncUrlAction")} </DropdownMenuItem>
</DropdownMenuItem> )}
)} {host.enableTelnet && (
{host.enableTelnet && ( <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); navigator.clipboard.writeText(
navigator.clipboard.writeText( `${window.location.origin}?view=telnet&hostId=${host.id}`,
`${window.location.origin}?view=telnet&hostId=${host.id}`, );
); toast.success(t("hosts.telnetUrlCopied"));
toast.success(t("hosts.telnetUrlCopied")); }}
}} >
> <Terminal className="size-3.5 mr-2" />
<Terminal className="size-3.5 mr-2" /> {t("hosts.copyTelnetUrlAction")}
{t("hosts.copyTelnetUrlAction")} </DropdownMenuItem>
</DropdownMenuItem> )}
)} </DropdownMenuSubContent>
</DropdownMenuSubContent> </DropdownMenuSub>
</DropdownMenuSub> <DropdownMenuSeparator />
<DropdownMenuSeparator /> <DropdownMenuItem
<DropdownMenuItem onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); onDuplicate();
onDuplicate(); }}
}} >
> <CopyPlus className="size-3.5 mr-2" />
<CopyPlus className="size-3.5 mr-2" /> {t("hosts.cloneHostAction")}
{t("hosts.cloneHostAction")} </DropdownMenuItem>
</DropdownMenuItem> <DropdownMenuSeparator />
<DropdownMenuSeparator /> <DropdownMenuItem
<DropdownMenuItem className="text-destructive focus:text-destructive"
className="text-destructive focus:text-destructive" onClick={(e) => {
onClick={(e) => { e.stopPropagation();
e.stopPropagation(); onDelete();
onDelete(); }}
}} >
> <Trash2 className="size-3.5 mr-2" />
<Trash2 className="size-3.5 mr-2" /> {t("common.delete")}
{t("common.delete")} </DropdownMenuItem>
</DropdownMenuItem> </DropdownMenuContent>
</DropdownMenuContent> </DropdownMenu>
</DropdownMenu> </div>
</div> </div>
</div> </div>
</div> </div>
+2 -1
View File
@@ -123,7 +123,8 @@ export function ConnectionLog({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={toggleExpanded} onClick={hasConnectionError ? undefined : toggleExpanded}
disabled={hasConnectionError}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
{isExpanded ? ( {isExpanded ? (