feat: Add folder customization and batch delete for SSH host manager
Allow users to customize folder appearance and manage hosts in bulk: Database & Types: - Add sshFolders table with color and icon fields for folder metadata - Add SSHFolder interface to types with userId, name, color, icon fields - Update folder rename route to also update folder metadata API Endpoints: - GET /ssh/folders - Fetch all folders with metadata for user - PUT /ssh/folders/metadata - Create or update folder color and icon - DELETE /ssh/folders/:name/hosts - Delete all hosts in a folder and folder metadata Frontend Features: - Create FolderEditDialog component with color picker (8 colors) and icon selector (10 icons) - Add folder metadata state management in HostManagerViewer - Display custom folder colors and icons in host manager UI - Add "Edit Folder Appearance" button with palette icon - Add "Delete All Hosts in Folder" button with trash icon and confirmation - Fetch and sync folder metadata on component mount and refresh API Functions: - getSSHFolders() - Retrieve all folder metadata - updateFolderMetadata(name, color, icon) - Update folder appearance - deleteAllHostsInFolder(folderName) - Batch delete with count return i18n Support: - Add translations for folder customization (en, zh) - Add batch delete confirmation messages - Add success/error toast messages
This commit is contained in:
@@ -209,6 +209,22 @@ export const snippets = sqliteTable("snippets", {
|
|||||||
.default(sql`CURRENT_TIMESTAMP`),
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const sshFolders = sqliteTable("ssh_folders", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id),
|
||||||
|
name: text("name").notNull(),
|
||||||
|
color: text("color"),
|
||||||
|
icon: text("icon"),
|
||||||
|
createdAt: text("created_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: text("updated_at")
|
||||||
|
.notNull()
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
export const recentActivity = sqliteTable("recent_activity", {
|
export const recentActivity = sqliteTable("recent_activity", {
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
fileManagerRecent,
|
fileManagerRecent,
|
||||||
fileManagerPinned,
|
fileManagerPinned,
|
||||||
fileManagerShortcuts,
|
fileManagerShortcuts,
|
||||||
|
sshFolders,
|
||||||
} from "../db/schema.js";
|
} from "../db/schema.js";
|
||||||
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
import { eq, and, desc, isNotNull, or } from "drizzle-orm";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
@@ -1341,6 +1342,17 @@ router.put(
|
|||||||
|
|
||||||
DatabaseSaveTrigger.triggerSave("folder_rename");
|
DatabaseSaveTrigger.triggerSave("folder_rename");
|
||||||
|
|
||||||
|
// Also update folder metadata if exists
|
||||||
|
await db
|
||||||
|
.update(sshFolders)
|
||||||
|
.set({
|
||||||
|
name: newName,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)),
|
||||||
|
);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: "Folder renamed successfully",
|
message: "Folder renamed successfully",
|
||||||
updatedHosts: updatedHosts.length,
|
updatedHosts: updatedHosts.length,
|
||||||
@@ -1358,6 +1370,157 @@ router.put(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Route: Get all folders with metadata (requires JWT)
|
||||||
|
// GET /ssh/db/folders
|
||||||
|
router.get(
|
||||||
|
"/folders",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId)) {
|
||||||
|
return res.status(400).json({ error: "Invalid user ID" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const folders = await db
|
||||||
|
.select()
|
||||||
|
.from(sshFolders)
|
||||||
|
.where(eq(sshFolders.userId, userId));
|
||||||
|
|
||||||
|
res.json(folders);
|
||||||
|
} catch (err) {
|
||||||
|
sshLogger.error("Failed to fetch folders", err, {
|
||||||
|
operation: "fetch_folders",
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to fetch folders" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Route: Update folder metadata (requires JWT)
|
||||||
|
// PUT /ssh/db/folders/metadata
|
||||||
|
router.put(
|
||||||
|
"/folders/metadata",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const { name, color, icon } = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !name) {
|
||||||
|
return res.status(400).json({ error: "Folder name is required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if folder metadata exists
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(sshFolders)
|
||||||
|
.where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
// Update existing
|
||||||
|
await db
|
||||||
|
.update(sshFolders)
|
||||||
|
.set({
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
await db.insert(sshFolders).values({
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
DatabaseSaveTrigger.triggerSave("folder_metadata_update");
|
||||||
|
|
||||||
|
res.json({ message: "Folder metadata updated successfully" });
|
||||||
|
} catch (err) {
|
||||||
|
sshLogger.error("Failed to update folder metadata", err, {
|
||||||
|
operation: "update_folder_metadata",
|
||||||
|
userId,
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to update folder metadata" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Route: Delete all hosts in folder (requires JWT)
|
||||||
|
// DELETE /ssh/db/folders/:name/hosts
|
||||||
|
router.delete(
|
||||||
|
"/folders/:name/hosts",
|
||||||
|
authenticateJWT,
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as AuthenticatedRequest).userId;
|
||||||
|
const folderName = req.params.name;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !folderName) {
|
||||||
|
return res.status(400).json({ error: "Invalid folder name" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all hosts in the folder
|
||||||
|
const hostsToDelete = await db
|
||||||
|
.select()
|
||||||
|
.from(sshData)
|
||||||
|
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
|
||||||
|
|
||||||
|
if (hostsToDelete.length === 0) {
|
||||||
|
return res.json({
|
||||||
|
message: "No hosts found in folder",
|
||||||
|
deletedCount: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete all hosts
|
||||||
|
await db
|
||||||
|
.delete(sshData)
|
||||||
|
.where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName)));
|
||||||
|
|
||||||
|
// Delete folder metadata
|
||||||
|
await db
|
||||||
|
.delete(sshFolders)
|
||||||
|
.where(
|
||||||
|
and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)),
|
||||||
|
);
|
||||||
|
|
||||||
|
DatabaseSaveTrigger.triggerSave("folder_hosts_delete");
|
||||||
|
|
||||||
|
sshLogger.info("Deleted all hosts in folder", {
|
||||||
|
operation: "delete_folder_hosts",
|
||||||
|
userId,
|
||||||
|
folderName,
|
||||||
|
deletedCount: hostsToDelete.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: "All hosts in folder deleted successfully",
|
||||||
|
deletedCount: hostsToDelete.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
sshLogger.error("Failed to delete hosts in folder", err, {
|
||||||
|
operation: "delete_folder_hosts",
|
||||||
|
userId,
|
||||||
|
folderName,
|
||||||
|
});
|
||||||
|
res.status(500).json({ error: "Failed to delete hosts in folder" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Route: Bulk import SSH hosts (requires JWT)
|
// Route: Bulk import SSH hosts (requires JWT)
|
||||||
// POST /ssh/bulk-import
|
// POST /ssh/bulk-import
|
||||||
router.post(
|
router.post(
|
||||||
|
|||||||
@@ -754,6 +754,17 @@
|
|||||||
"failedToRemoveFromFolder": "Failed to remove host from folder",
|
"failedToRemoveFromFolder": "Failed to remove host from folder",
|
||||||
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||||
"failedToRenameFolder": "Failed to rename folder",
|
"failedToRenameFolder": "Failed to rename folder",
|
||||||
|
"editFolderAppearance": "Edit Folder Appearance",
|
||||||
|
"editFolderAppearanceDesc": "Customize the color and icon for folder",
|
||||||
|
"folderColor": "Folder Color",
|
||||||
|
"folderIcon": "Folder Icon",
|
||||||
|
"preview": "Preview",
|
||||||
|
"folderAppearanceUpdated": "Folder appearance updated successfully",
|
||||||
|
"failedToUpdateFolderAppearance": "Failed to update folder appearance",
|
||||||
|
"deleteAllHostsInFolder": "Delete All Hosts in Folder",
|
||||||
|
"confirmDeleteAllHostsInFolder": "Are you sure you want to delete all {{count}} hosts in folder \"{{folder}}\"? This action cannot be undone.",
|
||||||
|
"allHostsInFolderDeleted": "Deleted {{count}} hosts from folder \"{{folder}}\" successfully",
|
||||||
|
"failedToDeleteHostsInFolder": "Failed to delete hosts in folder",
|
||||||
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
|
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
|
||||||
"failedToMoveToFolder": "Failed to move host to folder",
|
"failedToMoveToFolder": "Failed to move host to folder",
|
||||||
"statistics": "Statistics",
|
"statistics": "Statistics",
|
||||||
|
|||||||
@@ -766,6 +766,17 @@
|
|||||||
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
||||||
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||||
"failedToRenameFolder": "重命名文件夹失败",
|
"failedToRenameFolder": "重命名文件夹失败",
|
||||||
|
"editFolderAppearance": "编辑文件夹外观",
|
||||||
|
"editFolderAppearanceDesc": "自定义文件夹的颜色和图标",
|
||||||
|
"folderColor": "文件夹颜色",
|
||||||
|
"folderIcon": "文件夹图标",
|
||||||
|
"preview": "预览",
|
||||||
|
"folderAppearanceUpdated": "文件夹外观更新成功",
|
||||||
|
"failedToUpdateFolderAppearance": "更新文件夹外观失败",
|
||||||
|
"deleteAllHostsInFolder": "删除文件夹内所有主机",
|
||||||
|
"confirmDeleteAllHostsInFolder": "确定要删除文件夹\"{{folder}}\"中的全部 {{count}} 个主机吗?此操作无法撤销。",
|
||||||
|
"allHostsInFolderDeleted": "已成功从文件夹\"{{folder}}\"删除 {{count}} 个主机",
|
||||||
|
"failedToDeleteHostsInFolder": "删除文件夹中的主机失败",
|
||||||
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
|
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||||
"failedToMoveToFolder": "移动主机到文件夹失败",
|
"failedToMoveToFolder": "移动主机到文件夹失败",
|
||||||
"statistics": "统计",
|
"statistics": "统计",
|
||||||
|
|||||||
@@ -62,6 +62,16 @@ export interface SSHHostData {
|
|||||||
terminalConfig?: TerminalConfig;
|
terminalConfig?: TerminalConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SSHFolder {
|
||||||
|
id: number;
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
color?: string;
|
||||||
|
icon?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// CREDENTIAL TYPES
|
// CREDENTIAL TYPES
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ import {
|
|||||||
updateSSHHost,
|
updateSSHHost,
|
||||||
renameFolder,
|
renameFolder,
|
||||||
exportSSHHostWithCredentials,
|
exportSSHHostWithCredentials,
|
||||||
|
getSSHFolders,
|
||||||
|
updateFolderMetadata,
|
||||||
|
deleteAllHostsInFolder,
|
||||||
} from "@/ui/main-axios.ts";
|
} from "@/ui/main-axios.ts";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -45,12 +48,24 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Activity,
|
Activity,
|
||||||
Clock,
|
Clock,
|
||||||
|
Palette,
|
||||||
|
Trash,
|
||||||
|
Cloud,
|
||||||
|
Database,
|
||||||
|
Box,
|
||||||
|
Package,
|
||||||
|
Layers,
|
||||||
|
Archive,
|
||||||
|
HardDrive,
|
||||||
|
Globe,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type {
|
import type {
|
||||||
SSHHost,
|
SSHHost,
|
||||||
|
SSHFolder,
|
||||||
SSHManagerHostViewerProps,
|
SSHManagerHostViewerProps,
|
||||||
} from "../../../../types/index.js";
|
} from "../../../../types/index.js";
|
||||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||||
|
import { FolderEditDialog } from "./components/FolderEditDialog";
|
||||||
|
|
||||||
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -65,13 +80,17 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||||
const [editingFolderName, setEditingFolderName] = useState("");
|
const [editingFolderName, setEditingFolderName] = useState("");
|
||||||
const [operationLoading, setOperationLoading] = useState(false);
|
const [operationLoading, setOperationLoading] = useState(false);
|
||||||
|
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(new Map());
|
||||||
|
const [editingFolderAppearance, setEditingFolderAppearance] = useState<string | null>(null);
|
||||||
const dragCounter = useRef(0);
|
const dragCounter = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
|
fetchFolderMetadata();
|
||||||
|
|
||||||
const handleHostsRefresh = () => {
|
const handleHostsRefresh = () => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
|
fetchFolderMetadata();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("hosts:refresh", handleHostsRefresh);
|
window.addEventListener("hosts:refresh", handleHostsRefresh);
|
||||||
@@ -116,6 +135,87 @@ 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",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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) => {
|
const handleDelete = async (hostId: number, hostName: string) => {
|
||||||
confirmWithToast(
|
confirmWithToast(
|
||||||
t("hosts.confirmDelete", { name: hostName }),
|
t("hosts.confirmDelete", { name: hostName }),
|
||||||
@@ -854,7 +954,16 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
<AccordionItem value={folder} className="border-none">
|
<AccordionItem value={folder} className="border-none">
|
||||||
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
<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">
|
<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 ? (
|
{editingFolder === folder ? (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
@@ -935,6 +1044,50 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{folderHosts.length}
|
{folderHosts.length}
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="p-2">
|
<AccordionContent className="p-2">
|
||||||
@@ -1202,6 +1355,22 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
189
src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx
Normal file
189
src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
Server,
|
||||||
|
Cloud,
|
||||||
|
Database,
|
||||||
|
Box,
|
||||||
|
Package,
|
||||||
|
Layers,
|
||||||
|
Archive,
|
||||||
|
HardDrive,
|
||||||
|
Globe,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface FolderEditDialogProps {
|
||||||
|
folderName: string;
|
||||||
|
currentColor?: string;
|
||||||
|
currentIcon?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSave: (color: string, icon: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVAILABLE_COLORS = [
|
||||||
|
{ value: "#ef4444", label: "Red" },
|
||||||
|
{ value: "#f97316", label: "Orange" },
|
||||||
|
{ value: "#eab308", label: "Yellow" },
|
||||||
|
{ value: "#22c55e", label: "Green" },
|
||||||
|
{ value: "#3b82f6", label: "Blue" },
|
||||||
|
{ value: "#a855f7", label: "Purple" },
|
||||||
|
{ value: "#ec4899", label: "Pink" },
|
||||||
|
{ value: "#6b7280", label: "Gray" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const AVAILABLE_ICONS = [
|
||||||
|
{ value: "Folder", label: "Folder", Icon: Folder },
|
||||||
|
{ value: "Server", label: "Server", Icon: Server },
|
||||||
|
{ value: "Cloud", label: "Cloud", Icon: Cloud },
|
||||||
|
{ value: "Database", label: "Database", Icon: Database },
|
||||||
|
{ value: "Box", label: "Box", Icon: Box },
|
||||||
|
{ value: "Package", label: "Package", Icon: Package },
|
||||||
|
{ value: "Layers", label: "Layers", Icon: Layers },
|
||||||
|
{ value: "Archive", label: "Archive", Icon: Archive },
|
||||||
|
{ value: "HardDrive", label: "HardDrive", Icon: HardDrive },
|
||||||
|
{ value: "Globe", label: "Globe", Icon: Globe },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function FolderEditDialog({
|
||||||
|
folderName,
|
||||||
|
currentColor,
|
||||||
|
currentIcon,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSave,
|
||||||
|
}: FolderEditDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selectedColor, setSelectedColor] = useState(currentColor || AVAILABLE_COLORS[0].value);
|
||||||
|
const [selectedIcon, setSelectedIcon] = useState(currentIcon || AVAILABLE_ICONS[0].value);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelectedColor(currentColor || AVAILABLE_COLORS[0].value);
|
||||||
|
setSelectedIcon(currentIcon || AVAILABLE_ICONS[0].value);
|
||||||
|
}
|
||||||
|
}, [open, currentColor, currentIcon]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onSave(selectedColor, selectedIcon);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save folder metadata:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Folder className="w-5 h-5" />
|
||||||
|
{t("hosts.editFolderAppearance")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-muted-foreground">
|
||||||
|
{t("hosts.editFolderAppearanceDesc")}: <span className="font-mono text-foreground">{folderName}</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-6 py-4">
|
||||||
|
{/* Color Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold text-foreground">
|
||||||
|
{t("hosts.folderColor")}
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{AVAILABLE_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.value}
|
||||||
|
type="button"
|
||||||
|
className={`h-12 rounded-md border-2 transition-all hover:scale-105 ${
|
||||||
|
selectedColor === color.value
|
||||||
|
? "border-white shadow-lg scale-105"
|
||||||
|
: "border-dark-border"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: color.value }}
|
||||||
|
onClick={() => setSelectedColor(color.value)}
|
||||||
|
title={color.label}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Icon Selection */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold text-foreground">
|
||||||
|
{t("hosts.folderIcon")}
|
||||||
|
</Label>
|
||||||
|
<div className="grid grid-cols-5 gap-3">
|
||||||
|
{AVAILABLE_ICONS.map(({ value, label, Icon }) => (
|
||||||
|
<button
|
||||||
|
key={value}
|
||||||
|
type="button"
|
||||||
|
className={`h-14 rounded-md border-2 transition-all hover:scale-105 flex items-center justify-center ${
|
||||||
|
selectedIcon === value
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-dark-border bg-dark-bg-darker"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedIcon(value)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<Icon className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-base font-semibold text-foreground">
|
||||||
|
{t("hosts.preview")}
|
||||||
|
</Label>
|
||||||
|
<div className="flex items-center gap-3 p-4 rounded-md bg-dark-bg-darker border border-dark-border">
|
||||||
|
{(() => {
|
||||||
|
const IconComponent = AVAILABLE_ICONS.find(
|
||||||
|
(i) => i.value === selectedIcon
|
||||||
|
)?.Icon || Folder;
|
||||||
|
return (
|
||||||
|
<IconComponent
|
||||||
|
className="w-5 h-5"
|
||||||
|
style={{ color: selectedColor }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
<span className="font-medium">{folderName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={loading}>
|
||||||
|
{loading ? t("common.saving") : t("common.save")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import axios, { AxiosError, type AxiosInstance } from "axios";
|
|||||||
import type {
|
import type {
|
||||||
SSHHost,
|
SSHHost,
|
||||||
SSHHostData,
|
SSHHostData,
|
||||||
|
SSHFolder,
|
||||||
TunnelConfig,
|
TunnelConfig,
|
||||||
TunnelStatus,
|
TunnelStatus,
|
||||||
FileManagerFile,
|
FileManagerFile,
|
||||||
@@ -2451,6 +2452,90 @@ export async function renameFolder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getSSHFolders(): Promise<SSHFolder[]> {
|
||||||
|
try {
|
||||||
|
sshLogger.info("Fetching SSH folders", {
|
||||||
|
operation: "fetch_ssh_folders",
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await authApi.get("/ssh/folders");
|
||||||
|
|
||||||
|
sshLogger.success("SSH folders fetched successfully", {
|
||||||
|
operation: "fetch_ssh_folders",
|
||||||
|
count: response.data.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to fetch SSH folders", error, {
|
||||||
|
operation: "fetch_ssh_folders",
|
||||||
|
});
|
||||||
|
handleApiError(error, "fetch SSH folders");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateFolderMetadata(
|
||||||
|
name: string,
|
||||||
|
color?: string,
|
||||||
|
icon?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
sshLogger.info("Updating folder metadata", {
|
||||||
|
operation: "update_folder_metadata",
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
await authApi.put("/ssh/folders/metadata", {
|
||||||
|
name,
|
||||||
|
color,
|
||||||
|
icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
sshLogger.success("Folder metadata updated successfully", {
|
||||||
|
operation: "update_folder_metadata",
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to update folder metadata", error, {
|
||||||
|
operation: "update_folder_metadata",
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
handleApiError(error, "update folder metadata");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllHostsInFolder(
|
||||||
|
folderName: string,
|
||||||
|
): Promise<{ deletedCount: number }> {
|
||||||
|
try {
|
||||||
|
sshLogger.info("Deleting all hosts in folder", {
|
||||||
|
operation: "delete_folder_hosts",
|
||||||
|
folderName,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await authApi.delete(`/ssh/folders/${encodeURIComponent(folderName)}/hosts`);
|
||||||
|
|
||||||
|
sshLogger.success("All hosts in folder deleted successfully", {
|
||||||
|
operation: "delete_folder_hosts",
|
||||||
|
folderName,
|
||||||
|
deletedCount: response.data.deletedCount,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
sshLogger.error("Failed to delete hosts in folder", error, {
|
||||||
|
operation: "delete_folder_hosts",
|
||||||
|
folderName,
|
||||||
|
});
|
||||||
|
handleApiError(error, "delete hosts in folder");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function renameCredentialFolder(
|
export async function renameCredentialFolder(
|
||||||
oldName: string,
|
oldName: string,
|
||||||
newName: string,
|
newName: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user