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