fix: More bug fixes and QOL fixes

This commit is contained in:
LukeGus
2025-11-13 00:07:01 -06:00
parent bd7f9730b0
commit e564748d01
33 changed files with 611 additions and 245 deletions

View File

@@ -15,6 +15,7 @@ import { PasswordInput } from "@/components/ui/password-input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert";
import React, { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import {
@@ -52,6 +53,8 @@ export function CredentialEditor({
const [detectedKeyType, setDetectedKeyType] = useState<string | null>(null);
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState<string | null>(null);
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
string | null
@@ -60,6 +63,11 @@ export function CredentialEditor({
useState(false);
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Clear error when tab changes
useEffect(() => {
setFormError(null);
}, [activeTab]);
useEffect(() => {
const fetchData = async () => {
try {
@@ -320,6 +328,8 @@ export function CredentialEditor({
const onSubmit = async (data: FormData) => {
try {
setFormError(null);
if (!data.name || data.name.trim() === "") {
data.name = data.username;
}
@@ -378,6 +388,28 @@ export function CredentialEditor({
}
};
const handleFormError = () => {
const errors = form.formState.errors;
if (
errors.name ||
errors.username ||
errors.description ||
errors.folder ||
errors.tags
) {
setActiveTab("general");
} else if (
errors.password ||
errors.key ||
errors.publicKey ||
errors.keyPassword ||
errors.keyType
) {
setActiveTab("authentication");
}
};
const [tagInput, setTagInput] = useState("");
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
@@ -427,11 +459,20 @@ export function CredentialEditor({
>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
className="flex flex-col flex-1 min-h-0 h-full"
>
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<Tabs defaultValue="general" className="w-full">
{formError && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList>
<TabsTrigger value="general">
{t("credentials.general")}

View File

@@ -204,27 +204,59 @@ export function Dashboard({
setServerStatsLoading(true);
const serversWithStats = await Promise.all(
hosts.slice(0, 50).map(async (host: { id: number; name: string }) => {
try {
const metrics = await getServerMetricsById(host.id);
return {
id: host.id,
name: host.name || `Host ${host.id}`,
cpu: metrics.cpu.percent,
ram: metrics.memory.percent,
};
} catch {
return {
id: host.id,
name: host.name || `Host ${host.id}`,
cpu: null,
ram: null,
};
}
}),
hosts
.slice(0, 50)
.map(
async (host: {
id: number;
name: string;
statsConfig?: string | { metricsEnabled?: boolean };
}) => {
try {
// Parse statsConfig if it's a string
let statsConfig: { metricsEnabled?: boolean } = {
metricsEnabled: true,
};
if (host.statsConfig) {
if (typeof host.statsConfig === "string") {
statsConfig = JSON.parse(host.statsConfig);
} else {
statsConfig = host.statsConfig;
}
}
// Skip if metrics are disabled
if (statsConfig.metricsEnabled === false) {
return null;
}
const metrics = await getServerMetricsById(host.id);
return {
id: host.id,
name: host.name || `Host ${host.id}`,
cpu: metrics.cpu.percent,
ram: metrics.memory.percent,
};
} catch {
return {
id: host.id,
name: host.name || `Host ${host.id}`,
cpu: null,
ram: null,
};
}
},
),
);
const validServerStats = serversWithStats.filter(
(server) => server.cpu !== null && server.ram !== null,
(
server,
): server is {
id: number;
name: string;
cpu: number | null;
ram: number | null;
} => server !== null && server.cpu !== null && server.ram !== null,
);
setServerStats(validServerStats);
setServerStatsLoading(false);
@@ -339,7 +371,7 @@ export function Dashboard({
</div>
) : (
<div
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex"
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex min-w-0"
style={{
marginLeft: leftMarginPx,
marginRight: rightSidebarOpen
@@ -352,19 +384,19 @@ export function Dashboard({
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
}}
>
<div className="flex flex-col relative z-10 w-full h-full">
<div className="flex flex-row items-center justify-between w-full px-3 mt-3">
<div className="text-2xl text-white font-semibold">
<div className="flex flex-col relative z-10 w-full h-full min-w-0">
<div className="flex flex-row items-center justify-between w-full px-3 mt-3 min-w-0 flex-wrap gap-2">
<div className="text-2xl text-white font-semibold shrink-0">
{t("dashboard.title")}
</div>
<div className="flex flex-row gap-3">
<div className="flex flex-col items-center gap-4 justify-center mr-5">
<p className="text-muted-foreground text-sm">
<div className="flex flex-row gap-3 flex-wrap min-w-0">
<div className="flex flex-col items-center gap-4 justify-center mr-5 min-w-0 shrink">
<p className="text-muted-foreground text-sm whitespace-nowrap">
Press <Kbd>LShift</Kbd> twice to open the command palette
</p>
</div>
<Button
className="font-semibold"
className="font-semibold shrink-0"
variant="outline"
onClick={() =>
window.open(
@@ -376,7 +408,7 @@ export function Dashboard({
{t("dashboard.github")}
</Button>
<Button
className="font-semibold"
className="font-semibold shrink-0"
variant="outline"
onClick={() =>
window.open(
@@ -388,7 +420,7 @@ export function Dashboard({
{t("dashboard.support")}
</Button>
<Button
className="font-semibold"
className="font-semibold shrink-0"
variant="outline"
onClick={() =>
window.open(
@@ -400,7 +432,7 @@ export function Dashboard({
{t("dashboard.discord")}
</Button>
<Button
className="font-semibold"
className="font-semibold shrink-0"
variant="outline"
onClick={() =>
window.open("https://github.com/sponsors/LukeGus", "_blank")
@@ -413,23 +445,23 @@ export function Dashboard({
<Separator className="mt-3 p-0.25" />
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0">
<div className="flex flex-row flex-1 gap-4 min-h-0">
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0 min-w-0">
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<Server className="mr-3" />
{t("dashboard.serverOverview")}
</p>
<div className="bg-dark-bg w-full h-auto border-2 border-dark-border rounded-md px-3 py-3">
<div className="flex flex-row items-center justify-between mb-3">
<div className="flex flex-row items-center">
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<History
size={20}
color="#FFFFFF"
className="shrink-0"
/>
<p className="ml-2 leading-none">
<p className="ml-2 leading-none truncate">
{t("dashboard.version")}
</p>
</div>
@@ -451,14 +483,14 @@ export function Dashboard({
</div>
</div>
<div className="flex flex-row items-center justify-between mb-5">
<div className="flex flex-row items-center">
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Clock
size={20}
color="#FFFFFF"
className="shrink-0"
/>
<p className="ml-2 leading-none">
<p className="ml-2 leading-none truncate">
{t("dashboard.uptime")}
</p>
</div>
@@ -470,14 +502,14 @@ export function Dashboard({
</div>
</div>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Database
size={20}
color="#FFFFFF"
className="shrink-0"
/>
<p className="ml-2 leading-none">
<p className="ml-2 leading-none truncate">
{t("dashboard.database")}
</p>
</div>
@@ -494,14 +526,14 @@ export function Dashboard({
</div>
</div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
<div className="flex flex-row items-center">
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Server
size={16}
color="#FFFFFF"
className="mr-3 shrink-0"
/>
<p className="m-0 leading-none">
<p className="m-0 leading-none truncate">
{t("dashboard.totalServers")}
</p>
</div>
@@ -509,14 +541,14 @@ export function Dashboard({
{totalServers}
</p>
</div>
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
<div className="flex flex-row items-center">
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Network
size={16}
color="#FFFFFF"
className="mr-3 shrink-0"
/>
<p className="m-0 leading-none">
<p className="m-0 leading-none truncate">
{t("dashboard.totalTunnels")}
</p>
</div>
@@ -526,14 +558,14 @@ export function Dashboard({
</div>
</div>
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
<div className="flex flex-row items-center">
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
<div className="flex flex-row items-center min-w-0">
<Key
size={16}
color="#FFFFFF"
className="mr-3 shrink-0"
/>
<p className="m-0 leading-none">
<p className="m-0 leading-none truncate">
{t("dashboard.totalCredentials")}
</p>
</div>
@@ -544,7 +576,7 @@ export function Dashboard({
</div>
</div>
</div>
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<div className="flex flex-row items-center justify-between mb-3 mt-1">
<p className="text-xl font-semibold flex flex-row items-center">
@@ -561,7 +593,7 @@ export function Dashboard({
</Button>
</div>
<div
className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{recentActivityLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
@@ -577,7 +609,7 @@ export function Dashboard({
<Button
key={item.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg"
className="border-2 !border-dark-border bg-dark-bg min-w-0"
onClick={() => handleActivityClick(item)}
>
{item.type === "terminal" ? (
@@ -595,17 +627,17 @@ export function Dashboard({
</div>
</div>
</div>
<div className="flex flex-row flex-1 gap-4 min-h-0">
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<FastForward className="mr-3" />
{t("dashboard.quickActions")}
</p>
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-y-auto overflow-x-hidden">
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden">
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
onClick={handleAddHost}
>
<Server
@@ -618,7 +650,7 @@ export function Dashboard({
</Button>
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
onClick={handleAddCredential}
>
<Key
@@ -632,7 +664,7 @@ export function Dashboard({
{isAdmin && (
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
onClick={handleOpenAdminSettings}
>
<Settings
@@ -646,7 +678,7 @@ export function Dashboard({
)}
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
onClick={handleOpenUserProfile}
>
<User
@@ -660,14 +692,14 @@ export function Dashboard({
</div>
</div>
</div>
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
<ChartLine className="mr-3" />
{t("dashboard.serverStats")}
</p>
<div
className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
>
{serverStatsLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
@@ -683,7 +715,7 @@ export function Dashboard({
<Button
key={server.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg h-auto p-3"
className="border-2 !border-dark-border bg-dark-bg h-auto p-3 min-w-0"
>
<div className="flex flex-col w-full">
<div className="flex flex-row items-center mb-2">

View File

@@ -900,6 +900,26 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
);
}
function handleCopyPath(files: FileItem[]) {
if (files.length === 0) return;
const paths = files.map((file) => file.path).join("\n");
navigator.clipboard.writeText(paths).then(
() => {
toast.success(
files.length === 1
? t("fileManager.pathCopiedToClipboard")
: t("fileManager.pathsCopiedToClipboard", { count: files.length }),
);
},
(err) => {
console.error("Failed to copy path to clipboard:", err);
toast.error(t("fileManager.failedToCopyPath"));
},
);
}
async function handlePasteFiles() {
if (!clipboard || !sshSessionId) return;
@@ -2064,6 +2084,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onProperties={handleOpenPermissionsDialog}
onExtractArchive={handleExtractArchive}
onCompress={handleOpenCompressDialog}
onCopyPath={handleCopyPath}
/>
</div>
</div>

View File

@@ -63,6 +63,7 @@ interface ContextMenuProps {
currentPath?: string;
onExtractArchive?: (file: FileItem) => void;
onCompress?: (files: FileItem[]) => void;
onCopyPath?: (files: FileItem[]) => void;
}
interface MenuItem {
@@ -104,6 +105,7 @@ export function FileManagerContextMenu({
currentPath,
onExtractArchive,
onCompress,
onCopyPath,
}: ContextMenuProps) {
const { t } = useTranslation();
const [menuPosition, setMenuPosition] = useState({ x, y });
@@ -365,7 +367,18 @@ export function FileManagerContextMenu({
});
}
if ((isSingleFile && onRename) || onCopy || onCut) {
if (onCopyPath) {
menuItems.push({
icon: <Clipboard className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.copyPaths")
: t("fileManager.copyPath"),
action: () => onCopyPath(files),
shortcut: "Ctrl+Shift+P",
});
}
if ((isSingleFile && onRename) || onCopy || onCut || onCopyPath) {
menuItems.push({ separator: true } as MenuItem);
}

View File

@@ -72,13 +72,14 @@ export function HostManager({
};
const handleTabChange = (value: string) => {
setActiveTab(value);
if (value !== "add_host") {
// Only clear editing state when leaving the respective tabs, not when entering them
if (activeTab === "add_host" && value !== "add_host") {
setEditingHost(null);
}
if (value !== "add_credential") {
if (activeTab === "add_credential" && value !== "add_credential") {
setEditingCredential(null);
}
setActiveTab(value);
};
const topMarginPx = isTopbarOpen ? 74 : 26;

View File

@@ -345,6 +345,13 @@ export function HostManagerEditor({
"upload",
);
const isSubmittingRef = useRef(false);
const [activeTab, setActiveTab] = useState("general");
const [formError, setFormError] = useState<string | null>(null);
// Clear error when tab changes
useEffect(() => {
setFormError(null);
}, [activeTab]);
const [statusIntervalUnit, setStatusIntervalUnit] = useState<
"seconds" | "minutes"
@@ -817,6 +824,7 @@ export function HostManagerEditor({
const onSubmit = async (data: FormData) => {
try {
isSubmittingRef.current = true;
setFormError(null);
if (!data.name || data.name.trim() === "") {
data.name = `${data.username}@${data.ip}`;
@@ -828,12 +836,16 @@ export function HostManagerEditor({
if (statusInterval < 5 || statusInterval > 3600) {
toast.error(t("hosts.intervalValidation"));
setActiveTab("statistics");
setFormError(t("hosts.intervalValidation"));
isSubmittingRef.current = false;
return;
}
if (metricsInterval < 5 || metricsInterval > 3600) {
toast.error(t("hosts.intervalValidation"));
setActiveTab("statistics");
setFormError(t("hosts.intervalValidation"));
isSubmittingRef.current = false;
return;
}
@@ -943,13 +955,47 @@ export function HostManagerEditor({
);
notifyHostCreatedOrUpdated(savedHost.id);
}
} catch {
} catch (error) {
toast.error(t("hosts.failedToSaveHost"));
console.error("Failed to save host:", error);
} finally {
isSubmittingRef.current = false;
}
};
// Handle form validation errors
const handleFormError = () => {
const errors = form.formState.errors;
// Determine which tab contains the error
if (
errors.ip ||
errors.port ||
errors.username ||
errors.name ||
errors.folder ||
errors.tags ||
errors.pin ||
errors.password ||
errors.key ||
errors.keyPassword ||
errors.keyType ||
errors.credentialId ||
errors.forceKeyboardInteractive ||
errors.jumpHosts
) {
setActiveTab("general");
} else if (errors.enableTerminal || errors.terminalConfig) {
setActiveTab("terminal");
} else if (errors.enableTunnel || errors.tunnelConnections) {
setActiveTab("tunnel");
} else if (errors.enableFileManager || errors.defaultPath) {
setActiveTab("file_manager");
} else if (errors.statsConfig) {
setActiveTab("statistics");
}
};
const [tagInput, setTagInput] = useState("");
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
@@ -1038,12 +1084,26 @@ export function HostManagerEditor({
const getFilteredSshConfigs = (index: number) => {
const value = form.watch(`tunnelConnections.${index}.endpointHost`);
const currentHostName =
form.watch("name") || `${form.watch("username")}@${form.watch("ip")}`;
const currentHostId = editingHost?.id;
let filtered = sshConfigurations.filter(
(config) => config !== currentHostName,
);
let filtered = sshConfigurations;
// Filter out the current host being edited (by ID, not by name)
if (currentHostId) {
const currentHostName = hosts.find((h) => h.id === currentHostId)?.name;
if (currentHostName) {
filtered = sshConfigurations.filter(
(config) => config !== currentHostName,
);
}
} else {
// If creating a new host, filter by the name being entered
const currentHostName =
form.watch("name") || `${form.watch("username")}@${form.watch("ip")}`;
filtered = sshConfigurations.filter(
(config) => config !== currentHostName,
);
}
if (value) {
filtered = filtered.filter((config) =>
@@ -1099,12 +1159,21 @@ export function HostManagerEditor({
<div className="flex-1 flex flex-col h-full min-h-0 w-full">
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
className="flex flex-col flex-1 min-h-0 h-full"
>
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
<div className="pr-4">
<Tabs defaultValue="general" className="w-full">
{formError && (
<Alert variant="destructive" className="mb-4">
<AlertDescription>{formError}</AlertDescription>
</Alert>
)}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="w-full"
>
<TabsList>
<TabsTrigger value="general">
{t("hosts.general")}

View File

@@ -199,9 +199,6 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
await fetchHosts();
await fetchFolderMetadata();
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
const { refreshServerPolling } = await import("@/ui/main-axios.ts");
refreshServerPolling();
} catch (error) {
console.error("Failed to delete hosts in folder:", error);
toast.error(t("hosts.failedToDeleteHostsInFolder"));