Credential manager + bug fixes #191
@@ -1,2 +1,3 @@
|
|||||||
VERSION=1.6.0
|
VERSION=1.6.0
|
||||||
VITE_API_HOST=localhost
|
VITE_API_HOST=localhost
|
||||||
|
CREDENTIAL_ENCRYPTION_KEY=98fbfabe84b125db7cbbb5168eb584aaecc2f3779a2aaa955c57bdd305071a84
|
||||||
|
|||||||
@@ -431,7 +431,14 @@
|
|||||||
"terminal": "Terminal",
|
"terminal": "Terminal",
|
||||||
"tunnel": "Tunnel",
|
"tunnel": "Tunnel",
|
||||||
"fileManager": "File Manager",
|
"fileManager": "File Manager",
|
||||||
"hostViewer": "Host Viewer"
|
"hostViewer": "Host Viewer",
|
||||||
|
"confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The host will be moved to \"No Folder\".",
|
||||||
|
"removedFromFolder": "Host \"{{name}}\" removed from folder successfully",
|
||||||
|
"failedToRemoveFromFolder": "Failed to remove host from folder",
|
||||||
|
"folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully",
|
||||||
|
"failedToRenameFolder": "Failed to rename folder",
|
||||||
|
"movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully",
|
||||||
|
"failedToMoveToFolder": "Failed to move host to folder"
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "Terminal",
|
"title": "Terminal",
|
||||||
|
|||||||
@@ -469,7 +469,14 @@
|
|||||||
"general": "常规",
|
"general": "常规",
|
||||||
"terminal": "终端",
|
"terminal": "终端",
|
||||||
"tunnel": "隧道",
|
"tunnel": "隧道",
|
||||||
"fileManager": "文件管理器"
|
"fileManager": "文件管理器",
|
||||||
|
"confirmRemoveFromFolder": "确定要将\"{{name}}\"从文件夹\"{{folder}}\"中移除吗?主机将被移动到\"无文件夹\"。",
|
||||||
|
"removedFromFolder": "主机\"{{name}}\"已成功从文件夹中移除",
|
||||||
|
"failedToRemoveFromFolder": "从文件夹中移除主机失败",
|
||||||
|
"folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"",
|
||||||
|
"failedToRenameFolder": "重命名文件夹失败",
|
||||||
|
"movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"",
|
||||||
|
"failedToMoveToFolder": "移动主机到文件夹失败"
|
||||||
},
|
},
|
||||||
"terminal": {
|
"terminal": {
|
||||||
"title": "终端",
|
"title": "终端",
|
||||||
|
|||||||
@@ -402,6 +402,112 @@ router.get('/db/folders', authenticateJWT, async (req: Request, res: Response) =
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Route: Get all folders with usage statistics for the authenticated user (requires JWT)
|
||||||
|
// GET /ssh/folders/with-stats
|
||||||
|
router.get('/db/folders/with-stats', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
if (!isNonEmptyString(userId)) {
|
||||||
|
logger.warn('Invalid userId for SSH folder stats fetch');
|
||||||
|
return res.status(400).json({error: 'Invalid userId'});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const data = await db
|
||||||
|
.select({
|
||||||
|
folder: sshData.folder,
|
||||||
|
hostId: sshData.id,
|
||||||
|
hostName: sshData.name,
|
||||||
|
hostIp: sshData.ip
|
||||||
|
})
|
||||||
|
.from(sshData)
|
||||||
|
.where(eq(sshData.userId, userId));
|
||||||
|
|
||||||
|
const folderStats: Record<string, {
|
||||||
|
name: string;
|
||||||
|
hostCount: number;
|
||||||
|
hosts: Array<{id: number; name?: string; ip: string}>;
|
||||||
|
}> = {};
|
||||||
|
|
||||||
|
data.forEach(d => {
|
||||||
|
if (d.folder && d.folder.trim() !== '') {
|
||||||
|
if (!folderStats[d.folder]) {
|
||||||
|
folderStats[d.folder] = {
|
||||||
|
name: d.folder,
|
||||||
|
hostCount: 0,
|
||||||
|
hosts: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
folderStats[d.folder].hostCount++;
|
||||||
|
folderStats[d.folder].hosts.push({
|
||||||
|
id: d.hostId,
|
||||||
|
name: d.hostName || undefined,
|
||||||
|
ip: d.hostIp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = Object.values(folderStats).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to fetch SSH folder statistics', err);
|
||||||
|
res.status(500).json({error: 'Failed to fetch SSH folder statistics'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Route: Rename folder across all hosts for the authenticated user (requires JWT)
|
||||||
|
// PUT /ssh/folders/rename
|
||||||
|
router.put('/db/folders/rename', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
const userId = (req as any).userId;
|
||||||
|
const {oldName, newName} = req.body;
|
||||||
|
|
||||||
|
if (!isNonEmptyString(userId) || !isNonEmptyString(oldName) || !isNonEmptyString(newName)) {
|
||||||
|
logger.warn('Invalid parameters for folder rename');
|
||||||
|
return res.status(400).json({error: 'userId, oldName, and newName are required'});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldName === newName) {
|
||||||
|
logger.warn('Attempt to rename folder to the same name');
|
||||||
|
return res.status(400).json({error: 'New folder name must be different from old name'});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if the old folder exists
|
||||||
|
const existingHosts = await db
|
||||||
|
.select({id: sshData.id})
|
||||||
|
.from(sshData)
|
||||||
|
.where(and(
|
||||||
|
eq(sshData.userId, userId),
|
||||||
|
eq(sshData.folder, oldName)
|
||||||
|
));
|
||||||
|
|
||||||
|
if (existingHosts.length === 0) {
|
||||||
|
logger.warn(`Attempt to rename non-existent folder: ${oldName}`);
|
||||||
|
return res.status(404).json({error: 'Folder not found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all hosts using this folder name
|
||||||
|
const result = await db
|
||||||
|
.update(sshData)
|
||||||
|
.set({folder: newName})
|
||||||
|
.where(and(
|
||||||
|
eq(sshData.userId, userId),
|
||||||
|
eq(sshData.folder, oldName)
|
||||||
|
));
|
||||||
|
|
||||||
|
logger.success(`Renamed folder "${oldName}" to "${newName}" for ${existingHosts.length} hosts`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: `Folder renamed successfully`,
|
||||||
|
oldName,
|
||||||
|
newName,
|
||||||
|
affectedHostsCount: existingHosts.length
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('Failed to rename SSH folder', err);
|
||||||
|
res.status(500).json({error: 'Failed to rename SSH folder'});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Route: Delete SSH host by id (requires JWT)
|
// Route: Delete SSH host by id (requires JWT)
|
||||||
// DELETE /ssh/host/:id
|
// DELETE /ssh/host/:id
|
||||||
router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
router.delete('/db/host/:id', authenticateJWT, async (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
Edit,
|
||||||
|
Search,
|
||||||
|
Trash2,
|
||||||
|
Users
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getFoldersWithStats, renameFolder } from '@/ui/main-axios';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
interface FolderStats {
|
||||||
|
name: string;
|
||||||
|
hostCount: number;
|
||||||
|
hosts: Array<{
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
ip: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderManagerProps {
|
||||||
|
onFolderChanged?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FolderManager({ onFolderChanged }: FolderManagerProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [folders, setFolders] = useState<FolderStats[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
// Rename state
|
||||||
|
const [renameLoading, setRenameLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchFolders();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchFolders = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getFoldersWithStats();
|
||||||
|
setFolders(data || []);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to fetch folder statistics');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRename = async (folder: FolderStats) => {
|
||||||
|
const newName = prompt(
|
||||||
|
`Enter new name for folder "${folder.name}":\n\nThis will update ${folder.hostCount} host(s) that use this folder.`,
|
||||||
|
folder.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!newName || newName.trim() === '' || newName === folder.name) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.confirm(
|
||||||
|
`Are you sure you want to rename folder "${folder.name}" to "${newName.trim()}"?\n\n` +
|
||||||
|
`This will update ${folder.hostCount} host(s) that currently use this folder.`
|
||||||
|
)) {
|
||||||
|
try {
|
||||||
|
setRenameLoading(true);
|
||||||
|
await renameFolder(folder.name, newName.trim());
|
||||||
|
toast.success(`Folder renamed from "${folder.name}" to "${newName.trim()}"`, {
|
||||||
|
description: `Updated ${folder.hostCount} host(s)`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh folder list
|
||||||
|
await fetchFolders();
|
||||||
|
|
||||||
|
// Notify parent component about folder change
|
||||||
|
if (onFolderChanged) {
|
||||||
|
onFolderChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event for other components to refresh
|
||||||
|
window.dispatchEvent(new CustomEvent('folders:changed'));
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
toast.error('Failed to rename folder');
|
||||||
|
} finally {
|
||||||
|
setRenameLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredFolders = useMemo(() => {
|
||||||
|
if (!searchQuery.trim()) {
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return folders.filter(folder =>
|
||||||
|
folder.name.toLowerCase().includes(query) ||
|
||||||
|
folder.hosts.some(host =>
|
||||||
|
(host.name?.toLowerCase().includes(query)) ||
|
||||||
|
host.ip.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [folders, searchQuery]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||||
|
<p className="text-muted-foreground">Loading folders...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-500 mb-4">{error}</p>
|
||||||
|
<Button onClick={fetchFolders} variant="outline">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folders.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<Folder className="h-12 w-12 text-muted-foreground mx-auto mb-4"/>
|
||||||
|
<h3 className="text-lg font-semibold mb-2">No Folders Found</h3>
|
||||||
|
<p className="text-muted-foreground mb-4">
|
||||||
|
Create some hosts with folders to manage them here
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full min-h-0">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Folder Management</h2>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
{filteredFolders.length} folder(s) found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button onClick={fetchFolders} variant="outline" size="sm">
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative mb-3">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground"/>
|
||||||
|
<Input
|
||||||
|
placeholder="Search folders..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
|
<div className="space-y-3 pb-20">
|
||||||
|
{filteredFolders.map((folder) => (
|
||||||
|
<div
|
||||||
|
key={folder.name}
|
||||||
|
className="bg-[#222225] border border-input rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Folder className="h-5 w-5 text-blue-500" />
|
||||||
|
<h3 className="font-medium text-lg truncate">
|
||||||
|
{folder.name}
|
||||||
|
</h3>
|
||||||
|
<Badge variant="secondary" className="ml-auto">
|
||||||
|
<Users className="h-3 w-3 mr-1" />
|
||||||
|
{folder.hostCount} host(s)
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-shrink-0 ml-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleRename(folder)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
title="Rename folder"
|
||||||
|
disabled={renameLoading}
|
||||||
|
>
|
||||||
|
{renameLoading ? (
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||||
|
) : (
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
|
Hosts using this folder:
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-1 max-h-32 overflow-y-auto">
|
||||||
|
{folder.hosts.slice(0, 5).map((host) => (
|
||||||
|
<div key={host.id} className="flex items-center gap-2 text-sm bg-muted/20 rounded px-2 py-1">
|
||||||
|
<span className="font-medium">
|
||||||
|
{host.name || host.ip}
|
||||||
|
</span>
|
||||||
|
{host.name && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
({host.ip})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{folder.hosts.length > 5 && (
|
||||||
|
<div className="text-sm text-muted-foreground px-2 py-1">
|
||||||
|
... and {folder.hosts.length - 5} more host(s)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ export function HostManager({onSelectView, isTopbarOpen}: HostManagerProps): Rea
|
|||||||
setActiveTab("credentials");
|
setActiveTab("credentials");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const handleTabChange = (value: string) => {
|
const handleTabChange = (value: string) => {
|
||||||
setActiveTab(value);
|
setActiveTab(value);
|
||||||
if (value === "host_viewer") {
|
if (value === "host_viewer") {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {useState, useEffect, useMemo} from "react";
|
import React, {useState, useEffect, useMemo, useRef} from "react";
|
||||||
import {Card, CardContent} from "@/components/ui/card.tsx";
|
import {Card, CardContent} from "@/components/ui/card.tsx";
|
||||||
import {Button} from "@/components/ui/button.tsx";
|
import {Button} from "@/components/ui/button.tsx";
|
||||||
import {Badge} from "@/components/ui/badge.tsx";
|
import {Badge} from "@/components/ui/badge.tsx";
|
||||||
@@ -6,7 +6,7 @@ import {ScrollArea} from "@/components/ui/scroll-area.tsx";
|
|||||||
import {Input} from "@/components/ui/input.tsx";
|
import {Input} from "@/components/ui/input.tsx";
|
||||||
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
|
import {Accordion, AccordionContent, AccordionItem, AccordionTrigger} from "@/components/ui/accordion.tsx";
|
||||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx";
|
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip.tsx";
|
||||||
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts} from "@/ui/main-axios.ts";
|
import {getSSHHosts, deleteSSHHost, bulkImportSSHHosts, updateSSHHost, renameFolder} from "@/ui/main-axios.ts";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {useTranslation} from "react-i18next";
|
import {useTranslation} from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -21,7 +21,10 @@ import {
|
|||||||
FileEdit,
|
FileEdit,
|
||||||
Search,
|
Search,
|
||||||
Upload,
|
Upload,
|
||||||
Info
|
Info,
|
||||||
|
X,
|
||||||
|
Check,
|
||||||
|
Pencil
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {Separator} from "@/components/ui/separator.tsx";
|
import {Separator} from "@/components/ui/separator.tsx";
|
||||||
|
|
||||||
@@ -55,9 +58,30 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [draggedHost, setDraggedHost] = useState<SSHHost | null>(null);
|
||||||
|
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||||
|
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||||
|
const [editingFolderName, setEditingFolderName] = useState("");
|
||||||
|
const [operationLoading, setOperationLoading] = useState(false);
|
||||||
|
const dragCounter = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchHosts();
|
fetchHosts();
|
||||||
|
|
||||||
|
// Listen for refresh events from other components
|
||||||
|
const handleHostsRefresh = () => {
|
||||||
|
fetchHosts();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('hosts:refresh', handleHostsRefresh);
|
||||||
|
window.addEventListener('ssh-hosts:changed', handleHostsRefresh);
|
||||||
|
window.addEventListener('folders:changed', handleHostsRefresh);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('hosts:refresh', handleHostsRefresh);
|
||||||
|
window.removeEventListener('ssh-hosts:changed', handleHostsRefresh);
|
||||||
|
window.removeEventListener('folders:changed', handleHostsRefresh);
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchHosts = async () => {
|
const fetchHosts = async () => {
|
||||||
@@ -92,6 +116,118 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRemoveFromFolder = async (host: SSHHost) => {
|
||||||
|
if (window.confirm(t('hosts.confirmRemoveFromFolder', { name: host.name || `${host.username}@${host.ip}`, folder: host.folder }))) {
|
||||||
|
try {
|
||||||
|
setOperationLoading(true);
|
||||||
|
const updatedHost = { ...host, folder: '' };
|
||||||
|
await updateSSHHost(host.id, updatedHost);
|
||||||
|
toast.success(t('hosts.removedFromFolder', { name: host.name || `${host.username}@${host.ip}` }));
|
||||||
|
await fetchHosts();
|
||||||
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('hosts.failedToRemoveFromFolder'));
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFolderRename = async (oldName: string) => {
|
||||||
|
if (!editingFolderName.trim() || editingFolderName === oldName) {
|
||||||
|
setEditingFolder(null);
|
||||||
|
setEditingFolderName('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setOperationLoading(true);
|
||||||
|
await renameFolder(oldName, editingFolderName.trim());
|
||||||
|
toast.success(t('hosts.folderRenamed', { oldName, newName: editingFolderName.trim() }));
|
||||||
|
await fetchHosts();
|
||||||
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
|
setEditingFolder(null);
|
||||||
|
setEditingFolderName('');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('hosts.failedToRenameFolder'));
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startFolderEdit = (folderName: string) => {
|
||||||
|
setEditingFolder(folderName);
|
||||||
|
setEditingFolderName(folderName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelFolderEdit = () => {
|
||||||
|
setEditingFolder(null);
|
||||||
|
setEditingFolderName('');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drag and drop handlers
|
||||||
|
const handleDragStart = (e: React.DragEvent, host: SSHHost) => {
|
||||||
|
setDraggedHost(host);
|
||||||
|
e.dataTransfer.effectAllowed = 'move';
|
||||||
|
e.dataTransfer.setData('text/plain', ''); // Required for Firefox
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedHost(null);
|
||||||
|
setDragOverFolder(null);
|
||||||
|
dragCounter.current = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.dataTransfer.dropEffect = 'move';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnter = (e: React.DragEvent, folderName: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter.current++;
|
||||||
|
setDragOverFolder(folderName);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragLeave = (e: React.DragEvent) => {
|
||||||
|
dragCounter.current--;
|
||||||
|
if (dragCounter.current === 0) {
|
||||||
|
setDragOverFolder(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (e: React.DragEvent, targetFolder: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dragCounter.current = 0;
|
||||||
|
setDragOverFolder(null);
|
||||||
|
|
||||||
|
if (!draggedHost) return;
|
||||||
|
|
||||||
|
const newFolder = targetFolder === t('hosts.uncategorized') ? '' : targetFolder;
|
||||||
|
|
||||||
|
if (draggedHost.folder === newFolder) {
|
||||||
|
setDraggedHost(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setOperationLoading(true);
|
||||||
|
const updatedHost = { ...draggedHost, folder: newFolder };
|
||||||
|
await updateSSHHost(draggedHost.id, updatedHost);
|
||||||
|
toast.success(t('hosts.movedToFolder', {
|
||||||
|
name: draggedHost.name || `${draggedHost.username}@${draggedHost.ip}`,
|
||||||
|
folder: targetFolder
|
||||||
|
}));
|
||||||
|
await fetchHosts();
|
||||||
|
window.dispatchEvent(new CustomEvent('ssh-hosts:changed'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('hosts.failedToMoveToFolder'));
|
||||||
|
} finally {
|
||||||
|
setOperationLoading(false);
|
||||||
|
setDraggedHost(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleJsonImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleJsonImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -495,14 +631,90 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
<div className="space-y-2 pb-20">
|
<div className="space-y-2 pb-20">
|
||||||
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
{Object.entries(hostsByFolder).map(([folder, folderHosts]) => (
|
||||||
<div key={folder} className="border rounded-md">
|
<div
|
||||||
|
key={folder}
|
||||||
|
className={`border rounded-md transition-all duration-200 ${
|
||||||
|
dragOverFolder === folder ? 'border-blue-500 bg-blue-500/10' : ''
|
||||||
|
}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnter={(e) => handleDragEnter(e, folder)}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={(e) => handleDrop(e, folder)}
|
||||||
|
>
|
||||||
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
|
<Accordion type="multiple" defaultValue={Object.keys(hostsByFolder)}>
|
||||||
<AccordionItem value={folder} className="border-none">
|
<AccordionItem value={folder} className="border-none">
|
||||||
<AccordionTrigger
|
<AccordionTrigger
|
||||||
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 flex-1">
|
||||||
<Folder className="h-4 w-4"/>
|
<Folder className="h-4 w-4"/>
|
||||||
<span className="font-medium">{folder}</span>
|
{editingFolder === folder ? (
|
||||||
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Input
|
||||||
|
value={editingFolderName}
|
||||||
|
onChange={(e) => setEditingFolderName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleFolderRename(folder);
|
||||||
|
if (e.key === 'Escape') cancelFolderEdit();
|
||||||
|
}}
|
||||||
|
className="h-6 text-sm px-2 flex-1"
|
||||||
|
autoFocus
|
||||||
|
disabled={operationLoading}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleFolderRename(folder);
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
disabled={operationLoading}
|
||||||
|
>
|
||||||
|
<Check className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
cancelFolderEdit();
|
||||||
|
}}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
disabled={operationLoading}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (folder !== t('hosts.uncategorized')) {
|
||||||
|
startFolderEdit(folder);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={folder !== t('hosts.uncategorized') ? 'Click to rename folder' : ''}
|
||||||
|
>
|
||||||
|
{folder}
|
||||||
|
</span>
|
||||||
|
{folder !== t('hosts.uncategorized') && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
startFolderEdit(folder);
|
||||||
|
}}
|
||||||
|
className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity"
|
||||||
|
title="Rename folder"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Badge variant="secondary" className="text-xs">
|
<Badge variant="secondary" className="text-xs">
|
||||||
{folderHosts.length}
|
{folderHosts.length}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -513,7 +725,12 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
{folderHosts.map((host) => (
|
{folderHosts.map((host) => (
|
||||||
<div
|
<div
|
||||||
key={host.id}
|
key={host.id}
|
||||||
className="bg-[#222225] border border-input rounded cursor-pointer hover:shadow-md transition-shadow p-2"
|
draggable
|
||||||
|
onDragStart={(e) => handleDragStart(e, host)}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
className={`bg-[#222225] border border-input rounded cursor-move hover:shadow-md transition-all p-2 ${
|
||||||
|
draggedHost?.id === host.id ? 'opacity-50 scale-95' : ''
|
||||||
|
}`}
|
||||||
onClick={() => handleEdit(host)}
|
onClick={() => handleEdit(host)}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
@@ -533,6 +750,21 @@ export function HostManagerHostViewer({onEditHost}: SSHManagerHostViewerProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||||
|
{host.folder && host.folder !== '' && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleRemoveFromFolder(host);
|
||||||
|
}}
|
||||||
|
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700"
|
||||||
|
title={`Remove from folder "${host.folder}"`}
|
||||||
|
disabled={operationLoading}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3"/>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -1125,4 +1125,29 @@ export async function applyCredentialToHost(credentialId: number, hostId: number
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, 'apply credential to host');
|
handleApiError(error, 'apply credential to host');
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SSH FOLDER MANAGEMENT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export async function getFoldersWithStats(): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.get('/ssh/db/folders/with-stats');
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'fetch folders with statistics');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renameFolder(oldName: string, newName: string): Promise<any> {
|
||||||
|
try {
|
||||||
|
const response = await authApi.put('/ssh/db/folders/rename', {
|
||||||
|
oldName,
|
||||||
|
newName
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
handleApiError(error, 'rename folder');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user