fix: Merge zac/luke

This commit is contained in:
ZacharyZcR
2025-11-10 10:41:20 +08:00
committed by LukeGus
parent 45176bc735
commit 16e35d7976
30 changed files with 5976 additions and 668 deletions

View File

@@ -22,10 +22,15 @@ import {
updateSSHHost,
renameFolder,
exportSSHHostWithCredentials,
getSSHFolders,
updateFolderMetadata,
deleteAllHostsInFolder,
getServerStatusById,
} from "@/ui/main-axios.ts";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import {
Edit,
Trash2,
@@ -45,16 +50,31 @@ import {
Copy,
Activity,
Clock,
Palette,
Trash,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
FolderOpen,
} from "lucide-react";
import type {
SSHHost,
SSHFolder,
SSHManagerHostViewerProps,
} from "../../../../types/index.js";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
import { FolderEditDialog } from "./components/FolderEditDialog";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const { addTab } = useTabs();
const [hosts, setHosts] = useState<SSHHost[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -65,13 +85,24 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
const [editingFolder, setEditingFolder] = useState<string | null>(null);
const [editingFolderName, setEditingFolderName] = useState("");
const [operationLoading, setOperationLoading] = useState(false);
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(
new Map(),
);
const [editingFolderAppearance, setEditingFolderAppearance] = useState<
string | null
>(null);
const [serverStatuses, setServerStatuses] = useState<
Map<number, "online" | "offline" | "degraded">
>(new Map());
const dragCounter = useRef(0);
useEffect(() => {
fetchHosts();
fetchFolderMetadata();
const handleHostsRefresh = () => {
fetchHosts();
fetchFolderMetadata();
};
window.addEventListener("hosts:refresh", handleHostsRefresh);
@@ -116,6 +147,159 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
}
};
const fetchFolderMetadata = async () => {
try {
const folders = await getSSHFolders();
const metadataMap = new Map<string, SSHFolder>();
folders.forEach((folder) => {
metadataMap.set(folder.name, folder);
});
setFolderMetadata(metadataMap);
} catch (error) {
console.error("Failed to fetch folder metadata:", error);
}
};
const handleSaveFolderAppearance = async (
folderName: string,
color: string,
icon: string,
) => {
try {
await updateFolderMetadata(folderName, color, icon);
toast.success(t("hosts.folderAppearanceUpdated"));
await fetchFolderMetadata();
window.dispatchEvent(new CustomEvent("folders:changed"));
} catch (error) {
console.error("Failed to update folder appearance:", error);
toast.error(t("hosts.failedToUpdateFolderAppearance"));
}
};
const handleDeleteAllHostsInFolder = async (folderName: string) => {
const hostsInFolder = hostsByFolder[folderName] || [];
confirmWithToast(
t("hosts.confirmDeleteAllHostsInFolder", {
folder: folderName,
count: hostsInFolder.length,
}),
async () => {
try {
const result = await deleteAllHostsInFolder(folderName);
toast.success(
t("hosts.allHostsInFolderDeleted", {
folder: folderName,
count: result.deletedCount,
}),
);
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"));
}
},
"destructive",
);
};
useEffect(() => {
if (hosts.length === 0) return;
const statusIntervals: NodeJS.Timeout[] = [];
const statusCancelled: boolean[] = [];
hosts.forEach((host, index) => {
const statsConfig = (() => {
try {
return host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
} catch {
return DEFAULT_STATS_CONFIG;
}
})();
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
if (!shouldShowStatus) {
setServerStatuses((prev) => {
const next = new Map(prev);
next.set(host.id, "offline");
return next;
});
return;
}
const fetchStatus = async () => {
try {
const res = await getServerStatusById(host.id);
if (!statusCancelled[index]) {
setServerStatuses((prev) => {
const next = new Map(prev);
next.set(
host.id,
res?.status === "online" ? "online" : "offline",
);
return next;
});
}
} catch (error: unknown) {
if (!statusCancelled[index]) {
const err = error as { response?: { status?: number } };
let status: "online" | "offline" | "degraded" = "offline";
if (err?.response?.status === 504) {
status = "degraded";
}
setServerStatuses((prev) => {
const next = new Map(prev);
next.set(host.id, status);
return next;
});
}
}
};
fetchStatus();
const intervalId = setInterval(fetchStatus, 10000);
statusIntervals.push(intervalId);
});
return () => {
statusCancelled.fill(true);
statusIntervals.forEach((interval) => clearInterval(interval));
};
}, [hosts]);
const getFolderIcon = (folderName: string) => {
const metadata = folderMetadata.get(folderName);
if (!metadata?.icon) return Folder;
const iconMap: Record<string, React.ComponentType> = {
Folder,
Server,
Cloud,
Database,
Box,
Package,
Layers,
Archive,
HardDrive,
Globe,
};
return iconMap[metadata.icon] || Folder;
};
const getFolderColor = (folderName: string) => {
const metadata = folderMetadata.get(folderName);
return metadata?.color;
};
const handleDelete = async (hostId: number, hostName: string) => {
confirmWithToast(
t("hosts.confirmDelete", { name: hostName }),
@@ -854,7 +1038,18 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<AccordionItem value={folder} className="border-none">
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
<div className="flex items-center gap-2 flex-1">
<Folder className="h-4 w-4" />
{(() => {
const FolderIcon = getFolderIcon(folder);
const folderColor = getFolderColor(folder);
return (
<FolderIcon
className="h-4 w-4"
style={
folderColor ? { color: folderColor } : undefined
}
/>
);
})()}
{editingFolder === folder ? (
<div
className="flex items-center gap-2"
@@ -935,6 +1130,50 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<Badge variant="secondary" className="text-xs">
{folderHosts.length}
</Badge>
{folder !== t("hosts.uncategorized") && (
<div className="flex items-center gap-1 ml-auto">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
setEditingFolderAppearance(folder);
}}
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 transition-opacity"
>
<Palette className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("hosts.editFolderAppearance")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
onClick={(e) => {
e.stopPropagation();
handleDeleteAllHostsInFolder(folder);
}}
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 hover:text-red-400 transition-all"
>
<Trash className="h-3 w-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
{t("hosts.deleteAllHostsInFolder")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
</AccordionTrigger>
<AccordionContent className="p-2">
@@ -957,6 +1196,32 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1">
{(() => {
const statsConfig = (() => {
try {
return host.statsConfig
? JSON.parse(host.statsConfig)
: DEFAULT_STATS_CONFIG;
} catch {
return DEFAULT_STATS_CONFIG;
}
})();
const shouldShowStatus =
statsConfig.statusCheckEnabled !==
false;
const serverStatus =
serverStatuses.get(host.id) ||
"degraded";
return shouldShowStatus ? (
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
) : null;
})()}
{host.pin && (
<Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
)}
@@ -1179,6 +1444,88 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
})()}
</div>
</div>
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
{host.enableTerminal && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "terminal",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
>
<Terminal className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Terminal</p>
</TooltipContent>
</Tooltip>
)}
{host.enableFileManager && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "file_manager",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-emerald-500/10 hover:border-emerald-500/50 flex-1"
>
<FolderOpen className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open File Manager</p>
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
const title = host.name?.trim()
? host.name
: `${host.username}@${host.ip}:${host.port}`;
addTab({
type: "server",
title,
hostConfig: host,
});
}}
className="h-7 px-2 hover:bg-purple-500/10 hover:border-purple-500/50 flex-1"
>
<Server className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Open Server Details</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipTrigger>
<TooltipContent>
@@ -1202,6 +1549,26 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
))}
</div>
</ScrollArea>
{editingFolderAppearance && (
<FolderEditDialog
folderName={editingFolderAppearance}
currentColor={getFolderColor(editingFolderAppearance)}
currentIcon={folderMetadata.get(editingFolderAppearance)?.icon}
open={editingFolderAppearance !== null}
onOpenChange={(open) => {
if (!open) setEditingFolderAppearance(null);
}}
onSave={async (color, icon) => {
await handleSaveFolderAppearance(
editingFolderAppearance,
color,
icon,
);
setEditingFolderAppearance(null);
}}
/>
)}
</div>
);
}