fix: Uncapitalize folder titles and finalize file cleanup

This commit is contained in:
LukeGus
2025-10-31 21:07:12 -05:00
parent e375878576
commit a9709f3877
88 changed files with 79 additions and 821 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,226 @@
import React, { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
import { getCredentials } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import type { Credential } from "../../../../types";
interface CredentialSelectorProps {
value?: number | null;
onValueChange: (credentialId: number | null) => void;
onCredentialSelect?: (credential: Credential | null) => void;
}
export function CredentialSelector({
value,
onValueChange,
onCredentialSelect,
}: CredentialSelectorProps) {
const { t } = useTranslation();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(true);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const fetchCredentials = async () => {
try {
setLoading(true);
const data = await getCredentials();
const credentialsArray = Array.isArray(data)
? data
: data.credentials || data.data || [];
setCredentials(credentialsArray);
} catch {
const { toast } = await import("sonner");
toast.error(t("credentials.failedToFetchCredentials"));
setCredentials([]);
} finally {
setLoading(false);
}
};
fetchCredentials();
}, []);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [dropdownOpen]);
const selectedCredential = credentials.find((c) => c.id === value);
const filteredCredentials = credentials.filter((credential) => {
if (!searchQuery) return true;
const searchLower = searchQuery.toLowerCase();
return (
credential.name.toLowerCase().includes(searchLower) ||
credential.username.toLowerCase().includes(searchLower) ||
(credential.folder &&
credential.folder.toLowerCase().includes(searchLower))
);
});
const handleCredentialSelect = (credential: Credential) => {
onValueChange(credential.id);
if (onCredentialSelect) {
onCredentialSelect(credential);
}
setDropdownOpen(false);
setSearchQuery("");
};
const handleClear = () => {
onValueChange(null);
if (onCredentialSelect) {
onCredentialSelect(null);
}
setDropdownOpen(false);
setSearchQuery("");
};
return (
<FormItem>
<FormLabel>{t("hosts.selectCredential")}</FormLabel>
<FormControl>
<div className="relative">
<Button
ref={buttonRef}
type="button"
variant="outline"
className="w-full justify-between text-left rounded-lg px-3 py-2 bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring border border-border text-foreground transition-all duration-200"
onClick={() => setDropdownOpen(!dropdownOpen)}
>
{loading ? (
t("common.loading")
) : value === "existing_credential" ? (
<div className="flex items-center justify-between w-full">
<div>
<span className="font-medium">
{t("hosts.existingCredential")}
</span>
</div>
</div>
) : selectedCredential ? (
<div className="flex items-center justify-between w-full">
<div>
<span className="font-medium">{selectedCredential.name}</span>
<span className="text-sm text-muted-foreground ml-2">
({selectedCredential.username} {" "}
{selectedCredential.authType})
</span>
</div>
</div>
) : (
t("hosts.selectCredentialPlaceholder")
)}
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{dropdownOpen && (
<div
ref={dropdownRef}
className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-80 overflow-hidden backdrop-blur-sm"
>
<div className="p-2 border-b border-border">
<Input
placeholder={t("credentials.searchCredentials")}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
<div className="max-h-60 overflow-y-auto p-2">
{loading ? (
<div className="p-3 text-center text-sm text-muted-foreground">
{t("common.loading")}
</div>
) : filteredCredentials.length === 0 ? (
<div className="p-3 text-center text-sm text-muted-foreground">
{searchQuery
? t("credentials.noCredentialsMatchFilters")
: t("credentials.noCredentialsYet")}
</div>
) : (
<div className="grid grid-cols-1 gap-2.5">
{value && (
<Button
type="button"
variant="ghost"
size="sm"
className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
onClick={handleClear}
>
{t("common.clear")}
</Button>
)}
{filteredCredentials.map((credential) => (
<Button
key={credential.id}
type="button"
variant="ghost"
size="sm"
className={`w-full justify-start text-left rounded-lg px-3 py-7 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
credential.id === value ? "bg-muted" : ""
}`}
onClick={() => handleCredentialSelect(credential)}
>
<div className="w-full">
<div className="flex items-center justify-between">
<span className="font-medium">
{credential.name}
</span>
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{credential.username} {credential.authType}
{credential.description &&
`${credential.description}`}
</div>
</div>
</Button>
))}
</div>
)}
</div>
</div>
)}
</div>
</FormControl>
</FormItem>
);
}

View File

@@ -0,0 +1,531 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import {
Key,
User,
Calendar,
Hash,
Folder,
Edit3,
Copy,
Shield,
Clock,
Server,
Eye,
EyeOff,
AlertTriangle,
CheckCircle,
FileText,
} from "lucide-react";
import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import type {
Credential,
HostInfo,
CredentialViewerProps,
} from "../../../types/index.js";
const CredentialViewer: React.FC<CredentialViewerProps> = ({
credential,
onClose,
onEdit,
}) => {
const { t } = useTranslation();
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(
null,
);
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
const [loading, setLoading] = useState(true);
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>(
{},
);
const [activeTab, setActiveTab] = useState<"overview" | "security" | "usage">(
"overview",
);
useEffect(() => {
fetchCredentialDetails();
fetchHostsUsing();
}, [credential.id]);
const fetchCredentialDetails = async () => {
try {
const response = await getCredentialDetails(credential.id);
setCredentialDetails(response);
} catch {
toast.error(t("credentials.failedToFetchCredentialDetails"));
}
};
const fetchHostsUsing = async () => {
try {
const response = await getCredentialHosts(credential.id);
setHostsUsing(response);
} catch {
toast.error(t("credentials.failedToFetchHostsUsing"));
} finally {
setLoading(false);
}
};
const toggleSensitiveVisibility = (field: string) => {
setShowSensitive((prev) => ({
...prev,
[field]: !prev[field],
}));
};
const copyToClipboard = async (text: string, fieldName: string) => {
try {
await navigator.clipboard.writeText(text);
toast.success(t("copiedToClipboard", { field: fieldName }));
} catch {
toast.error(t("credentials.failedToCopy"));
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
const getAuthIcon = (authType: string) => {
return authType === "password" ? (
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
) : (
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
);
};
const renderSensitiveField = (
value: string | undefined,
fieldName: string,
label: string,
isMultiline = false,
) => {
if (!value) return null;
const isVisible = showSensitive[fieldName];
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
{label}
</label>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => toggleSensitiveVisibility(fieldName)}
>
{isVisible ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => copyToClipboard(value, label)}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div
className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? "" : "min-h-[2.5rem]"}`}
>
{isVisible ? (
<pre
className={`text-sm ${isMultiline ? "whitespace-pre-wrap" : "whitespace-nowrap"} font-mono`}
>
{value}
</pre>
) : (
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{"•".repeat(isMultiline ? 50 : 20)}
</div>
)}
</div>
</div>
);
};
if (loading || !credentialDetails) {
return (
<Sheet open={true} onOpenChange={onClose}>
<SheetContent className="w-[600px] max-w-[50vw]">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
</div>
</SheetContent>
</Sheet>
);
}
return (
<Sheet open={true} onOpenChange={onClose}>
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
<SheetHeader className="space-y-6 pb-8">
<SheetTitle className="flex items-center space-x-4">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
{getAuthIcon(credentialDetails.authType)}
</div>
<div className="flex-1">
<div className="text-xl font-semibold">
{credentialDetails.name}
</div>
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
{credentialDetails.description}
</div>
</div>
<div className="flex items-center space-x-2">
<Badge
variant="outline"
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400"
>
{credentialDetails.authType}
</Badge>
{credentialDetails.keyType && (
<Badge
variant="secondary"
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300"
>
{credentialDetails.keyType}
</Badge>
)}
</div>
</SheetTitle>
</SheetHeader>
<div className="space-y-10">
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
<Button
variant={activeTab === "overview" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("overview")}
className="flex-1 h-10"
>
<FileText className="h-4 w-4 mr-2" />
{t("credentials.overview")}
</Button>
<Button
variant={activeTab === "security" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("security")}
className="flex-1 h-10"
>
<Shield className="h-4 w-4 mr-2" />
{t("credentials.security")}
</Button>
<Button
variant={activeTab === "usage" ? "default" : "ghost"}
size="sm"
onClick={() => setActiveTab("usage")}
className="flex-1 h-10"
>
<Server className="h-4 w-4 mr-2" />
{t("credentials.usage")}
</Button>
</div>
{activeTab === "overview" && (
<div className="grid gap-10 lg:grid-cols-2">
<Card className="border-zinc-200 dark:border-zinc-700">
<CardHeader className="pb-8">
<CardTitle className="text-lg font-semibold">
{t("credentials.basicInformation")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
<div className="flex items-center space-x-5">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("common.username")}
</div>
<div className="font-medium text-zinc-800 dark:text-zinc-200">
{credentialDetails.username}
</div>
</div>
</div>
{credentialDetails.folder && (
<div className="flex items-center space-x-4">
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("common.folder")}
</div>
<div className="font-medium">
{credentialDetails.folder}
</div>
</div>
</div>
)}
{credentialDetails.tags.length > 0 && (
<div className="flex items-start space-x-4">
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" />
<div className="flex-1">
<div className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">
{t("hosts.tags")}
</div>
<div className="flex flex-wrap gap-2">
{credentialDetails.tags.map((tag, index) => (
<Badge
key={index}
variant="outline"
className="text-xs"
>
{tag}
</Badge>
))}
</div>
</div>
</div>
)}
<Separator />
<div className="flex items-center space-x-4">
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("credentials.created")}
</div>
<div className="font-medium">
{formatDate(credentialDetails.createdAt)}
</div>
</div>
</div>
<div className="flex items-center space-x-4">
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("credentials.lastModified")}
</div>
<div className="font-medium">
{formatDate(credentialDetails.updatedAt)}
</div>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t("credentials.usageStatistics")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
{credentialDetails.usageCount}
</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400">
{t("credentials.timesUsed")}
</div>
</div>
{credentialDetails.lastUsed && (
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("credentials.lastUsed")}
</div>
<div className="font-medium">
{formatDate(credentialDetails.lastUsed)}
</div>
</div>
</div>
)}
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{t("credentials.connectedHosts")}
</div>
<div className="font-medium">{hostsUsing.length}</div>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{activeTab === "security" && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center space-x-2">
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<span>{t("credentials.securityDetails")}</span>
</CardTitle>
<CardDescription>
{t("credentials.securityDetailsDescription")}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
<div>
<div className="font-medium text-zinc-800 dark:text-zinc-200">
{t("credentials.credentialSecured")}
</div>
<div className="text-sm text-zinc-700 dark:text-zinc-300">
{t("credentials.credentialSecuredDescription")}
</div>
</div>
</div>
{credentialDetails.authType === "password" && (
<div>
<h3 className="font-semibold mb-4">
{t("credentials.passwordAuthentication")}
</h3>
{renderSensitiveField(
credentialDetails.password,
"password",
t("common.password"),
)}
</div>
)}
{credentialDetails.authType === "key" && (
<div className="space-y-6">
<h3 className="font-semibold mb-2">
{t("credentials.keyAuthentication")}
</h3>
<div className="grid gap-6 md:grid-cols-2">
<div>
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
{t("credentials.keyType")}
</div>
<Badge variant="outline" className="text-sm">
{credentialDetails.keyType?.toUpperCase() ||
t("unknown").toUpperCase()}
</Badge>
</div>
</div>
{renderSensitiveField(
credentialDetails.key,
"key",
t("credentials.privateKey"),
true,
)}
{credentialDetails.keyPassword &&
renderSensitiveField(
credentialDetails.keyPassword,
"keyPassword",
t("credentials.keyPassphrase"),
)}
</div>
)}
<div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" />
<div className="text-sm">
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
{t("credentials.securityReminder")}
</div>
<div className="text-zinc-700 dark:text-zinc-300">
{t("credentials.securityReminderText")}
</div>
</div>
</div>
</CardContent>
</Card>
)}
{activeTab === "usage" && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center space-x-2">
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
<span>{t("credentials.hostsUsingCredential")}</span>
<Badge variant="secondary">{hostsUsing.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{hostsUsing.length === 0 ? (
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" />
<p>{t("credentials.noHostsUsingCredential")}</p>
</div>
) : (
<ScrollArea className="h-64">
<div className="space-y-3">
{hostsUsing.map((host) => (
<div
key={host.id}
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
>
<div className="flex items-center space-x-3">
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
<Server className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
</div>
<div>
<div className="font-medium">
{host.name || `${host.ip}:${host.port}`}
</div>
<div className="text-sm text-zinc-500 dark:text-zinc-400">
{host.ip}:{host.port}
</div>
</div>
</div>
<div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
{formatDate(host.createdAt)}
</div>
</div>
))}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
)}
</div>
<SheetFooter>
<Button variant="outline" onClick={onClose}>
{t("common.close")}
</Button>
<Button onClick={onEdit}>
<Edit3 className="h-4 w-4 mr-2" />
{t("credentials.editCredential")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
};
export default CredentialViewer;

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,694 @@
import React, { useEffect, useState } from "react";
import { Auth } from "@/ui/desktop/authentication/Auth.tsx";
import { UpdateLog } from "@/ui/desktop/apps/dashboard/apps/UpdateLog.tsx";
import { AlertManager } from "@/ui/desktop/apps/dashboard/apps/alerts/AlertManager.tsx";
import { Button } from "@/components/ui/button.tsx";
import {
getUserInfo,
getDatabaseHealth,
getCookie,
getUptime,
getVersionInfo,
getSSHHosts,
getTunnelStatuses,
getCredentials,
getRecentActivity,
resetRecentActivity,
getServerMetricsById,
type RecentActivityItem,
} from "@/ui/main-axios.ts";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import {
ChartLine,
Clock,
Database,
FastForward,
History,
Key,
Network,
Server,
UserPlus,
Settings,
User,
Loader2,
Terminal,
FolderOpen,
} from "lucide-react";
import { Status } from "@/components/ui/shadcn-io/status";
import { BsLightning } from "react-icons/bs";
import { useTranslation } from "react-i18next";
interface DashboardProps {
onSelectView: (view: string) => void;
isAuthenticated: boolean;
authLoading: boolean;
onAuthSuccess: (authData: {
isAdmin: boolean;
username: string | null;
userId: string | null;
}) => void;
isTopbarOpen: boolean;
}
export function Dashboard({
isAuthenticated,
authLoading,
onAuthSuccess,
isTopbarOpen,
onSelectView,
}: DashboardProps): React.ReactElement {
const { t } = useTranslation();
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
const [isAdmin, setIsAdmin] = useState(false);
const [, setUsername] = useState<string | null>(null);
const [userId, setUserId] = useState<string | null>(null);
const [dbError, setDbError] = useState<string | null>(null);
const [uptime, setUptime] = useState<string>("0d 0h 0m");
const [versionStatus, setVersionStatus] = useState<
"up_to_date" | "requires_update"
>("up_to_date");
const [versionText, setVersionText] = useState<string>("v1.8.0");
const [dbHealth, setDbHealth] = useState<"healthy" | "error">("healthy");
const [totalServers, setTotalServers] = useState<number>(0);
const [totalTunnels, setTotalTunnels] = useState<number>(0);
const [totalCredentials, setTotalCredentials] = useState<number>(0);
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
[],
);
const [recentActivityLoading, setRecentActivityLoading] =
useState<boolean>(true);
const [serverStats, setServerStats] = useState<
Array<{ id: number; name: string; cpu: number | null; ram: number | null }>
>([]);
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
const { addTab, setCurrentTab, tabs: tabList } = useTabs();
let sidebarState: "expanded" | "collapsed" = "expanded";
try {
const sidebar = useSidebar();
sidebarState = sidebar.state;
} catch {}
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8;
useEffect(() => {
setLoggedIn(isAuthenticated);
}, [isAuthenticated]);
useEffect(() => {
if (isAuthenticated) {
if (getCookie("jwt")) {
getUserInfo()
.then((meRes) => {
setIsAdmin(!!meRes.is_admin);
setUsername(meRes.username || null);
setUserId(meRes.userId || null);
setDbError(null);
})
.catch((err) => {
setIsAdmin(false);
setUsername(null);
setUserId(null);
const errorCode = err?.response?.data?.code;
if (errorCode === "SESSION_EXPIRED") {
console.warn("Session expired - please log in again");
setDbError("Session expired - please log in again");
} else {
setDbError(null);
}
});
getDatabaseHealth()
.then(() => {
setDbError(null);
})
.catch((err) => {
if (err?.response?.data?.error?.includes("Database")) {
setDbError(
"Could not connect to the database. Please try again later.",
);
}
});
}
}
}, [isAuthenticated]);
useEffect(() => {
if (!loggedIn) return;
const fetchDashboardData = async () => {
try {
const uptimeInfo = await getUptime();
setUptime(uptimeInfo.formatted);
const versionInfo = await getVersionInfo();
setVersionText(`v${versionInfo.localVersion}`);
setVersionStatus(versionInfo.status || "up_to_date");
try {
await getDatabaseHealth();
setDbHealth("healthy");
} catch {
setDbHealth("error");
}
const hosts = await getSSHHosts();
setTotalServers(hosts.length);
let totalTunnelsCount = 0;
for (const host of hosts) {
if (host.tunnelConnections) {
try {
const tunnelConnections = Array.isArray(host.tunnelConnections)
? host.tunnelConnections
: JSON.parse(host.tunnelConnections);
if (Array.isArray(tunnelConnections)) {
totalTunnelsCount += tunnelConnections.length;
}
} catch {}
}
}
setTotalTunnels(totalTunnelsCount);
const credentials = await getCredentials();
setTotalCredentials(credentials.length);
setRecentActivityLoading(true);
const activity = await getRecentActivity(35);
setRecentActivity(activity);
setRecentActivityLoading(false);
setServerStatsLoading(true);
const serversWithStats = await Promise.all(
hosts.slice(0, 5).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,
};
}
}),
);
setServerStats(serversWithStats);
setServerStatsLoading(false);
} catch (error) {
console.error("Failed to fetch dashboard data:", error);
setRecentActivityLoading(false);
setServerStatsLoading(false);
}
};
fetchDashboardData();
const interval = setInterval(fetchDashboardData, 30000);
return () => clearInterval(interval);
}, [loggedIn]);
const handleResetActivity = async () => {
try {
await resetRecentActivity();
setRecentActivity([]);
} catch (error) {
console.error("Failed to reset activity:", error);
}
};
const handleActivityClick = (item: RecentActivityItem) => {
getSSHHosts().then((hosts) => {
const host = hosts.find((h: { id: number }) => h.id === item.hostId);
if (!host) return;
if (item.type === "terminal") {
addTab({
type: "terminal",
title: item.hostName,
hostConfig: host,
});
} else if (item.type === "file_manager") {
addTab({
type: "file_manager",
title: item.hostName,
hostConfig: host,
});
}
});
};
const handleAddHost = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) {
setCurrentTab(sshManagerTab.id);
} else {
const id = addTab({
type: "ssh_manager",
title: "Host Manager",
initialTab: "add_host",
});
setCurrentTab(id);
}
};
const handleAddCredential = () => {
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
if (sshManagerTab) {
setCurrentTab(sshManagerTab.id);
} else {
const id = addTab({
type: "ssh_manager",
title: "Host Manager",
initialTab: "add_credential",
});
setCurrentTab(id);
}
};
const handleOpenAdminSettings = () => {
const adminTab = tabList.find((t) => t.type === "admin");
if (adminTab) {
setCurrentTab(adminTab.id);
} else {
const id = addTab({ type: "admin", title: "Admin Settings" });
setCurrentTab(id);
}
};
const handleOpenUserProfile = () => {
const userProfileTab = tabList.find((t) => t.type === "user_profile");
if (userProfileTab) {
setCurrentTab(userProfileTab.id);
} else {
const id = addTab({ type: "user_profile", title: "User Profile" });
setCurrentTab(id);
}
};
return (
<>
{!loggedIn ? (
<div className="w-full h-full flex items-center justify-center">
<Auth
setLoggedIn={setLoggedIn}
setIsAdmin={setIsAdmin}
setUsername={setUsername}
setUserId={setUserId}
loggedIn={loggedIn}
authLoading={authLoading}
dbError={dbError}
setDbError={setDbError}
onAuthSuccess={onAuthSuccess}
/>
</div>
) : (
<div
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex"
style={{
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
}}
>
<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">
{t("dashboard.title")}
</div>
<div className="flex flex-row gap-3">
<Button
className="font-semibold"
variant="outline"
onClick={() =>
window.open(
"https://github.com/Termix-SSH/Termix",
"_blank",
)
}
>
{t("dashboard.github")}
</Button>
<Button
className="font-semibold"
variant="outline"
onClick={() =>
window.open(
"https://github.com/Termix-SSH/Support/issues/new",
"_blank",
)
}
>
{t("dashboard.support")}
</Button>
<Button
className="font-semibold"
variant="outline"
onClick={() =>
window.open(
"https://discord.com/invite/jVQGdvHDrf",
"_blank",
)
}
>
{t("dashboard.discord")}
</Button>
<Button
className="font-semibold"
variant="outline"
onClick={() =>
window.open("https://github.com/sponsors/LukeGus", "_blank")
}
>
{t("dashboard.donate")}
</Button>
</div>
</div>
<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 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">
<History
size={20}
color="#FFFFFF"
className="shrink-0"
/>
<p className="ml-2 leading-none">
{t("dashboard.version")}
</p>
</div>
<div className="flex flex-row items-center">
<p className="leading-none text-muted-foreground">
{versionText}
</p>
<Button
variant="outline"
size="sm"
className={`ml-2 text-sm border-1 border-dark-border ${versionStatus === "up_to_date" ? "text-green-400" : "text-yellow-400"}`}
>
{versionStatus === "up_to_date"
? t("dashboard.upToDate")
: t("dashboard.updateAvailable")}
</Button>
<UpdateLog loggedIn={loggedIn} />
</div>
</div>
<div className="flex flex-row items-center justify-between mb-5">
<div className="flex flex-row items-center">
<Clock
size={20}
color="#FFFFFF"
className="shrink-0"
/>
<p className="ml-2 leading-none">
{t("dashboard.uptime")}
</p>
</div>
<div className="flex flex-row items-center">
<p className="leading-none text-muted-foreground">
{uptime}
</p>
</div>
</div>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center">
<Database
size={20}
color="#FFFFFF"
className="shrink-0"
/>
<p className="ml-2 leading-none">
{t("dashboard.database")}
</p>
</div>
<div className="flex flex-row items-center">
<p
className={`leading-none ${dbHealth === "healthy" ? "text-green-400" : "text-red-400"}`}
>
{dbHealth === "healthy"
? t("dashboard.healthy")
: t("dashboard.error")}
</p>
</div>
</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">
<Server
size={16}
color="#FFFFFF"
className="mr-3 shrink-0"
/>
<p className="m-0 leading-none">
{t("dashboard.totalServers")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
{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">
<Network
size={16}
color="#FFFFFF"
className="mr-3 shrink-0"
/>
<p className="m-0 leading-none">
{t("dashboard.totalTunnels")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
{totalTunnels}
</p>
</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">
<Key
size={16}
color="#FFFFFF"
className="mr-3 shrink-0"
/>
<p className="m-0 leading-none">
{t("dashboard.totalCredentials")}
</p>
</div>
<p className="m-0 leading-none text-muted-foreground font-semibold">
{totalCredentials}
</p>
</div>
</div>
</div>
</div>
<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 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">
<Clock className="mr-3" />
{t("dashboard.recentActivity")}
</p>
<Button
variant="outline"
size="sm"
className="border-2 !border-dark-border h-7"
onClick={handleResetActivity}
>
{t("dashboard.reset")}
</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"}`}
>
{recentActivityLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm">
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingRecentActivity")}</span>
</div>
) : recentActivity.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noRecentActivity")}
</p>
) : (
recentActivity.map((item) => (
<Button
key={item.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg"
onClick={() => handleActivityClick(item)}
>
{item.type === "terminal" ? (
<Terminal size={20} className="shrink-0" />
) : (
<FolderOpen size={20} className="shrink-0" />
)}
<p className="truncate ml-2 font-semibold">
{item.hostName}
</p>
</Button>
))
)}
</div>
</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">
<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">
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
onClick={handleAddHost}
>
<Server
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span className="font-semibold text-sm mt-2">
{t("dashboard.addHost")}
</span>
</Button>
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
onClick={handleAddCredential}
>
<Key
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span className="font-semibold text-sm mt-2">
{t("dashboard.addCredential")}
</span>
</Button>
{isAdmin && (
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
onClick={handleOpenAdminSettings}
>
<Settings
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span className="font-semibold text-sm mt-2">
{t("dashboard.adminSettings")}
</span>
</Button>
)}
<Button
variant="outline"
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
onClick={handleOpenUserProfile}
>
<User
className="shrink-0"
style={{ width: "40px", height: "40px" }}
/>
<span className="font-semibold text-sm mt-2">
{t("dashboard.userProfile")}
</span>
</Button>
</div>
</div>
</div>
<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 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"}`}
>
{serverStatsLoading ? (
<div className="flex flex-row items-center text-muted-foreground text-sm">
<Loader2 className="animate-spin mr-2" size={16} />
<span>{t("dashboard.loadingServerStats")}</span>
</div>
) : serverStats.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("dashboard.noServerData")}
</p>
) : (
serverStats.map((server) => (
<Button
key={server.id}
variant="outline"
className="border-2 !border-dark-border bg-dark-bg h-auto p-3"
>
<div className="flex flex-col w-full">
<div className="flex flex-row items-center mb-2">
<Server size={20} className="shrink-0" />
<p className="truncate ml-2 font-semibold">
{server.name}
</p>
</div>
<div className="flex flex-row justify-between text-xs text-muted-foreground">
<span>
{t("dashboard.cpu")}:{" "}
{server.cpu !== null
? `${server.cpu}%`
: t("dashboard.notAvailable")}
</span>
<span>
{t("dashboard.ram")}:{" "}
{server.ram !== null
? `${server.ram}%`
: t("dashboard.notAvailable")}
</span>
</div>
</div>
</Button>
))
)}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)}
<AlertManager userId={userId} loggedIn={loggedIn} />
</>
);
}

View File

@@ -0,0 +1,215 @@
import React, { useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
} from "@/components/ui/sheet.tsx";
import { getReleasesRSS, getVersionInfo } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import { BookOpen, X } from "lucide-react";
interface UpdateLogProps extends React.ComponentProps<"div"> {
loggedIn: boolean;
}
interface ReleaseItem {
id: number;
title: string;
description: string;
link: string;
pubDate: string;
version: string;
isPrerelease: boolean;
isDraft: boolean;
assets: Array<{
name: string;
size: number;
download_count: number;
download_url: string;
}>;
}
interface RSSResponse {
feed: {
title: string;
description: string;
link: string;
updated: string;
};
items: ReleaseItem[];
total_count: number;
cached: boolean;
cache_age?: number;
}
interface VersionResponse {
status: "up_to_date" | "requires_update";
version: string;
latest_release: {
name: string;
published_at: string;
html_url: string;
};
cached: boolean;
cache_age?: number;
}
export function UpdateLog({ loggedIn }: UpdateLogProps) {
const { t } = useTranslation();
const [releases, setReleases] = useState<RSSResponse | null>(null);
const [versionInfo, setVersionInfo] = useState<VersionResponse | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (loggedIn && isOpen) {
setLoading(true);
Promise.all([getReleasesRSS(100), getVersionInfo()])
.then(([releasesRes, versionRes]) => {
setReleases(releasesRes);
setVersionInfo(versionRes);
setError(null);
})
.catch(() => {
setError(t("common.failedToFetchUpdateInfo"));
})
.finally(() => setLoading(false));
}
}, [loggedIn, isOpen]);
if (!loggedIn) {
return null;
}
const formatDescription = (description: string) => {
const firstLine = description.split("\n")[0];
return firstLine.replace(/[#*`]/g, "").replace(/\s+/g, " ").trim();
};
return (
<>
<Button
variant="outline"
size="sm"
className="ml-2 text-sm border-1 border-dark-border text-muted-foreground"
onClick={() => setIsOpen(true)}
>
{t("common.updatesAndReleases")}
</Button>
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetContent
side="right"
className="w-[500px] bg-dark-bg border-l-2 border-dark-border text-white sm:max-w-[500px] p-0 flex flex-col [&>button]:hidden"
>
<div className="flex items-center justify-between p-4 border-b border-dark-border">
<h2 className="text-lg font-semibold text-white">
{t("common.updatesAndReleases")}
</h2>
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(false)}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t("common.close")}
>
<X />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{versionInfo && versionInfo.status === "requires_update" && (
<Alert className="bg-dark-bg-darker border-dark-border text-white mb-3">
<AlertTitle className="text-white">
{t("common.updateAvailable")}
</AlertTitle>
<AlertDescription className="text-gray-300">
{t("common.newVersionAvailable", {
version: versionInfo.version,
})}
</AlertDescription>
</Alert>
)}
{loading && (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
)}
{error && (
<Alert
variant="destructive"
className="bg-red-900/20 border-red-500 text-red-300 mb-3"
>
<AlertTitle className="text-red-300">
{t("common.error")}
</AlertTitle>
<AlertDescription className="text-red-300">
{error}
</AlertDescription>
</Alert>
)}
<div className="space-y-3">
{releases?.items.map((release) => (
<div
key={release.id}
className="border border-dark-border rounded-lg p-3 hover:bg-dark-bg-darker transition-colors cursor-pointer bg-dark-bg-darker/50"
onClick={() => window.open(release.link, "_blank")}
>
<div className="flex items-start justify-between mb-2">
<h4 className="font-semibold text-sm leading-tight flex-1 text-white">
{release.title}
</h4>
{release.isPrerelease && (
<span className="text-xs bg-yellow-600 text-yellow-100 px-2 py-1 rounded ml-2 flex-shrink-0 font-medium">
{t("common.preRelease")}
</span>
)}
</div>
<p className="text-xs text-gray-300 mb-2 leading-relaxed">
{formatDescription(release.description)}
</p>
<div className="flex items-center text-xs text-gray-400">
<span>
{new Date(release.pubDate).toLocaleDateString()}
</span>
{release.assets.length > 0 && (
<>
<span className="mx-2"></span>
<span>
{release.assets.length} asset
{release.assets.length !== 1 ? "s" : ""}
</span>
</>
)}
</div>
</div>
))}
</div>
{releases && releases.items.length === 0 && !loading && (
<Alert className="bg-dark-bg-darker border-dark-border text-gray-300">
<AlertTitle className="text-gray-300">
{t("common.noReleases")}
</AlertTitle>
<AlertDescription className="text-gray-400">
{t("common.noReleasesFound")}
</AlertDescription>
</Alert>
)}
</div>
</SheetContent>
</Sheet>
</>
);
}

View File

@@ -0,0 +1,157 @@
import React from "react";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Badge } from "@/components/ui/badge.tsx";
import {
X,
ExternalLink,
AlertTriangle,
Info,
CheckCircle,
AlertCircle,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { TermixAlert } from "../../../../../../types";
interface AlertCardProps {
alert: TermixAlert;
onDismiss: (alertId: string) => void;
onClose: () => void;
}
const getAlertIcon = (type?: string) => {
switch (type) {
case "warning":
return <AlertTriangle className="h-5 w-5 text-yellow-500" />;
case "error":
return <AlertCircle className="h-5 w-5 text-red-500" />;
case "success":
return <CheckCircle className="h-5 w-5 text-green-500" />;
case "info":
default:
return <Info className="h-5 w-5 text-blue-500" />;
}
};
const getPriorityBadgeVariant = (priority?: string) => {
switch (priority) {
case "critical":
return "destructive";
case "high":
return "destructive";
case "medium":
return "secondary";
case "low":
default:
return "outline";
}
};
const getTypeBadgeVariant = (type?: string) => {
switch (type) {
case "warning":
return "secondary";
case "error":
return "destructive";
case "success":
return "default";
case "info":
default:
return "outline";
}
};
export function AlertCard({
alert,
onDismiss,
onClose,
}: AlertCardProps): React.ReactElement {
const { t } = useTranslation();
if (!alert) {
return null;
}
const handleDismiss = () => {
onDismiss(alert.id);
onClose();
};
const formatExpiryDate = (expiryString: string) => {
const expiryDate = new Date(expiryString);
const now = new Date();
const diffTime = expiryDate.getTime() - now.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays < 0) return t("common.expired");
if (diffDays === 0) return t("common.expiresToday");
if (diffDays === 1) return t("common.expiresTomorrow");
return t("common.expiresInDays", { days: diffDays });
};
return (
<Card className="w-full max-w-2xl mx-auto">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{getAlertIcon(alert.type)}
<CardTitle className="text-xl font-bold">{alert.title}</CardTitle>
</div>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2 mt-2">
{alert.priority && (
<Badge variant={getPriorityBadgeVariant(alert.priority)}>
{alert.priority.toUpperCase()}
</Badge>
)}
{alert.type && (
<Badge variant={getTypeBadgeVariant(alert.type)}>
{alert.type}
</Badge>
)}
<span className="text-sm text-muted-foreground">
{formatExpiryDate(alert.expiresAt)}
</span>
</div>
</CardHeader>
<CardContent className="pb-4">
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">
{alert.message}
</p>
</CardContent>
<CardFooter className="flex items-center justify-between pt-0">
<div className="flex gap-2">
<Button variant="outline" onClick={handleDismiss}>
Dismiss
</Button>
{alert.actionUrl && alert.actionText && (
<Button
variant="default"
onClick={() =>
window.open(alert.actionUrl, "_blank", "noopener,noreferrer")
}
className="gap-2"
>
{alert.actionText}
<ExternalLink className="h-4 w-4" />
</Button>
)}
</div>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,174 @@
import React, { useEffect, useState } from "react";
import { AlertCard } from "./AlertCard.tsx";
import { Button } from "@/components/ui/button.tsx";
import { getUserAlerts, dismissAlert } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next";
import type { TermixAlert } from "../../../../../../types";
interface AlertManagerProps {
userId: string | null;
loggedIn: boolean;
}
export function AlertManager({
userId,
loggedIn,
}: AlertManagerProps): React.ReactElement {
const { t } = useTranslation();
const [alerts, setAlerts] = useState<TermixAlert[]>([]);
const [currentAlertIndex, setCurrentAlertIndex] = useState(0);
const [, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (loggedIn && userId) {
fetchUserAlerts();
}
}, [loggedIn, userId]);
const fetchUserAlerts = async () => {
setLoading(true);
setError(null);
try {
const response = await getUserAlerts();
const userAlerts = response.alerts || [];
const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => {
const priorityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
const aPriority =
priorityOrder[a.priority as keyof typeof priorityOrder] || 0;
const bPriority =
priorityOrder[b.priority as keyof typeof priorityOrder] || 0;
if (aPriority !== bPriority) {
return bPriority - aPriority;
}
return (
new Date(a.expiresAt).getTime() - new Date(b.expiresAt).getTime()
);
});
setAlerts(sortedAlerts);
setCurrentAlertIndex(0);
} catch {
const { toast } = await import("sonner");
toast.error(t("homepage.failedToLoadAlerts"));
setError(t("homepage.failedToLoadAlerts"));
} finally {
setLoading(false);
}
};
const handleDismissAlert = async (alertId: string) => {
try {
await dismissAlert(alertId);
setAlerts((prev) => {
const newAlerts = prev.filter((alert) => alert.id !== alertId);
return newAlerts;
});
setCurrentAlertIndex((prevIndex) => {
const newAlertsLength = alerts.length - 1;
if (newAlertsLength === 0) return 0;
if (prevIndex >= newAlertsLength)
return Math.max(0, newAlertsLength - 1);
return prevIndex;
});
} catch {
setError(t("homepage.failedToDismissAlert"));
}
};
const handleCloseCurrentAlert = () => {
if (alerts.length === 0) return;
if (currentAlertIndex < alerts.length - 1) {
setCurrentAlertIndex(currentAlertIndex + 1);
} else {
setAlerts([]);
setCurrentAlertIndex(0);
}
};
const handlePreviousAlert = () => {
if (currentAlertIndex > 0) {
setCurrentAlertIndex(currentAlertIndex - 1);
}
};
const handleNextAlert = () => {
if (currentAlertIndex < alerts.length - 1) {
setCurrentAlertIndex(currentAlertIndex + 1);
}
};
if (!loggedIn || !userId) {
return null;
}
if (alerts.length === 0) {
return null;
}
const currentAlert = alerts[currentAlertIndex];
if (!currentAlert) {
return null;
}
const priorityCounts = { critical: 0, high: 0, medium: 0, low: 0 };
alerts.forEach((alert) => {
const priority = alert.priority || "low";
priorityCounts[priority as keyof typeof priorityCounts]++;
});
const hasMultipleAlerts = alerts.length > 1;
return (
<div className="fixed inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm z-[99999]">
<div className="relative w-full max-w-2xl mx-4">
<AlertCard
alert={currentAlert}
onDismiss={handleDismissAlert}
onClose={handleCloseCurrentAlert}
/>
{hasMultipleAlerts && (
<div className="absolute -bottom-16 left-1/2 transform -translate-x-1/2 flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePreviousAlert}
disabled={currentAlertIndex === 0}
className="h-8 px-3"
>
Previous
</Button>
<span className="text-sm text-muted-foreground">
{currentAlertIndex + 1} of {alerts.length}
</span>
<Button
variant="outline"
size="sm"
onClick={handleNextAlert}
disabled={currentAlertIndex === alerts.length - 1}
className="h-8 px-3"
>
Next
</Button>
</div>
)}
{error && (
<div className="absolute -bottom-20 left-1/2 transform -translate-x-1/2">
<div className="bg-destructive text-destructive-foreground px-3 py-1 rounded text-sm">
{error}
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,143 @@
import React from "react";
import { cn } from "@/lib/utils.ts";
import { useTranslation } from "react-i18next";
import {
Download,
FileDown,
FolderDown,
Loader2,
CheckCircle,
AlertCircle,
} from "lucide-react";
interface DragIndicatorProps {
isVisible: boolean;
isDragging: boolean;
isDownloading: boolean;
progress: number;
fileName?: string;
fileCount?: number;
error?: string | null;
className?: string;
}
export function DragIndicator({
isVisible,
isDragging,
isDownloading,
progress,
fileName,
fileCount = 1,
error,
className,
}: DragIndicatorProps) {
const { t } = useTranslation();
if (!isVisible) return null;
const getIcon = () => {
if (error) {
return <AlertCircle className="w-6 h-6 text-red-500" />;
}
if (isDragging) {
return <CheckCircle className="w-6 h-6 text-green-500" />;
}
if (isDownloading) {
return <Loader2 className="w-6 h-6 text-blue-500 animate-spin" />;
}
if (fileCount > 1) {
return <FolderDown className="w-6 h-6 text-blue-500" />;
}
return <FileDown className="w-6 h-6 text-blue-500" />;
};
const getStatusText = () => {
if (error) {
return t("dragIndicator.error", { error });
}
if (isDragging) {
return t("dragIndicator.dragging", { fileName: fileName || "" });
}
if (isDownloading) {
return t("dragIndicator.preparing", { fileName: fileName || "" });
}
if (fileCount > 1) {
return t("dragIndicator.readyMultiple", { count: fileCount });
}
return t("dragIndicator.readySingle", { fileName: fileName || "" });
};
return (
<div
className={cn(
"fixed top-4 right-4 z-50 min-w-[300px] max-w-[400px]",
"bg-dark-bg border border-dark-border rounded-lg shadow-lg",
"p-4 transition-all duration-300 ease-in-out",
isVisible ? "opacity-100 translate-x-0" : "opacity-0 translate-x-full",
className,
)}
>
<div className="flex items-start gap-3">
<div className="flex-shrink-0 mt-0.5">{getIcon()}</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-foreground mb-2">
{fileCount > 1
? t("dragIndicator.batchDrag")
: t("dragIndicator.dragToDesktop")}
</div>
<div
className={cn(
"text-xs mb-3",
error
? "text-red-500"
: isDragging
? "text-green-500"
: "text-muted-foreground",
)}
>
{getStatusText()}
</div>
{(isDownloading || isDragging) && !error && (
<div className="w-full bg-dark-border rounded-full h-2 mb-2">
<div
className={cn(
"h-2 rounded-full transition-all duration-300",
isDragging ? "bg-green-500" : "bg-blue-500",
)}
style={{ width: `${Math.max(5, progress)}%` }}
/>
</div>
)}
{(isDownloading || isDragging) && !error && (
<div className="text-xs text-muted-foreground">
{progress.toFixed(0)}%
</div>
)}
{isDragging && !error && (
<div className="text-xs text-green-500 mt-2 flex items-center gap-1">
<Download className="w-3 h-3" />
{t("dragIndicator.canDragAnywhere")}
</div>
)}
</div>
</div>
{isDragging && !error && (
<div className="absolute inset-0 rounded-lg bg-green-500/5 animate-pulse" />
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,483 @@
import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import {
Download,
Edit3,
Copy,
Scissors,
Trash2,
Info,
Upload,
FolderPlus,
FilePlus,
RefreshCw,
Clipboard,
Eye,
Terminal,
Play,
Star,
Bookmark,
} from "lucide-react";
import { useTranslation } from "react-i18next";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
executable?: boolean;
}
interface ContextMenuProps {
x: number;
y: number;
files: FileItem[];
isVisible: boolean;
onClose: () => void;
onDownload?: (files: FileItem[]) => void;
onRename?: (file: FileItem) => void;
onCopy?: (files: FileItem[]) => void;
onCut?: (files: FileItem[]) => void;
onDelete?: (files: FileItem[]) => void;
onProperties?: (file: FileItem) => void;
onUpload?: () => void;
onNewFolder?: () => void;
onNewFile?: () => void;
onRefresh?: () => void;
onPaste?: () => void;
onPreview?: (file: FileItem) => void;
hasClipboard?: boolean;
onDragToDesktop?: () => void;
onOpenTerminal?: (path: string) => void;
onRunExecutable?: (file: FileItem) => void;
onPinFile?: (file: FileItem) => void;
onUnpinFile?: (file: FileItem) => void;
onAddShortcut?: (path: string) => void;
isPinned?: (file: FileItem) => boolean;
currentPath?: string;
}
interface MenuItem {
icon: React.ReactNode;
label: string;
action: () => void;
shortcut?: string;
separator?: boolean;
disabled?: boolean;
danger?: boolean;
}
export function FileManagerContextMenu({
x,
y,
files,
isVisible,
onClose,
onDownload,
onRename,
onCopy,
onCut,
onDelete,
onProperties,
onUpload,
onNewFolder,
onNewFile,
onRefresh,
onPaste,
onPreview,
hasClipboard = false,
onDragToDesktop,
onOpenTerminal,
onRunExecutable,
onPinFile,
onUnpinFile,
onAddShortcut,
isPinned,
currentPath,
}: ContextMenuProps) {
const { t } = useTranslation();
const [menuPosition, setMenuPosition] = useState({ x, y });
useEffect(() => {
if (!isVisible) return;
const adjustPosition = () => {
const menuWidth = 200;
const menuHeight = 300;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let adjustedX = x;
let adjustedY = y;
if (x + menuWidth > viewportWidth) {
adjustedX = viewportWidth - menuWidth - 10;
}
if (y + menuHeight > viewportHeight) {
adjustedY = viewportHeight - menuHeight - 10;
}
setMenuPosition({ x: adjustedX, y: adjustedY });
};
adjustPosition();
let cleanupFn: (() => void) | null = null;
const timeoutId = setTimeout(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector("[data-context-menu]");
if (!menuElement?.contains(target)) {
onClose();
}
};
const handleRightClick = (event: MouseEvent) => {
event.preventDefault();
onClose();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
};
const handleBlur = () => {
onClose();
};
const handleScroll = () => {
onClose();
};
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("contextmenu", handleRightClick);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", handleBlur);
window.addEventListener("scroll", handleScroll, true);
cleanupFn = () => {
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("contextmenu", handleRightClick);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", handleBlur);
window.removeEventListener("scroll", handleScroll, true);
};
}, 50);
return () => {
clearTimeout(timeoutId);
if (cleanupFn) {
cleanupFn();
}
};
}, [isVisible, x, y, onClose]);
if (!isVisible) return null;
const isFileContext = files.length > 0;
const isSingleFile = files.length === 1;
const isMultipleFiles = files.length > 1;
const hasFiles = files.some((f) => f.type === "file");
const hasExecutableFiles = files.some(
(f) => f.type === "file" && f.executable,
);
const menuItems: MenuItem[] = [];
if (isFileContext) {
if (onOpenTerminal) {
const targetPath = isSingleFile
? files[0].type === "directory"
? files[0].path
: files[0].path.substring(0, files[0].path.lastIndexOf("/"))
: files[0].path.substring(0, files[0].path.lastIndexOf("/"));
menuItems.push({
icon: <Terminal className="w-4 h-4" />,
label:
files[0].type === "directory"
? t("fileManager.openTerminalInFolder")
: t("fileManager.openTerminalInFileLocation"),
action: () => onOpenTerminal(targetPath),
shortcut: "Ctrl+Shift+T",
});
}
if (isSingleFile && hasExecutableFiles && onRunExecutable) {
menuItems.push({
icon: <Play className="w-4 h-4" />,
label: t("fileManager.run"),
action: () => onRunExecutable(files[0]),
shortcut: "Enter",
});
}
if (
onOpenTerminal ||
(isSingleFile && hasExecutableFiles && onRunExecutable)
) {
menuItems.push({ separator: true } as MenuItem);
}
if (hasFiles && onPreview) {
menuItems.push({
icon: <Eye className="w-4 h-4" />,
label: t("fileManager.preview"),
action: () => onPreview(files[0]),
disabled: !isSingleFile || files[0].type !== "file",
});
}
if (hasFiles && onDownload) {
menuItems.push({
icon: <Download className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.downloadFiles", { count: files.length })
: t("fileManager.downloadFile"),
action: () => onDownload(files),
shortcut: "Ctrl+D",
});
}
if (isSingleFile && files[0].type === "file") {
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
if (isCurrentlyPinned && onUnpinFile) {
menuItems.push({
icon: <Star className="w-4 h-4 fill-yellow-400" />,
label: t("fileManager.unpinFile"),
action: () => onUnpinFile(files[0]),
});
} else if (!isCurrentlyPinned && onPinFile) {
menuItems.push({
icon: <Star className="w-4 h-4" />,
label: t("fileManager.pinFile"),
action: () => onPinFile(files[0]),
});
}
}
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
menuItems.push({
icon: <Bookmark className="w-4 h-4" />,
label: t("fileManager.addToShortcuts"),
action: () => onAddShortcut(files[0].path),
});
}
if (
(hasFiles && (onPreview || onDragToDesktop)) ||
(isSingleFile &&
files[0].type === "file" &&
(onPinFile || onUnpinFile)) ||
(isSingleFile && files[0].type === "directory" && onAddShortcut)
) {
menuItems.push({ separator: true } as MenuItem);
}
if (isSingleFile && onRename) {
menuItems.push({
icon: <Edit3 className="w-4 h-4" />,
label: t("fileManager.rename"),
action: () => onRename(files[0]),
shortcut: "F6",
});
}
if (onCopy) {
menuItems.push({
icon: <Copy className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.copyFiles", { count: files.length })
: t("fileManager.copy"),
action: () => onCopy(files),
shortcut: "Ctrl+C",
});
}
if (onCut) {
menuItems.push({
icon: <Scissors className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.cutFiles", { count: files.length })
: t("fileManager.cut"),
action: () => onCut(files),
shortcut: "Ctrl+X",
});
}
if ((isSingleFile && onRename) || onCopy || onCut) {
menuItems.push({ separator: true } as MenuItem);
}
if (onDelete) {
menuItems.push({
icon: <Trash2 className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.deleteFiles", { count: files.length })
: t("fileManager.delete"),
action: () => onDelete(files),
shortcut: "Delete",
danger: true,
});
}
if (onDelete) {
menuItems.push({ separator: true } as MenuItem);
}
if (isSingleFile && onProperties) {
menuItems.push({
icon: <Info className="w-4 h-4" />,
label: t("fileManager.properties"),
action: () => onProperties(files[0]),
});
}
} else {
if (onOpenTerminal && currentPath) {
menuItems.push({
icon: <Terminal className="w-4 h-4" />,
label: t("fileManager.openTerminalHere"),
action: () => onOpenTerminal(currentPath),
shortcut: "Ctrl+Shift+T",
});
}
if (onUpload) {
menuItems.push({
icon: <Upload className="w-4 h-4" />,
label: t("fileManager.uploadFile"),
action: onUpload,
shortcut: "Ctrl+U",
});
}
if ((onOpenTerminal && currentPath) || onUpload) {
menuItems.push({ separator: true } as MenuItem);
}
if (onNewFolder) {
menuItems.push({
icon: <FolderPlus className="w-4 h-4" />,
label: t("fileManager.newFolder"),
action: onNewFolder,
shortcut: "Ctrl+Shift+N",
});
}
if (onNewFile) {
menuItems.push({
icon: <FilePlus className="w-4 h-4" />,
label: t("fileManager.newFile"),
action: onNewFile,
shortcut: "Ctrl+N",
});
}
if (onNewFolder || onNewFile) {
menuItems.push({ separator: true } as MenuItem);
}
if (onRefresh) {
menuItems.push({
icon: <RefreshCw className="w-4 h-4" />,
label: t("fileManager.refresh"),
action: onRefresh,
shortcut: "Ctrl+Y",
});
}
if (hasClipboard && onPaste) {
menuItems.push({
icon: <Clipboard className="w-4 h-4" />,
label: t("fileManager.paste"),
action: onPaste,
shortcut: "Ctrl+V",
});
}
}
const filteredMenuItems = menuItems.filter((item, index) => {
if (!item.separator) return true;
const prevItem = index > 0 ? menuItems[index - 1] : null;
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
if (prevItem?.separator || nextItem?.separator) {
return false;
}
return true;
});
const finalMenuItems = filteredMenuItems.filter((item, index) => {
if (!item.separator) return true;
return index > 0 && index < filteredMenuItems.length - 1;
});
return (
<>
<div className="fixed inset-0 z-[99990]" />
<div
data-context-menu
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden"
style={{
left: menuPosition.x,
top: menuPosition.y,
}}
>
{finalMenuItems.map((item, index) => {
if (item.separator) {
return (
<div
key={`separator-${index}`}
className="border-t border-dark-border"
/>
);
}
return (
<button
key={index}
className={cn(
"w-full px-3 py-2 text-left text-sm flex items-center justify-between",
"hover:bg-dark-hover transition-colors",
"first:rounded-t-lg last:rounded-b-lg",
item.disabled && "opacity-50 cursor-not-allowed",
item.danger && "text-red-400 hover:bg-red-500/10",
)}
onClick={() => {
if (!item.disabled) {
item.action();
onClose();
}
}}
disabled={item.disabled}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0">{item.icon}</div>
<span className="flex-1">{item.label}</span>
</div>
{item.shortcut && (
<span className="text-xs text-muted-foreground ml-2 flex-shrink-0">
{item.shortcut}
</span>
)}
</button>
);
})}
</div>
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,578 @@
import React, { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import {
ChevronRight,
ChevronDown,
Folder,
File,
Star,
Clock,
Bookmark,
FolderOpen,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "@/types/index";
import {
getRecentFiles,
getPinnedFiles,
getFolderShortcuts,
listSSHFiles,
removeRecentFile,
removePinnedFile,
removeFolderShortcut,
} from "@/ui/main-axios.ts";
import { toast } from "sonner";
interface RecentFileData {
id: number;
name: string;
path: string;
lastOpened?: string;
[key: string]: unknown;
}
interface PinnedFileData {
id: number;
name: string;
path: string;
[key: string]: unknown;
}
interface ShortcutData {
id: number;
name: string;
path: string;
[key: string]: unknown;
}
interface DirectoryItemData {
name: string;
path: string;
type: string;
[key: string]: unknown;
}
export interface SidebarItem {
id: string;
name: string;
path: string;
type: "recent" | "pinned" | "shortcut" | "folder";
lastAccessed?: string;
isExpanded?: boolean;
children?: SidebarItem[];
}
interface FileManagerSidebarProps {
currentHost: SSHHost;
currentPath: string;
onPathChange: (path: string) => void;
onFileOpen?: (file: SidebarItem) => void;
sshSessionId?: string;
refreshTrigger?: number;
}
export function FileManagerSidebar({
currentHost,
currentPath,
onPathChange,
onFileOpen,
sshSessionId,
refreshTrigger,
}: FileManagerSidebarProps) {
const { t } = useTranslation();
const [recentItems, setRecentItems] = useState<SidebarItem[]>([]);
const [pinnedItems, setPinnedItems] = useState<SidebarItem[]>([]);
const [shortcuts, setShortcuts] = useState<SidebarItem[]>([]);
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(["root"]),
);
const [contextMenu, setContextMenu] = useState<{
x: number;
y: number;
isVisible: boolean;
item: SidebarItem | null;
}>({
x: 0,
y: 0,
isVisible: false,
item: null,
});
useEffect(() => {
loadQuickAccessData();
}, [currentHost, refreshTrigger]);
useEffect(() => {
if (sshSessionId) {
loadDirectoryTree();
}
}, [sshSessionId]);
const loadQuickAccessData = async () => {
if (!currentHost?.id) return;
try {
const recentData = await getRecentFiles(currentHost.id);
const recentItems = (recentData as RecentFileData[])
.slice(0, 5)
.map((item: RecentFileData) => ({
id: `recent-${item.id}`,
name: item.name,
path: item.path,
type: "recent" as const,
lastAccessed: item.lastOpened,
}));
setRecentItems(recentItems);
const pinnedData = await getPinnedFiles(currentHost.id);
const pinnedItems = (pinnedData as PinnedFileData[]).map(
(item: PinnedFileData) => ({
id: `pinned-${item.id}`,
name: item.name,
path: item.path,
type: "pinned" as const,
}),
);
setPinnedItems(pinnedItems);
const shortcutData = await getFolderShortcuts(currentHost.id);
const shortcutItems = (shortcutData as ShortcutData[]).map(
(item: ShortcutData) => ({
id: `shortcut-${item.id}`,
name: item.name,
path: item.path,
type: "shortcut" as const,
}),
);
setShortcuts(shortcutItems);
} catch (error) {
console.error("Failed to load quick access data:", error);
setRecentItems([]);
setPinnedItems([]);
setShortcuts([]);
}
};
const handleRemoveRecentFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeRecentFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(
t("fileManager.removedFromRecentFiles", { name: item.name }),
);
} catch (error) {
console.error("Failed to remove recent file:", error);
toast.error(t("fileManager.removeFailed"));
}
};
const handleUnpinFile = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removePinnedFile(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
} catch (error) {
console.error("Failed to unpin file:", error);
toast.error(t("fileManager.unpinFailed"));
}
};
const handleRemoveShortcut = async (item: SidebarItem) => {
if (!currentHost?.id) return;
try {
await removeFolderShortcut(currentHost.id, item.path);
loadQuickAccessData();
toast.success(t("fileManager.removedShortcut", { name: item.name }));
} catch (error) {
console.error("Failed to remove shortcut:", error);
toast.error(t("fileManager.removeShortcutFailed"));
}
};
const handleClearAllRecent = async () => {
if (!currentHost?.id || recentItems.length === 0) return;
try {
await Promise.all(
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
);
loadQuickAccessData();
toast.success(t("fileManager.clearedAllRecentFiles"));
} catch (error) {
console.error("Failed to clear recent files:", error);
toast.error(t("fileManager.clearFailed"));
}
};
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({
x: e.clientX,
y: e.clientY,
isVisible: true,
item,
});
};
const closeContextMenu = () => {
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
};
useEffect(() => {
if (!contextMenu.isVisible) return;
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector("[data-sidebar-context-menu]");
if (!menuElement?.contains(target)) {
closeContextMenu();
}
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeContextMenu();
}
};
const timeoutId = setTimeout(() => {
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}, 50);
return () => {
clearTimeout(timeoutId);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [contextMenu.isVisible]);
const loadDirectoryTree = async () => {
if (!sshSessionId) return;
try {
const response = await listSSHFiles(sshSessionId, "/");
const rootFiles = (response.files || []) as DirectoryItemData[];
const rootFolders = rootFiles.filter(
(item: DirectoryItemData) => item.type === "directory",
);
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
id: `folder-${folder.name}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: true,
children: rootTreeItems,
},
]);
} catch (error) {
console.error("Failed to load directory tree:", error);
setDirectoryTree([
{
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: false,
children: [],
},
]);
}
};
const handleItemClick = (item: SidebarItem) => {
if (item.type === "folder") {
toggleFolder(item.id, item.path);
onPathChange(item.path);
} else if (item.type === "recent" || item.type === "pinned") {
if (onFileOpen) {
onFileOpen(item);
} else {
const directory =
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
onPathChange(directory);
}
} else if (item.type === "shortcut") {
onPathChange(item.path);
}
};
const toggleFolder = async (folderId: string, folderPath?: string) => {
const newExpanded = new Set(expandedFolders);
if (newExpanded.has(folderId)) {
newExpanded.delete(folderId);
} else {
newExpanded.add(folderId);
if (sshSessionId && folderPath && folderPath !== "/") {
try {
const subResponse = await listSSHFiles(sshSessionId, folderPath);
const subFiles = (subResponse.files || []) as DirectoryItemData[];
const subFolders = subFiles.filter(
(item: DirectoryItemData) => item.type === "directory",
);
const subTreeItems = subFolders.map((folder: DirectoryItemData) => ({
id: `folder-${folder.path.replace(/\//g, "-")}`,
name: folder.name,
path: folder.path,
type: "folder" as const,
isExpanded: false,
children: [],
}));
setDirectoryTree((prevTree) => {
const updateChildren = (items: SidebarItem[]): SidebarItem[] => {
return items.map((item) => {
if (item.id === folderId) {
return { ...item, children: subTreeItems };
} else if (item.children) {
return { ...item, children: updateChildren(item.children) };
}
return item;
});
};
return updateChildren(prevTree);
});
} catch (error) {
console.error("Failed to load subdirectory:", error);
}
}
}
setExpandedFolders(newExpanded);
};
const renderSidebarItem = (item: SidebarItem, level: number = 0) => {
const isExpanded = expandedFolders.has(item.id);
const isActive = currentPath === item.path;
return (
<div key={item.id}>
<div
className={cn(
"flex items-center gap-2 py-1.5 text-sm cursor-pointer hover:bg-dark-hover rounded",
isActive && "bg-primary/20 text-primary",
"text-white",
)}
style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
onClick={() => handleItemClick(item)}
onContextMenu={(e) => {
if (
item.type === "recent" ||
item.type === "pinned" ||
item.type === "shortcut"
) {
handleContextMenu(e, item);
}
}}
>
{item.type === "folder" && (
<button
onClick={(e) => {
e.stopPropagation();
toggleFolder(item.id, item.path);
}}
className="p-0.5 hover:bg-dark-hover rounded"
>
{isExpanded ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
)}
{item.type === "folder" ? (
isExpanded ? (
<FolderOpen className="w-4 h-4" />
) : (
<Folder className="w-4 h-4" />
)
) : (
<File className="w-4 h-4" />
)}
<span className="truncate">{item.name}</span>
</div>
{item.type === "folder" && isExpanded && item.children && (
<div>
{item.children.map((child) => renderSidebarItem(child, level + 1))}
</div>
)}
</div>
);
};
const renderSection = (
title: string,
icon: React.ReactNode,
items: SidebarItem[],
) => {
if (items.length === 0) return null;
return (
<div className="mb-5">
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{icon}
{title}
</div>
<div className="space-y-0.5">
{items.map((item) => renderSidebarItem(item))}
</div>
</div>
);
};
const hasQuickAccessItems =
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
return (
<>
<div className="h-full flex flex-col bg-dark-bg border-r border-dark-border">
<div className="flex-1 relative overflow-hidden">
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
{renderSection(
t("fileManager.recent"),
<Clock className="w-3 h-3" />,
recentItems,
)}
{renderSection(
t("fileManager.pinned"),
<Star className="w-3 h-3" />,
pinnedItems,
)}
{renderSection(
t("fileManager.folderShortcuts"),
<Bookmark className="w-3 h-3" />,
shortcuts,
)}
<div
className={cn(
hasQuickAccessItems && "pt-4 border-t border-dark-border",
)}
>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
<Folder className="w-3 h-3" />
{t("fileManager.directories")}
</div>
<div className="mt-2">
{directoryTree.map((item) => renderSidebarItem(item))}
</div>
</div>
</div>
</div>
</div>
{contextMenu.isVisible && contextMenu.item && (
<>
<div className="fixed inset-0 z-40" />
<div
data-sidebar-context-menu
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[160px] z-50 overflow-hidden"
style={{
left: contextMenu.x,
top: contextMenu.y,
}}
>
{contextMenu.item.type === "recent" && (
<>
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleRemoveRecentFile(contextMenu.item!);
closeContextMenu();
}}
>
<div className="flex-shrink-0">
<Clock className="w-4 h-4" />
</div>
<span className="flex-1">
{t("fileManager.removeFromRecentFiles")}
</span>
</button>
{recentItems.length > 1 && (
<>
<div className="border-t border-dark-border" />
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-red-400 hover:bg-red-500/10 first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleClearAllRecent();
closeContextMenu();
}}
>
<div className="flex-shrink-0">
<Clock className="w-4 h-4" />
</div>
<span className="flex-1">
{t("fileManager.clearAllRecentFiles")}
</span>
</button>
</>
)}
</>
)}
{contextMenu.item.type === "pinned" && (
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleUnpinFile(contextMenu.item!);
closeContextMenu();
}}
>
<div className="flex-shrink-0">
<Star className="w-4 h-4" />
</div>
<span className="flex-1">{t("fileManager.unpinFile")}</span>
</button>
)}
{contextMenu.item.type === "shortcut" && (
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleRemoveShortcut(contextMenu.item!);
closeContextMenu();
}}
>
<div className="flex-shrink-0">
<Bookmark className="w-4 h-4" />
</div>
<span className="flex-1">
{t("fileManager.removeShortcut")}
</span>
</button>
)}
</div>
</>
)}
</>
);
}

View File

@@ -0,0 +1,347 @@
import React, { useState, useEffect } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
Download,
RefreshCw,
Eye,
EyeOff,
ArrowLeftRight,
FileText,
} from "lucide-react";
import {
readSSHFile,
downloadSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import type { FileItem, SSHHost } from "@/types/index";
interface DiffViewerProps {
file1: FileItem;
file2: FileItem;
sshSessionId: string;
sshHost: SSHHost;
onDownload1?: () => void;
onDownload2?: () => void;
}
export function DiffViewer({
file1,
file2,
sshSessionId,
sshHost,
}: DiffViewerProps) {
const { t } = useTranslation();
const [content1, setContent1] = useState<string>("");
const [content2, setContent2] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [diffMode, setDiffMode] = useState<"side-by-side" | "inline">(
"side-by-side",
);
const [showLineNumbers, setShowLineNumbers] = useState(true);
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
} catch {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
};
const loadFileContents = async () => {
if (file1.type !== "file" || file2.type !== "file") {
setError(t("fileManager.canOnlyCompareFiles"));
return;
}
try {
setIsLoading(true);
setError(null);
await ensureSSHConnection();
const [response1, response2] = await Promise.all([
readSSHFile(sshSessionId, file1.path),
readSSHFile(sshSessionId, file2.path),
]);
setContent1(response1.content || "");
setContent2(response2.content || "");
} catch (error: unknown) {
console.error("Failed to load files for diff:", error);
const err = error as {
message?: string;
response?: { data?: { tooLarge?: boolean; error?: string } };
};
const errorData = err?.response?.data;
if (errorData?.tooLarge) {
setError(t("fileManager.fileTooLarge", { error: errorData.error }));
} else if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
setError(
t("fileManager.sshConnectionFailed", {
name: sshHost.name,
ip: sshHost.ip,
port: sshHost.port,
}),
);
} else {
setError(
t("fileManager.loadFileFailed", {
error:
err.message || errorData?.error || t("fileManager.unknownError"),
}),
);
}
} finally {
setIsLoading(false);
}
};
const handleDownloadFile = async (file: FileItem) => {
try {
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
if (response?.content) {
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(
t("fileManager.downloadFileSuccess", { name: file.name }),
);
}
} catch (error: unknown) {
console.error("Failed to download file:", error);
const err = error as { message?: string };
toast.error(
t("fileManager.downloadFileFailed") +
": " +
(err.message || t("fileManager.unknownError")),
);
}
};
const getFileLanguage = (fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = {
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
java: "java",
c: "c",
cpp: "cpp",
cs: "csharp",
php: "php",
rb: "ruby",
go: "go",
rs: "rust",
html: "html",
css: "css",
scss: "scss",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
md: "markdown",
sql: "sql",
sh: "shell",
bash: "shell",
ps1: "powershell",
dockerfile: "dockerfile",
};
return languageMap[ext || ""] || "plaintext";
};
useEffect(() => {
loadFileContents();
}, [file1, file2, sshSessionId]);
if (isLoading) {
return (
<div className="h-full flex items-center justify-center bg-dark-bg">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
{t("fileManager.loadingFileComparison")}
</p>
</div>
</div>
);
}
if (error) {
return (
<div className="h-full flex items-center justify-center bg-dark-bg">
<div className="text-center max-w-md">
<FileText className="w-16 h-16 mx-auto mb-4 text-red-500 opacity-50" />
<p className="text-red-500 mb-4">{error}</p>
<Button onClick={loadFileContents} variant="outline">
<RefreshCw className="w-4 h-4 mr-2" />
{t("fileManager.reload")}
</Button>
</div>
</div>
);
}
return (
<div className="h-full flex flex-col bg-dark-bg">
<div className="flex-shrink-0 border-b border-dark-border p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted-foreground">
{t("fileManager.compare")}:
</span>
<span className="font-medium text-green-400 mx-2">
{file1.name}
</span>
<ArrowLeftRight className="w-4 h-4 inline mx-1" />
<span className="font-medium text-blue-400">{file2.name}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
setDiffMode(
diffMode === "side-by-side" ? "inline" : "side-by-side",
)
}
>
{diffMode === "side-by-side"
? t("fileManager.sideBySide")
: t("fileManager.inline")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowLineNumbers(!showLineNumbers)}
>
{showLineNumbers ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(file1)}
title={t("fileManager.downloadFile", { name: file1.name })}
>
<Download className="w-4 h-4 mr-1" />
{file1.name}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDownloadFile(file2)}
title={t("fileManager.downloadFile", { name: file2.name })}
>
<Download className="w-4 h-4 mr-1" />
{file2.name}
</Button>
<Button variant="outline" size="sm" onClick={loadFileContents}>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
</div>
</div>
<div className="flex-1">
<DiffEditor
original={content1}
modified={content2}
language={getFileLanguage(file1.name)}
theme="vs-dark"
options={{
renderSideBySide: diffMode === "side-by-side",
lineNumbers: showLineNumbers ? "on" : "off",
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
wordWrap: "off",
automaticLayout: true,
readOnly: true,
originalEditable: false,
scrollbar: {
vertical: "visible",
horizontal: "visible",
},
diffWordWrap: "off",
ignoreTrimWhitespace: false,
}}
loading={
<div className="h-full flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-sm text-muted-foreground">
{t("fileManager.initializingEditor")}
</p>
</div>
</div>
}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import React from "react";
import { DraggableWindow } from "./DraggableWindow";
import { DiffViewer } from "./DiffViewer";
import { useWindowManager } from "./WindowManager";
import { useTranslation } from "react-i18next";
import type { FileItem, SSHHost } from "../../../../types/index.js";
interface DiffWindowProps {
windowId: string;
file1: FileItem;
file2: FileItem;
sshSessionId: string;
sshHost: SSHHost;
initialX?: number;
initialY?: number;
}
export function DiffWindow({
windowId,
file1,
file2,
sshSessionId,
sshHost,
initialX = 150,
initialY = 100,
}: DiffWindowProps) {
const { t } = useTranslation();
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const currentWindow = windows.find((w) => w.id === windowId);
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
if (!currentWindow) {
return null;
}
return (
<DraggableWindow
title={t("fileManager.fileComparison", {
file1: file1.name,
file2: file2.name,
})}
initialX={initialX}
initialY={initialY}
initialWidth={1200}
initialHeight={700}
minWidth={800}
minHeight={500}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<DiffViewer
file1={file1}
file2={file2}
sshSessionId={sshSessionId}
sshHost={sshHost}
/>
</DraggableWindow>
);
}

View File

@@ -0,0 +1,387 @@
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
import { useTranslation } from "react-i18next";
interface DraggableWindowProps {
title: string;
children: React.ReactNode;
initialX?: number;
initialY?: number;
initialWidth?: number;
initialHeight?: number;
minWidth?: number;
minHeight?: number;
onClose: () => void;
onMinimize?: () => void;
onMaximize?: () => void;
onResize?: () => void;
isMaximized?: boolean;
zIndex?: number;
onFocus?: () => void;
targetSize?: { width: number; height: number };
}
export function DraggableWindow({
title,
children,
initialX = 100,
initialY = 100,
initialWidth = 600,
initialHeight = 400,
minWidth = 300,
minHeight = 200,
onClose,
onMinimize,
onMaximize,
onResize,
isMaximized = false,
zIndex = 1000,
onFocus,
targetSize,
}: DraggableWindowProps) {
const { t } = useTranslation();
const [position, setPosition] = useState({ x: initialX, y: initialY });
const [size, setSize] = useState({
width: initialWidth,
height: initialHeight,
});
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<string>("");
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
const windowRef = useRef<HTMLDivElement>(null);
const titleBarRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (targetSize && !isMaximized) {
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
const maxHeight = Math.min(window.innerHeight * 0.8, 800);
let newWidth = Math.min(targetSize.width + 50, maxWidth);
let newHeight = Math.min(targetSize.height + 150, maxHeight);
if (newWidth > maxWidth || newHeight > maxHeight) {
const widthRatio = maxWidth / newWidth;
const heightRatio = maxHeight / newHeight;
const scale = Math.min(widthRatio, heightRatio);
newWidth = Math.floor(newWidth * scale);
newHeight = Math.floor(newHeight * scale);
}
newWidth = Math.max(newWidth, minWidth);
newHeight = Math.max(newHeight, minHeight);
setSize({ width: newWidth, height: newHeight });
setPosition({
x: Math.max(0, (window.innerWidth - newWidth) / 2),
y: Math.max(0, (window.innerHeight - newHeight) / 2),
});
}
}, [targetSize, isMaximized, minWidth, minHeight]);
const handleWindowClick = useCallback(() => {
onFocus?.();
}, [onFocus]);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isMaximized) return;
e.preventDefault();
setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
onFocus?.();
},
[isMaximized, position, onFocus],
);
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (isDragging && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
const newX = windowStart.x + deltaX;
const newY = windowStart.y + deltaY;
const windowElement = windowRef.current;
let positioningContainer = null;
let currentElement = windowElement?.parentElement;
while (currentElement && currentElement !== document.body) {
const computedStyle = window.getComputedStyle(currentElement);
const position = computedStyle.position;
const transform = computedStyle.transform;
if (
position === "relative" ||
position === "absolute" ||
position === "fixed" ||
transform !== "none"
) {
positioningContainer = currentElement;
break;
}
currentElement = currentElement.parentElement;
}
let maxX, maxY, minX, minY;
if (positioningContainer) {
const containerRect = positioningContainer.getBoundingClientRect();
maxX = containerRect.width - size.width;
maxY = containerRect.height - size.height;
minX = 0;
minY = 0;
} else {
maxX = window.innerWidth - size.width;
maxY = window.innerHeight - size.height;
minX = 0;
minY = 0;
}
const constrainedX = Math.max(minX, Math.min(maxX, newX));
const constrainedY = Math.max(minY, Math.min(maxY, newY));
setPosition({
x: constrainedX,
y: constrainedY,
});
}
if (isResizing && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
let newWidth = sizeStart.width;
let newHeight = sizeStart.height;
let newX = windowStart.x;
let newY = windowStart.y;
if (resizeDirection.includes("right")) {
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
}
if (resizeDirection.includes("left")) {
const widthChange = -deltaX;
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
if (newWidth > minWidth || widthChange > 0) {
newX = windowStart.x - (newWidth - sizeStart.width);
} else {
newX = windowStart.x - (minWidth - sizeStart.width);
}
}
if (resizeDirection.includes("bottom")) {
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
}
if (resizeDirection.includes("top")) {
const heightChange = -deltaY;
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
if (newHeight > minHeight || heightChange > 0) {
newY = windowStart.y - (newHeight - sizeStart.height);
} else {
newY = windowStart.y - (minHeight - sizeStart.height);
}
}
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
setSize({ width: newWidth, height: newHeight });
setPosition({ x: newX, y: newY });
if (onResize) {
onResize();
}
}
},
[
isDragging,
isResizing,
isMaximized,
dragStart,
windowStart,
sizeStart,
size,
position,
minWidth,
minHeight,
resizeDirection,
onResize,
],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setIsResizing(false);
setResizeDirection("");
}, []);
const handleResizeStart = useCallback(
(e: React.MouseEvent, direction: string) => {
if (isMaximized) return;
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeDirection(direction);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
setSizeStart({ width: size.width, height: size.height });
onFocus?.();
},
[isMaximized, position, size, onFocus],
);
useEffect(() => {
if (isDragging || isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "none";
document.body.style.cursor = isDragging ? "grabbing" : "resizing";
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
};
}
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
const handleTitleDoubleClick = useCallback(() => {
onMaximize?.();
}, [onMaximize]);
return (
<div
ref={windowRef}
className={cn(
"absolute bg-card border border-border rounded-lg shadow-2xl",
"select-none overflow-hidden",
isMaximized ? "inset-0" : "",
)}
style={{
left: isMaximized ? 0 : position.x,
top: isMaximized ? 0 : position.y,
width: isMaximized ? "100%" : size.width,
height: isMaximized ? "100%" : size.height,
zIndex,
}}
onClick={handleWindowClick}
>
<div
ref={titleBarRef}
className={cn(
"flex items-center justify-between px-3 py-2",
"bg-muted/50 text-foreground border-b border-border",
"cursor-grab active:cursor-grabbing",
)}
onMouseDown={handleMouseDown}
onDoubleClick={handleTitleDoubleClick}
>
<div className="flex items-center gap-2 flex-1">
<span className="text-sm font-medium truncate">{title}</span>
</div>
<div className="flex items-center gap-1">
{onMinimize && (
<button
className="w-8 h-6 flex items-center justify-center rounded hover:bg-accent transition-colors"
onClick={(e) => {
e.stopPropagation();
onMinimize();
}}
title={t("common.minimize")}
>
<Minus className="w-4 h-4" />
</button>
)}
{onMaximize && (
<button
className="w-8 h-6 flex items-center justify-center rounded hover:bg-accent transition-colors"
onClick={(e) => {
e.stopPropagation();
onMaximize();
}}
title={isMaximized ? t("common.restore") : t("common.maximize")}
>
{isMaximized ? (
<Minimize2 className="w-4 h-4" />
) : (
<Maximize2 className="w-4 h-4" />
)}
</button>
)}
<button
className="w-8 h-6 flex items-center justify-center rounded hover:bg-destructive hover:text-destructive-foreground transition-colors"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
title={t("common.close")}
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div
className="flex-1 overflow-hidden"
style={{ height: "calc(100% - 40px)" }}
>
{children}
</div>
{!isMaximized && (
<>
<div
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
onMouseDown={(e) => handleResizeStart(e, "top")}
/>
<div
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom")}
/>
<div
className="absolute top-0 bottom-0 left-0 w-1 cursor-w-resize"
onMouseDown={(e) => handleResizeStart(e, "left")}
/>
<div
className="absolute top-0 bottom-0 right-0 w-1 cursor-e-resize"
onMouseDown={(e) => handleResizeStart(e, "right")}
/>
<div
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
onMouseDown={(e) => handleResizeStart(e, "top-left")}
/>
<div
className="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
onMouseDown={(e) => handleResizeStart(e, "top-right")}
/>
<div
className="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom-left")}
/>
<div
className="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
onMouseDown={(e) => handleResizeStart(e, "bottom-right")}
/>
</>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,424 @@
import React, { useState, useEffect, useRef } from "react";
import { DraggableWindow } from "./DraggableWindow";
import { FileViewer } from "./FileViewer";
import { useWindowManager } from "./WindowManager";
import {
downloadSSHFile,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
interface FileWindowProps {
windowId: string;
file: FileItem;
sshSessionId: string;
sshHost: SSHHost;
initialX?: number;
initialY?: number;
onFileNotFound?: (file: FileItem) => void;
}
export function FileWindow({
windowId,
file,
sshSessionId,
sshHost,
initialX = 100,
initialY = 100,
onFileNotFound,
}: FileWindowProps) {
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const { t } = useTranslation();
const [content, setContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>("");
const [mediaDimensions, setMediaDimensions] = useState<
{ width: number; height: number } | undefined
>();
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const currentWindow = windows.find((w) => w.id === windowId);
const ensureSSHConnection = async () => {
try {
const status = await getSSHStatus(sshSessionId);
if (!status.connected) {
await connectSSH(sshSessionId, {
hostId: sshHost.id,
ip: sshHost.ip,
port: sshHost.port,
username: sshHost.username,
password: sshHost.password,
sshKey: sshHost.key,
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId,
});
}
} catch (error) {
console.error("SSH connection check/reconnect failed:", error);
throw error;
}
};
useEffect(() => {
const loadFileContent = async () => {
if (file.type !== "file") return;
try {
setIsLoading(true);
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent(fileContent);
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
const mediaExtensions = [
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"svg",
"webp",
"tiff",
"ico",
"mp3",
"wav",
"ogg",
"aac",
"flac",
"m4a",
"wma",
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"m4v",
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
"exe",
"dll",
"so",
"dylib",
"bin",
"iso",
];
const extension = file.name.split(".").pop()?.toLowerCase();
setIsEditable(!mediaExtensions.includes(extension || ""));
} catch (error: unknown) {
console.error("Failed to load file:", error);
const err = error as {
message?: string;
isFileNotFound?: boolean;
response?: {
status?: number;
data?: {
tooLarge?: boolean;
error?: string;
fileNotFound?: boolean;
};
};
};
const errorData = err?.response?.data;
if (errorData?.tooLarge) {
toast.error(`File too large: ${errorData.error}`, {
duration: 10000,
});
} else if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
const errorMessage =
errorData?.error || err.message || "Unknown error";
const isFileNotFound =
err.isFileNotFound ||
errorData?.fileNotFound ||
err.response?.status === 404 ||
errorMessage.includes("File not found") ||
errorMessage.includes("No such file or directory") ||
errorMessage.includes("cannot access") ||
errorMessage.includes("not found") ||
errorMessage.includes("Resource not found");
if (isFileNotFound && onFileNotFound) {
onFileNotFound(file);
toast.error(
t("fileManager.fileNotFoundAndRemoved", { name: file.name }),
);
closeWindow(windowId);
return;
} else {
toast.error(
t("fileManager.failedToLoadFile", {
error: errorMessage.includes("Server error occurred")
? t("fileManager.serverErrorOccurred")
: errorMessage,
}),
);
}
}
} finally {
setIsLoading(false);
}
};
loadFileContent();
}, [file, sshSessionId, sshHost]);
const handleRevert = async () => {
const loadFileContent = async () => {
if (file.type !== "file") return;
try {
setIsLoading(true);
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent("");
if (!file.size) {
const contentSize = new Blob([fileContent]).size;
file.size = contentSize;
}
} catch (error: unknown) {
console.error("Failed to load file content:", error);
const err = error as { message?: string };
toast.error(
`${t("fileManager.failedToLoadFile")}: ${err.message || t("fileManager.unknownError")}`,
);
} finally {
setIsLoading(false);
}
};
loadFileContent();
};
const handleSave = async (newContent: string) => {
try {
setIsLoading(true);
await ensureSSHConnection();
await writeSSHFile(sshSessionId, file.path, newContent);
setContent(newContent);
setPendingContent("");
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
toast.success(t("fileManager.fileSavedSuccessfully"));
} catch (error: unknown) {
console.error("Failed to save file:", error);
const err = error as { message?: string };
if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(
`${t("fileManager.failedToSaveFile")}: ${err.message || t("fileManager.unknownError")}`,
);
}
} finally {
setIsLoading(false);
}
};
const handleContentChange = (newContent: string) => {
setPendingContent(newContent);
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
autoSaveTimerRef.current = null;
}
if (newContent !== content) {
autoSaveTimerRef.current = setTimeout(async () => {
try {
await handleSave(newContent);
toast.success(t("fileManager.fileAutoSaved"));
} catch (error) {
console.error("Auto-save failed:", error);
toast.error(t("fileManager.autoSaveFailed"));
}
}, 60000);
}
};
useEffect(() => {
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current);
}
};
}, []);
const handleDownload = async () => {
try {
await ensureSSHConnection();
const response = await downloadSSHFile(sshSessionId, file.path);
if (response?.content) {
const byteCharacters = atob(response.content);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success(t("fileManager.fileDownloadedSuccessfully"));
}
} catch (error: unknown) {
console.error("Failed to download file:", error);
const err = error as { message?: string };
if (
err.message?.includes("connection") ||
err.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(
`Failed to download file: ${err.message || "Unknown error"}`,
);
}
}
};
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
const handleMediaDimensionsChange = (dimensions: {
width: number;
height: number;
}) => {
setMediaDimensions(dimensions);
};
if (!currentWindow) {
return null;
}
return (
<DraggableWindow
title={file.name}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={600}
minWidth={400}
minHeight={300}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
targetSize={mediaDimensions}
>
<FileViewer
file={file}
content={pendingContent || content}
savedContent={content}
isLoading={isLoading}
onRevert={handleRevert}
isEditable={isEditable}
onContentChange={handleContentChange}
onSave={(newContent) => handleSave(newContent)}
onDownload={handleDownload}
onMediaDimensionsChange={handleMediaDimensionsChange}
/>
</DraggableWindow>
);
}

View File

@@ -0,0 +1,116 @@
import React from "react";
import { DraggableWindow } from "./DraggableWindow";
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal";
import { useWindowManager } from "./WindowManager";
import { useTranslation } from "react-i18next";
interface SSHHost {
id: number;
name: string;
ip: string;
port: number;
username: string;
password?: string;
key?: string;
keyPassword?: string;
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
interface TerminalWindowProps {
windowId: string;
hostConfig: SSHHost;
initialPath?: string;
initialX?: number;
initialY?: number;
executeCommand?: string;
}
export function TerminalWindow({
windowId,
hostConfig,
initialPath,
initialX = 200,
initialY = 150,
executeCommand,
}: TerminalWindowProps) {
const { t } = useTranslation();
const { closeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const terminalRef = React.useRef<{ fit?: () => void } | null>(null);
const resizeTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
React.useEffect(() => {
return () => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
};
}, []);
const currentWindow = windows.find((w) => w.id === windowId);
if (!currentWindow) {
return null;
}
const handleClose = () => {
closeWindow(windowId);
};
const handleMaximize = () => {
maximizeWindow(windowId);
};
const handleFocus = () => {
focusWindow(windowId);
};
const handleResize = () => {
if (resizeTimeoutRef.current) {
clearTimeout(resizeTimeoutRef.current);
}
resizeTimeoutRef.current = setTimeout(() => {
if (terminalRef.current?.fit) {
terminalRef.current.fit();
}
}, 100);
};
const terminalTitle = executeCommand
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
: initialPath
? t("terminal.terminalWithPath", {
host: hostConfig.name,
path: initialPath,
})
: t("terminal.terminalTitle", { host: hostConfig.name });
return (
<DraggableWindow
title={terminalTitle}
initialX={initialX}
initialY={initialY}
initialWidth={800}
initialHeight={500}
minWidth={600}
minHeight={400}
onClose={handleClose}
onMaximize={handleMaximize}
onFocus={handleFocus}
onResize={handleResize}
isMaximized={currentWindow.isMaximized}
zIndex={currentWindow.zIndex}
>
<Terminal
ref={terminalRef}
hostConfig={hostConfig}
isVisible={!currentWindow.isMinimized}
initialPath={initialPath}
executeCommand={executeCommand}
onClose={handleClose}
/>
</DraggableWindow>
);
}

View File

@@ -0,0 +1,138 @@
import React, { useState, useCallback, useRef } from "react";
export interface WindowInstance {
id: string;
title: string;
component: React.ReactNode | ((windowId: string) => React.ReactNode);
x: number;
y: number;
width: number;
height: number;
isMaximized: boolean;
isMinimized: boolean;
zIndex: number;
}
interface WindowManagerProps {
children?: React.ReactNode;
}
interface WindowManagerContextType {
windows: WindowInstance[];
openWindow: (window: Omit<WindowInstance, "id" | "zIndex">) => string;
closeWindow: (id: string) => void;
minimizeWindow: (id: string) => void;
maximizeWindow: (id: string) => void;
focusWindow: (id: string) => void;
updateWindow: (id: string, updates: Partial<WindowInstance>) => void;
}
const WindowManagerContext =
React.createContext<WindowManagerContextType | null>(null);
export function WindowManager({ children }: WindowManagerProps) {
const [windows, setWindows] = useState<WindowInstance[]>([]);
const nextZIndex = useRef(1000);
const windowCounter = useRef(0);
const openWindow = useCallback(
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
const id = `window-${++windowCounter.current}`;
const zIndex = ++nextZIndex.current;
const offset = (windows.length % 5) * 20;
let adjustedX = windowData.x + offset;
let adjustedY = windowData.y + offset;
const maxX = Math.max(0, window.innerWidth - windowData.width - 20);
const maxY = Math.max(0, window.innerHeight - windowData.height - 20);
adjustedX = Math.max(20, Math.min(adjustedX, maxX));
adjustedY = Math.max(20, Math.min(adjustedY, maxY));
const newWindow: WindowInstance = {
...windowData,
id,
zIndex,
x: adjustedX,
y: adjustedY,
};
setWindows((prev) => [...prev, newWindow]);
return id;
},
[windows.length],
);
const closeWindow = useCallback((id: string) => {
setWindows((prev) => prev.filter((w) => w.id !== id));
}, []);
const minimizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
w.id === id ? { ...w, isMinimized: !w.isMinimized } : w,
),
);
}, []);
const maximizeWindow = useCallback((id: string) => {
setWindows((prev) =>
prev.map((w) =>
w.id === id ? { ...w, isMaximized: !w.isMaximized } : w,
),
);
}, []);
const focusWindow = useCallback((id: string) => {
setWindows((prev) => {
const targetWindow = prev.find((w) => w.id === id);
if (!targetWindow) return prev;
const newZIndex = ++nextZIndex.current;
return prev.map((w) => (w.id === id ? { ...w, zIndex: newZIndex } : w));
});
}, []);
const updateWindow = useCallback(
(id: string, updates: Partial<WindowInstance>) => {
setWindows((prev) =>
prev.map((w) => (w.id === id ? { ...w, ...updates } : w)),
);
},
[],
);
const contextValue: WindowManagerContextType = {
windows,
openWindow,
closeWindow,
minimizeWindow,
maximizeWindow,
focusWindow,
updateWindow,
};
return (
<WindowManagerContext.Provider value={contextValue}>
{children}
<div className="window-container">
{windows.map((window) => (
<div key={window.id}>
{typeof window.component === "function"
? window.component(window.id)
: window.component}
</div>
))}
</div>
</WindowManagerContext.Provider>
);
}
export function useWindowManager() {
const context = React.useContext(WindowManagerContext);
if (!context) {
throw new Error("useWindowManager must be used within a WindowManager");
}
return context;
}

View File

@@ -0,0 +1,161 @@
import { useState, useCallback } from "react";
interface DragAndDropState {
isDragging: boolean;
dragCounter: number;
draggedFiles: File[];
}
interface UseDragAndDropProps {
onFilesDropped: (files: FileList) => void;
onError?: (error: string) => void;
maxFileSize?: number;
allowedTypes?: string[];
}
export function useDragAndDrop({
onFilesDropped,
onError,
maxFileSize = 5120,
allowedTypes = [],
}: UseDragAndDropProps) {
const [state, setState] = useState<DragAndDropState>({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
const validateFiles = useCallback(
(files: FileList): string | null => {
const maxSizeBytes = maxFileSize * 1024 * 1024;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > maxSizeBytes) {
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
}
if (allowedTypes.length > 0) {
const fileExt = file.name.split(".").pop()?.toLowerCase();
const mimeType = file.type.toLowerCase();
const isAllowed = allowedTypes.some((type) => {
if (type.startsWith(".")) {
return fileExt === type.slice(1);
}
if (type.includes("/")) {
return (
mimeType === type || mimeType.startsWith(type.replace("*", ""))
);
}
switch (type) {
case "image":
return mimeType.startsWith("image/");
case "video":
return mimeType.startsWith("video/");
case "audio":
return mimeType.startsWith("audio/");
case "text":
return mimeType.startsWith("text/");
default:
return false;
}
});
if (!isAllowed) {
return `File type "${file.type || "unknown"}" is not allowed.`;
}
}
}
return null;
},
[maxFileSize, allowedTypes],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState((prev) => ({
...prev,
dragCounter: prev.dragCounter + 1,
}));
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setState((prev) => ({
...prev,
isDragging: true,
}));
}
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState((prev) => {
const newCounter = prev.dragCounter - 1;
return {
...prev,
dragCounter: newCounter,
isDragging: newCounter > 0,
};
});
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = "copy";
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
const files = e.dataTransfer.files;
if (files.length === 0) {
return;
}
const validationError = validateFiles(files);
if (validationError) {
onError?.(validationError);
return;
}
onFilesDropped(files);
},
[validateFiles, onFilesDropped, onError],
);
const resetDragState = useCallback(() => {
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
}, []);
return {
isDragging: state.isDragging,
dragHandlers: {
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop,
},
resetDragState,
};
}

View File

@@ -0,0 +1,92 @@
import { useState, useCallback } from "react";
interface FileItem {
name: string;
type: "file" | "directory" | "link";
path: string;
size?: number;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
}
export function useFileSelection() {
const [selectedFiles, setSelectedFiles] = useState<FileItem[]>([]);
const selectFile = useCallback((file: FileItem, multiSelect = false) => {
if (multiSelect) {
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
});
} else {
setSelectedFiles([file]);
}
}, []);
const selectRange = useCallback(
(files: FileItem[], startFile: FileItem, endFile: FileItem) => {
const startIndex = files.findIndex((f) => f.path === startFile.path);
const endIndex = files.findIndex((f) => f.path === endFile.path);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const rangeFiles = files.slice(start, end + 1);
setSelectedFiles(rangeFiles);
}
},
[],
);
const selectAll = useCallback((files: FileItem[]) => {
setSelectedFiles([...files]);
}, []);
const clearSelection = useCallback(() => {
setSelectedFiles([]);
}, []);
const toggleSelection = useCallback((file: FileItem) => {
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
});
}, []);
const isSelected = useCallback(
(file: FileItem) => {
return selectedFiles.some((f) => f.path === file.path);
},
[selectedFiles],
);
const getSelectedCount = useCallback(() => {
return selectedFiles.length;
}, [selectedFiles]);
const setSelection = useCallback((files: FileItem[]) => {
setSelectedFiles(files);
}, []);
return {
selectedFiles,
selectFile,
selectRange,
selectAll,
clearSelection,
toggleSelection,
isSelected,
getSelectedCount,
setSelection,
};
}

View File

@@ -0,0 +1,189 @@
import React, { useState, useEffect, useRef } from "react";
import { HostManagerViewer } from "@/ui/desktop/apps/host manager/HostManagerViewer.tsx";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@/components/ui/tabs.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { HostManagerEditor } from "@/ui/desktop/apps/host manager/HostManagerEditor.tsx";
import { CredentialsManager } from "@/ui/desktop/apps/credentials/CredentialsManager.tsx";
import { CredentialEditor } from "@/ui/desktop/apps/credentials/CredentialEditor.tsx";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { useTranslation } from "react-i18next";
import type { SSHHost, HostManagerProps } from "../../../types/index";
export function HostManager({
isTopbarOpen,
initialTab = "host_viewer",
hostConfig,
}: HostManagerProps): React.ReactElement {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState(initialTab);
const [editingHost, setEditingHost] = useState<SSHHost | null>(
hostConfig || null,
);
const [editingCredential, setEditingCredential] = useState<{
id: number;
name?: string;
username: string;
} | null>(null);
const { state: sidebarState } = useSidebar();
const ignoreNextHostConfigChangeRef = useRef<boolean>(false);
const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
useEffect(() => {
if (ignoreNextHostConfigChangeRef.current) {
ignoreNextHostConfigChangeRef.current = false;
return;
}
if (hostConfig && initialTab === "add_host") {
const currentHostId = hostConfig.id;
if (currentHostId !== lastProcessedHostIdRef.current) {
setEditingHost(hostConfig);
setActiveTab("add_host");
lastProcessedHostIdRef.current = currentHostId;
} else if (
activeTab === "host_viewer" ||
activeTab === "credentials" ||
activeTab === "add_credential"
) {
setEditingHost(hostConfig);
setActiveTab("add_host");
}
}
}, [hostConfig, initialTab]);
const handleEditHost = (host: SSHHost) => {
setEditingHost(host);
setActiveTab("add_host");
lastProcessedHostIdRef.current = host.id;
};
const handleFormSubmit = () => {
ignoreNextHostConfigChangeRef.current = true;
setEditingHost(null);
setActiveTab("host_viewer");
setTimeout(() => {
lastProcessedHostIdRef.current = undefined;
}, 500);
};
const handleEditCredential = (credential: {
id: number;
name?: string;
username: string;
}) => {
setEditingCredential(credential);
setActiveTab("add_credential");
};
const handleCredentialFormSubmit = () => {
setEditingCredential(null);
setActiveTab("credentials");
};
const handleTabChange = (value: string) => {
setActiveTab(value);
if (value !== "add_host") {
setEditingHost(null);
isEditingRef.current = false;
}
if (value !== "add_credential") {
setEditingCredential(null);
}
};
const topMarginPx = isTopbarOpen ? 74 : 26;
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
const bottomMarginPx = 8;
return (
<div>
<div className="w-full">
<div
className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden"
style={{
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
}}
>
<Tabs
value={activeTab}
onValueChange={handleTabChange}
className="flex-1 flex flex-col h-full min-h-0"
>
<TabsList className="bg-dark-bg border-2 border-dark-border mt-1.5">
<TabsTrigger value="host_viewer">
{t("hosts.hostViewer")}
</TabsTrigger>
<TabsTrigger value="add_host">
{editingHost
? editingHost.id
? t("hosts.editHost")
: t("hosts.cloneHost")
: t("hosts.addHost")}
</TabsTrigger>
<div className="h-6 w-px bg-dark-border mx-1"></div>
<TabsTrigger value="credentials">
{t("credentials.credentialsViewer")}
</TabsTrigger>
<TabsTrigger value="add_credential">
{editingCredential
? t("credentials.editCredential")
: t("credentials.addCredential")}
</TabsTrigger>
</TabsList>
<TabsContent
value="host_viewer"
className="flex-1 flex flex-col h-full min-h-0"
>
<Separator className="p-0.25 -mt-0.5 mb-1" />
<HostManagerViewer onEditHost={handleEditHost} />
</TabsContent>
<TabsContent
value="add_host"
className="flex-1 flex flex-col h-full min-h-0"
>
<Separator className="p-0.25 -mt-0.5 mb-1" />
<div className="flex flex-col h-full min-h-0">
<HostManagerEditor
editingHost={editingHost}
onFormSubmit={handleFormSubmit}
/>
</div>
</TabsContent>
<TabsContent
value="credentials"
className="flex-1 flex flex-col h-full min-h-0"
>
<Separator className="p-0.25 -mt-0.5 mb-1" />
<div className="flex flex-col h-full min-h-0 overflow-auto">
<CredentialsManager onEditCredential={handleEditCredential} />
</div>
</TabsContent>
<TabsContent
value="add_credential"
className="flex-1 flex flex-col h-full min-h-0"
>
<Separator className="p-0.25 -mt-0.5 mb-1" />
<div className="flex flex-col h-full min-h-0">
<CredentialEditor
editingCredential={editingCredential}
onFormSubmit={handleCredentialFormSubmit}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,506 @@
import React from "react";
import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
import {
getServerStatusById,
getServerMetricsById,
type ServerMetrics,
} from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
type WidgetType,
type StatsConfig,
DEFAULT_STATS_CONFIG,
} from "@/types/stats-widgets";
import {
CpuWidget,
MemoryWidget,
DiskWidget,
NetworkWidget,
UptimeWidget,
ProcessesWidget,
SystemWidget,
} from "./widgets";
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
folder?: string;
enableFileManager?: boolean;
tunnelConnections?: unknown[];
statsConfig?: string | StatsConfig;
[key: string]: unknown;
}
interface TabData {
id: number;
type: string;
title?: string;
hostConfig?: HostConfig;
[key: string]: unknown;
}
interface ServerProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
export function Server({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: ServerProps): React.ReactElement {
const { t } = useTranslation();
const { state: sidebarState } = useSidebar();
const { addTab, tabs } = useTabs() as {
addTab: (tab: { type: string; [key: string]: unknown }) => number;
tabs: TabData[];
};
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
"offline",
);
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
const [metricsHistory, setMetricsHistory] = React.useState<ServerMetrics[]>(
[],
);
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [showStatsUI, setShowStatsUI] = React.useState(true);
const statsConfig = React.useMemo((): StatsConfig => {
if (!currentHostConfig?.statsConfig) {
return DEFAULT_STATS_CONFIG;
}
try {
const parsed =
typeof currentHostConfig.statsConfig === "string"
? JSON.parse(currentHostConfig.statsConfig)
: currentHostConfig.statsConfig;
return { ...DEFAULT_STATS_CONFIG, ...parsed };
} catch (error) {
console.error("Failed to parse statsConfig:", error);
return DEFAULT_STATS_CONFIG;
}
}, [currentHostConfig?.statsConfig]);
const enabledWidgets = statsConfig.enabledWidgets;
const statusCheckEnabled = statsConfig.statusCheckEnabled !== false;
const metricsEnabled = statsConfig.metricsEnabled !== false;
React.useEffect(() => {
setCurrentHostConfig(hostConfig);
}, [hostConfig]);
const renderWidget = (widgetType: WidgetType) => {
switch (widgetType) {
case "cpu":
return <CpuWidget metrics={metrics} metricsHistory={metricsHistory} />;
case "memory":
return (
<MemoryWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "disk":
return <DiskWidget metrics={metrics} metricsHistory={metricsHistory} />;
case "network":
return (
<NetworkWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "uptime":
return (
<UptimeWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "processes":
return (
<ProcessesWidget metrics={metrics} metricsHistory={metricsHistory} />
);
case "system":
return (
<SystemWidget metrics={metrics} metricsHistory={metricsHistory} />
);
default:
return null;
}
};
React.useEffect(() => {
const fetchLatestHostConfig = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
toast.error(t("serverStats.failedToFetchHostConfig"));
}
}
};
fetchLatestHostConfig();
const handleHostsChanged = async () => {
if (hostConfig?.id) {
try {
const { getSSHHosts } = await import("@/ui/main-axios.ts");
const hosts = await getSSHHosts();
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
if (updatedHost) {
setCurrentHostConfig(updatedHost);
}
} catch {
toast.error(t("serverStats.failedToFetchHostConfig"));
}
}
};
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
return () =>
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
}, [hostConfig?.id]);
React.useEffect(() => {
if (!statusCheckEnabled || !currentHostConfig?.id || !isVisible) {
setServerStatus("offline");
return;
}
let cancelled = false;
let intervalId: number | undefined;
const fetchStatus = async () => {
try {
const res = await getServerStatusById(currentHostConfig?.id);
if (!cancelled) {
setServerStatus(res?.status === "online" ? "online" : "offline");
}
} catch (error: unknown) {
if (!cancelled) {
const err = error as {
response?: { status?: number };
};
if (err?.response?.status === 503) {
setServerStatus("offline");
} else if (err?.response?.status === 504) {
setServerStatus("offline");
} else if (err?.response?.status === 404) {
setServerStatus("offline");
} else {
setServerStatus("offline");
}
}
}
};
fetchStatus();
intervalId = window.setInterval(fetchStatus, 10000);
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [currentHostConfig?.id, isVisible, statusCheckEnabled]);
React.useEffect(() => {
if (!metricsEnabled || !currentHostConfig?.id || !isVisible) {
setShowStatsUI(false);
return;
}
let cancelled = false;
let intervalId: number | undefined;
const fetchMetrics = async () => {
if (!currentHostConfig?.id) return;
try {
setIsLoadingMetrics(true);
const data = await getServerMetricsById(currentHostConfig.id);
if (!cancelled) {
setMetrics(data);
setMetricsHistory((prev) => {
const newHistory = [...prev, data];
return newHistory.slice(-20);
});
setShowStatsUI(true);
}
} catch (error: unknown) {
if (!cancelled) {
const err = error as {
code?: string;
response?: { status?: number; data?: { error?: string } };
};
if (err?.response?.status === 404) {
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.code === "TOTP_REQUIRED" ||
(err?.response?.status === 403 &&
err?.response?.data?.error === "TOTP_REQUIRED")
) {
setMetrics(null);
setShowStatsUI(false);
toast.error(t("serverStats.totpUnavailable"));
} else {
setMetrics(null);
setShowStatsUI(false);
toast.error(t("serverStats.failedToFetchMetrics"));
}
}
} finally {
if (!cancelled) {
setIsLoadingMetrics(false);
}
}
};
fetchMetrics();
intervalId = window.setInterval(fetchMetrics, 10000);
return () => {
cancelled = true;
if (intervalId) window.clearInterval(intervalId);
};
}, [currentHostConfig?.id, isVisible, metricsEnabled]);
const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
const bottomMarginPx = 8;
const isFileManagerAlreadyOpen = React.useMemo(() => {
if (!currentHostConfig) return false;
return tabs.some(
(tab: TabData) =>
tab.type === "file_manager" &&
tab.hostConfig?.id === currentHostConfig.id,
);
}, [tabs, currentHostConfig]);
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
marginLeft: leftMarginPx,
marginRight: 17,
marginTop: topMarginPx,
marginBottom: bottomMarginPx,
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
};
const containerClass = embedded
? "h-full w-full text-white overflow-hidden bg-transparent"
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
{statusCheckEnabled && (
<Status
status={serverStatus}
className="!bg-transparent !p-0.75 flex-shrink-0"
>
<StatusIndicator />
</Status>
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant="outline"
disabled={isRefreshing}
className="font-semibold"
onClick={async () => {
if (currentHostConfig?.id) {
try {
setIsRefreshing(true);
const res = await getServerStatusById(currentHostConfig.id);
setServerStatus(
res?.status === "online" ? "online" : "offline",
);
const data = await getServerMetricsById(
currentHostConfig.id,
);
setMetrics(data);
setShowStatsUI(true);
} catch (error: unknown) {
const err = error as {
code?: string;
status?: number;
response?: { status?: number; data?: { error?: string } };
};
if (
err?.code === "TOTP_REQUIRED" ||
(err?.response?.status === 403 &&
err?.response?.data?.error === "TOTP_REQUIRED")
) {
toast.error(t("serverStats.totpUnavailable"));
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.response?.status === 503 ||
err?.status === 503
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.response?.status === 504 ||
err?.status === 504
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else if (
err?.response?.status === 404 ||
err?.status === 404
) {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
} else {
setServerStatus("offline");
setMetrics(null);
setShowStatsUI(false);
}
} finally {
setIsRefreshing(false);
}
}
}}
title={t("serverStats.refreshStatusAndMetrics")}
>
{isRefreshing ? (
<div className="flex items-center gap-2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
{t("serverStats.refreshing")}
</div>
) : (
t("serverStats.refreshStatus")
)}
</Button>
{currentHostConfig?.enableFileManager && (
<Button
variant="outline"
className="font-semibold"
disabled={isFileManagerAlreadyOpen}
title={
isFileManagerAlreadyOpen
? t("serverStats.fileManagerAlreadyOpen")
: t("serverStats.openFileManager")
}
onClick={() => {
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
const titleBase =
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name.trim()
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
addTab({
type: "file_manager",
title: titleBase,
hostConfig: currentHostConfig,
});
}}
>
{t("nav.fileManager")}
</Button>
)}
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-y-auto min-h-0">
{metricsEnabled && showStatsUI && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto">
{isLoadingMetrics && !metrics ? (
<div className="flex items-center justify-center py-8">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">
{t("serverStats.loadingMetrics")}
</span>
</div>
</div>
) : !metrics && serverStatus === "offline" ? (
<div className="flex items-center justify-center py-8">
<div className="text-center">
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
</div>
<p className="text-gray-300 mb-1">
{t("serverStats.serverOffline")}
</p>
<p className="text-sm text-gray-500">
{t("serverStats.cannotFetchMetrics")}
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{enabledWidgets.map((widgetType) => (
<div key={widgetType} className="h-[280px]">
{renderWidget(widgetType)}
</div>
))}
</div>
)}
</div>
)}
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
)}
</div>
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
{t("serverStats.feedbackMessage")}{" "}
<a
href="https://github.com/Termix-SSH/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,101 @@
import React from "react";
import { Cpu } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
const {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} = RechartsPrimitive;
interface CpuWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
const { t } = useTranslation();
const chartData = React.useMemo(() => {
return metricsHistory.map((m, index) => ({
index,
cpu: m.cpu?.percent || 0,
}));
}, [metricsHistory]);
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.cpuUsage")}
</h3>
</div>
<div className="flex flex-col flex-1 min-h-0 gap-2">
<div className="flex items-baseline gap-3 flex-shrink-0">
<div className="text-2xl font-bold text-blue-400">
{typeof metrics?.cpu?.percent === "number"
? `${metrics.cpu.percent}%`
: "N/A"}
</div>
<div className="text-xs text-gray-400">
{typeof metrics?.cpu?.cores === "number"
? t("serverStats.cpuCores", { count: metrics.cpu.cores })
: t("serverStats.naCpus")}
</div>
</div>
<div className="text-xs text-gray-500 flex-shrink-0">
{metrics?.cpu?.load
? t("serverStats.loadAverage", {
avg1: metrics.cpu.load[0].toFixed(2),
avg5: metrics.cpu.load[1].toFixed(2),
avg15: metrics.cpu.load[2].toFixed(2),
})
: t("serverStats.loadAverageNA")}
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="index"
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
hide
/>
<YAxis
domain={[0, 100]}
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
/>
<Tooltip
contentStyle={{
backgroundColor: "#1f2937",
border: "1px solid #374151",
borderRadius: "6px",
color: "#fff",
}}
formatter={(value: number) => [`${value.toFixed(1)}%`, "CPU"]}
/>
<Line
type="monotone"
dataKey="cpu"
stroke="#60a5fa"
strokeWidth={2}
dot={false}
animationDuration={300}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,99 @@
import React from "react";
import { HardDrive } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
const { RadialBarChart, RadialBar, PolarAngleAxis, ResponsiveContainer } =
RechartsPrimitive;
interface DiskWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function DiskWidget({ metrics }: DiskWidgetProps) {
const { t } = useTranslation();
const radialData = React.useMemo(() => {
const percent = metrics?.disk?.percent || 0;
return [
{
name: "Disk",
value: percent,
fill: "#fb923c",
},
];
}, [metrics]);
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.diskUsage")}
</h3>
</div>
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 min-h-0 flex items-center justify-center">
<ResponsiveContainer width="100%" height="100%">
<RadialBarChart
cx="50%"
cy="50%"
innerRadius="60%"
outerRadius="90%"
data={radialData}
startAngle={90}
endAngle={-270}
>
<PolarAngleAxis
type="number"
domain={[0, 100]}
angleAxisId={0}
tick={false}
/>
<RadialBar
background
dataKey="value"
cornerRadius={10}
fill="#fb923c"
/>
<text
x="50%"
y="50%"
textAnchor="middle"
dominantBaseline="middle"
className="text-2xl font-bold fill-orange-400"
>
{typeof metrics?.disk?.percent === "number"
? `${metrics.disk.percent}%`
: "N/A"}
</text>
</RadialBarChart>
</ResponsiveContainer>
</div>
<div className="flex-shrink-0 space-y-1 text-center pb-2">
<div className="text-xs text-gray-400">
{(() => {
const used = metrics?.disk?.usedHuman;
const total = metrics?.disk?.totalHuman;
if (used && total) {
return `${used} / ${total}`;
}
return "N/A";
})()}
</div>
<div className="text-xs text-gray-500">
{(() => {
const available = metrics?.disk?.availableHuman;
return available
? `${t("serverStats.available")}: ${available}`
: `${t("serverStats.available")}: N/A`;
})()}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,117 @@
import React from "react";
import { MemoryStick } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
import { RechartsPrimitive } from "@/components/ui/chart.tsx";
const {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} = RechartsPrimitive;
interface MemoryWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
const { t } = useTranslation();
const chartData = React.useMemo(() => {
return metricsHistory.map((m, index) => ({
index,
memory: m.memory?.percent || 0,
}));
}, [metricsHistory]);
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.memoryUsage")}
</h3>
</div>
<div className="flex flex-col flex-1 min-h-0 gap-2">
<div className="flex items-baseline gap-3 flex-shrink-0">
<div className="text-2xl font-bold text-green-400">
{typeof metrics?.memory?.percent === "number"
? `${metrics.memory.percent}%`
: "N/A"}
</div>
<div className="text-xs text-gray-400">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
if (typeof used === "number" && typeof total === "number") {
return `${used.toFixed(1)} / ${total.toFixed(1)} GiB`;
}
return "N/A";
})()}
</div>
</div>
<div className="text-xs text-gray-500 flex-shrink-0">
{(() => {
const used = metrics?.memory?.usedGiB;
const total = metrics?.memory?.totalGiB;
const free =
typeof used === "number" && typeof total === "number"
? (total - used).toFixed(1)
: "N/A";
return `${t("serverStats.free")}: ${free} GiB`;
})()}
</div>
<div className="flex-1 min-h-0">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="memoryGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#34d399" stopOpacity={0.8} />
<stop offset="95%" stopColor="#34d399" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#374151" />
<XAxis
dataKey="index"
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
hide
/>
<YAxis
domain={[0, 100]}
stroke="#9ca3af"
tick={{ fill: "#9ca3af" }}
/>
<Tooltip
contentStyle={{
backgroundColor: "#1f2937",
border: "1px solid #374151",
borderRadius: "6px",
color: "#fff",
}}
formatter={(value: number) => [
`${value.toFixed(1)}%`,
"Memory",
]}
/>
<Area
type="monotone"
dataKey="memory"
stroke="#34d399"
strokeWidth={2}
fill="url(#memoryGradient)"
animationDuration={300}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import React from "react";
import { Network, Wifi, WifiOff } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
interface NetworkWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function NetworkWidget({ metrics }: NetworkWidgetProps) {
const { t } = useTranslation();
const metricsWithNetwork = metrics as ServerMetrics & {
network?: {
interfaces?: Array<{
name: string;
state: string;
ip: string;
}>;
};
};
const network = metricsWithNetwork?.network;
const interfaces = network?.interfaces || [];
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Network className="h-5 w-5 text-indigo-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.networkInterfaces")}
</h3>
</div>
<div className="space-y-2.5 overflow-auto flex-1">
{interfaces.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<WifiOff className="h-10 w-10 mb-3 opacity-50" />
<p className="text-sm">{t("serverStats.noInterfacesFound")}</p>
</div>
) : (
interfaces.map((iface, index: number) => (
<div
key={index}
className="p-3 rounded-lg bg-dark-bg/50 border border-dark-border/30 hover:bg-dark-bg/60 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Wifi
className={`h-4 w-4 ${iface.state === "UP" ? "text-green-400" : "text-gray-500"}`}
/>
<span className="text-sm font-semibold text-white font-mono">
{iface.name}
</span>
</div>
<span
className={`text-xs px-2.5 py-0.5 rounded-full font-medium ${
iface.state === "UP"
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-500"
}`}
>
{iface.state}
</span>
</div>
<div className="text-xs text-gray-400 font-mono font-medium">
{iface.ip}
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import React from "react";
import { List, Activity } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
interface ProcessesWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
const { t } = useTranslation();
const metricsWithProcesses = metrics as ServerMetrics & {
processes?: {
total?: number;
running?: number;
top?: Array<{
pid: number;
cpu: number;
mem: number;
command: string;
user: string;
}>;
};
};
const processes = metricsWithProcesses?.processes;
const topProcesses = processes?.top || [];
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<List className="h-5 w-5 text-yellow-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.processes")}
</h3>
</div>
<div className="flex items-center justify-between mb-3 pb-2 border-b border-dark-border/30">
<div className="text-sm text-gray-400">
{t("serverStats.totalProcesses")}:{" "}
<span className="text-white font-semibold">
{processes?.total ?? "N/A"}
</span>
</div>
<div className="text-sm text-gray-400">
{t("serverStats.running")}:{" "}
<span className="text-green-400 font-semibold">
{processes?.running ?? "N/A"}
</span>
</div>
</div>
<div className="overflow-auto flex-1">
{topProcesses.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-gray-400">
<Activity className="h-10 w-10 mb-3 opacity-50" />
<p className="text-sm">{t("serverStats.noProcessesFound")}</p>
</div>
) : (
<div className="space-y-2">
{topProcesses.map((proc, index: number) => (
<div
key={index}
className="p-2.5 rounded-lg bg-dark-bg/30 hover:bg-dark-bg/50 transition-colors border border-dark-border/20"
>
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-mono text-gray-400 font-medium">
PID: {proc.pid}
</span>
<div className="flex gap-3 text-xs font-medium">
<span className="text-blue-400">CPU: {proc.cpu}%</span>
<span className="text-green-400">MEM: {proc.mem}%</span>
</div>
</div>
<div className="text-xs text-white font-mono truncate mb-1">
{proc.command}
</div>
<div className="text-xs text-gray-500">User: {proc.user}</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import React from "react";
import { Server, Info } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
interface SystemWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function SystemWidget({ metrics }: SystemWidgetProps) {
const { t } = useTranslation();
const metricsWithSystem = metrics as ServerMetrics & {
system?: {
hostname?: string;
os?: string;
kernel?: string;
};
};
const system = metricsWithSystem?.system;
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Server className="h-5 w-5 text-purple-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.systemInfo")}
</h3>
</div>
<div className="space-y-4">
<div className="flex items-start gap-3">
<Info className="h-4 w-4 text-purple-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-gray-400 mb-1.5">
{t("serverStats.hostname")}
</p>
<p className="text-sm text-white font-mono truncate font-medium">
{system?.hostname || "N/A"}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Info className="h-4 w-4 text-purple-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-gray-400 mb-1.5">
{t("serverStats.operatingSystem")}
</p>
<p className="text-sm text-white font-mono truncate font-medium">
{system?.os || "N/A"}
</p>
</div>
</div>
<div className="flex items-start gap-3">
<Info className="h-4 w-4 text-purple-400 mt-0.5 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs text-gray-400 mb-1.5">
{t("serverStats.kernel")}
</p>
<p className="text-sm text-white font-mono truncate font-medium">
{system?.kernel || "N/A"}
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import React from "react";
import { Clock, Activity } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
interface UptimeWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
export function UptimeWidget({ metrics }: UptimeWidgetProps) {
const { t } = useTranslation();
const metricsWithUptime = metrics as ServerMetrics & {
uptime?: {
formatted?: string;
seconds?: number;
};
};
const uptime = metricsWithUptime?.uptime;
return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Clock className="h-5 w-5 text-cyan-400" />
<h3 className="font-semibold text-lg text-white">
{t("serverStats.uptime")}
</h3>
</div>
<div className="flex flex-col items-center justify-center flex-1">
<div className="relative mb-4">
<div className="w-24 h-24 rounded-full bg-cyan-500/10 flex items-center justify-center">
<Activity className="h-12 w-12 text-cyan-400" />
</div>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-cyan-400 mb-2">
{uptime?.formatted || "N/A"}
</div>
<div className="text-sm text-gray-400">
{t("serverStats.totalUptime")}
</div>
{uptime?.seconds && (
<div className="text-xs text-gray-500 mt-2">
{Math.floor(uptime.seconds).toLocaleString()}{" "}
{t("serverStats.seconds")}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
export { CpuWidget } from "./CpuWidget";
export { MemoryWidget } from "./MemoryWidget";
export { DiskWidget } from "./DiskWidget";
export { NetworkWidget } from "./NetworkWidget";
export { UptimeWidget } from "./UptimeWidget";
export { ProcessesWidget } from "./ProcessesWidget";
export { SystemWidget } from "./SystemWidget";

View File

@@ -0,0 +1,480 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Plus, Play, Edit, Trash2, Copy, X } from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
import {
getSnippets,
createSnippet,
updateSnippet,
deleteSnippet,
} from "@/ui/main-axios";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import type { Snippet, SnippetData } from "../../../../types/index.js";
interface TabData {
id: number;
type: string;
title: string;
terminalRef?: {
current?: {
sendInput?: (data: string) => void;
};
};
[key: string]: unknown;
}
interface SnippetsSidebarProps {
isOpen: boolean;
onClose: () => void;
onExecute: (content: string) => void;
}
export function SnippetsSidebar({
isOpen,
onClose,
onExecute,
}: SnippetsSidebarProps) {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const { tabs } = useTabs() as { tabs: TabData[] };
const [snippets, setSnippets] = useState<Snippet[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
const [formData, setFormData] = useState<SnippetData>({
name: "",
content: "",
description: "",
});
const [formErrors, setFormErrors] = useState({
name: false,
content: false,
});
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
useEffect(() => {
if (isOpen) {
fetchSnippets();
}
}, [isOpen]);
const fetchSnippets = async () => {
try {
setLoading(true);
const data = await getSnippets();
setSnippets(Array.isArray(data) ? data : []);
} catch {
toast.error(t("snippets.failedToFetch"));
setSnippets([]);
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingSnippet(null);
setFormData({ name: "", content: "", description: "" });
setFormErrors({ name: false, content: false });
setShowDialog(true);
};
const handleEdit = (snippet: Snippet) => {
setEditingSnippet(snippet);
setFormData({
name: snippet.name,
content: snippet.content,
description: snippet.description || "",
});
setFormErrors({ name: false, content: false });
setShowDialog(true);
};
const handleDelete = (snippet: Snippet) => {
confirmWithToast(
t("snippets.deleteConfirmDescription", { name: snippet.name }),
async () => {
try {
await deleteSnippet(snippet.id);
toast.success(t("snippets.deleteSuccess"));
fetchSnippets();
} catch {
toast.error(t("snippets.deleteFailed"));
}
},
"destructive",
);
};
const handleSubmit = async () => {
const errors = {
name: !formData.name.trim(),
content: !formData.content.trim(),
};
setFormErrors(errors);
if (errors.name || errors.content) {
return;
}
try {
if (editingSnippet) {
await updateSnippet(editingSnippet.id, formData);
toast.success(t("snippets.updateSuccess"));
} else {
await createSnippet(formData);
toast.success(t("snippets.createSuccess"));
}
setShowDialog(false);
fetchSnippets();
} catch {
toast.error(
editingSnippet
? t("snippets.updateFailed")
: t("snippets.createFailed"),
);
}
};
const handleTabToggle = (tabId: number) => {
setSelectedTabIds((prev) =>
prev.includes(tabId)
? prev.filter((id) => id !== tabId)
: [...prev, tabId],
);
};
const handleExecute = (snippet: Snippet) => {
if (selectedTabIds.length > 0) {
selectedTabIds.forEach((tabId) => {
const tab = tabs.find((t: TabData) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(snippet.content + "\n");
}
});
toast.success(
t("snippets.executeSuccess", {
name: snippet.name,
count: selectedTabIds.length,
}),
);
} else {
onExecute(snippet.content);
toast.success(t("snippets.executeSuccess", { name: snippet.name }));
}
};
const handleCopy = (snippet: Snippet) => {
navigator.clipboard.writeText(snippet.content);
toast.success(t("snippets.copySuccess", { name: snippet.name }));
};
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
if (!isOpen) return null;
return (
<>
<div
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] flex justify-end pointer-events-auto isolate"
style={{
transform: "translateZ(0)",
}}
>
<div className="flex-1 cursor-pointer" onClick={onClose} />
<div
className="w-[400px] h-full bg-dark-bg border-l-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[999999]"
style={{
boxShadow: "-4px 0 20px rgba(0, 0, 0, 0.5)",
transform: "translateZ(0)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-dark-border">
<h2 className="text-lg font-semibold text-white">
{t("snippets.title")}
</h2>
<Button
variant="outline"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t("common.close")}
>
<X />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{terminalTabs.length > 0 && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("snippets.selectTerminals", {
defaultValue: "Select Terminals (optional)",
})}
</label>
<p className="text-xs text-muted-foreground">
{selectedTabIds.length > 0
? t("snippets.executeOnSelected", {
defaultValue: `Execute on ${selectedTabIds.length} selected terminal(s)`,
count: selectedTabIds.length,
})
: t("snippets.executeOnCurrent", {
defaultValue:
"Execute on current terminal (click to select multiple)",
})}
</p>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{terminalTabs.map((tab) => (
<Button
key={tab.id}
type="button"
variant="outline"
size="sm"
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
selectedTabIds.includes(tab.id)
? "text-white bg-gray-700"
: "text-gray-500"
}`}
onClick={() => handleTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
</div>
<Separator className="my-4" />
</>
)}
<Button
onClick={handleCreate}
className="w-full"
variant="outline"
>
<Plus className="w-4 h-4 mr-2" />
{t("snippets.new")}
</Button>
{loading ? (
<div className="text-center text-muted-foreground py-8">
<p>{t("common.loading")}</p>
</div>
) : snippets.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<p className="mb-2 font-medium">{t("snippets.empty")}</p>
<p className="text-sm">{t("snippets.emptyHint")}</p>
</div>
) : (
<TooltipProvider>
<div className="space-y-3">
{snippets.map((snippet) => (
<div
key={snippet.id}
className="bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group"
>
<div className="mb-2">
<h3 className="text-sm font-medium text-white mb-1">
{snippet.name}
</h3>
{snippet.description && (
<p className="text-xs text-muted-foreground">
{snippet.description}
</p>
)}
</div>
<div className="bg-muted/30 rounded p-2 mb-3">
<code className="text-xs font-mono break-all line-clamp-2 text-muted-foreground">
{snippet.content}
</code>
</div>
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="default"
className="flex-1"
onClick={() => handleExecute(snippet)}
>
<Play className="w-3 h-3 mr-1" />
{t("snippets.run")}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.runTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => handleCopy(snippet)}
>
<Copy className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.copyTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(snippet)}
>
<Edit className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.editTooltip")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
onClick={() => handleDelete(snippet)}
className="hover:bg-destructive hover:text-destructive-foreground"
>
<Trash2 className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("snippets.deleteTooltip")}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
</TooltipProvider>
)}
</div>
</div>
</div>
</div>
{showDialog && (
<div
className="fixed inset-0 flex items-center justify-center z-[9999999] bg-black/50 backdrop-blur-sm"
onClick={() => setShowDialog(false)}
>
<div
className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6">
<h2 className="text-xl font-semibold text-white">
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{editingSnippet
? t("snippets.editDescription")
: t("snippets.createDescription")}
</p>
</div>
<div className="space-y-5">
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
{t("snippets.name")}
<span className="text-destructive">*</span>
</label>
<Input
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
placeholder={t("snippets.namePlaceholder")}
className={`${formErrors.name ? "border-destructive focus-visible:ring-destructive" : ""}`}
autoFocus
/>
{formErrors.name && (
<p className="text-xs text-destructive mt-1">
{t("snippets.nameRequired")}
</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("snippets.description")}
<span className="text-muted-foreground ml-1">
({t("common.optional")})
</span>
</label>
<Input
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
placeholder={t("snippets.descriptionPlaceholder")}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white flex items-center gap-1">
{t("snippets.content")}
<span className="text-destructive">*</span>
</label>
<Textarea
value={formData.content}
onChange={(e) =>
setFormData({ ...formData, content: e.target.value })
}
placeholder={t("snippets.contentPlaceholder")}
className={`font-mono text-sm ${formErrors.content ? "border-destructive focus-visible:ring-destructive" : ""}`}
rows={10}
/>
{formErrors.content && (
<p className="text-xs text-destructive mt-1">
{t("snippets.contentRequired")}
</p>
)}
</div>
</div>
<Separator className="my-6" />
<div className="flex gap-3">
<Button
variant="outline"
onClick={() => setShowDialog(false)}
className="flex-1"
>
{t("common.cancel")}
</Button>
<Button onClick={handleSubmit} className="flex-1">
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
</Button>
</div>
</div>
</div>
)}
</>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
import type { TerminalTheme } from "@/constants/terminal-themes";
import { TERMINAL_THEMES, TERMINAL_FONTS } from "@/constants/terminal-themes";
interface TerminalPreviewProps {
theme: string;
fontSize?: number;
fontFamily?: string;
cursorStyle?: "block" | "underline" | "bar";
cursorBlink?: boolean;
letterSpacing?: number;
lineHeight?: number;
}
export function TerminalPreview({
theme = "termix",
fontSize = 14,
fontFamily = "Caskaydia Cove Nerd Font Mono",
cursorStyle = "bar",
cursorBlink = true,
letterSpacing = 0,
lineHeight = 1.2,
}: TerminalPreviewProps) {
return (
<div className="border border-input rounded-md overflow-hidden">
<div
className="p-4 font-mono text-sm"
style={{
fontSize: `${fontSize}px`,
fontFamily:
TERMINAL_FONTS.find((f) => f.value === fontFamily)?.fallback ||
TERMINAL_FONTS[0].fallback,
letterSpacing: `${letterSpacing}px`,
lineHeight,
background: TERMINAL_THEMES[theme]?.colors.background || "#18181b",
color: TERMINAL_THEMES[theme]?.colors.foreground || "#f7f7f7",
}}
>
<div>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.green }}>
user@termix
</span>
<span>:</span>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.blue }}>~</span>
<span>$ ls -la</span>
</div>
<div>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.blue }}>
drwxr-xr-x
</span>
<span> 5 user </span>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.cyan }}>
docs
</span>
</div>
<div>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.green }}>
-rwxr-xr-x
</span>
<span> 1 user </span>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.green }}>
script.sh
</span>
</div>
<div>
<span>-rw-r--r--</span>
<span> 1 user </span>
<span>README.md</span>
</div>
<div>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.green }}>
user@termix
</span>
<span>:</span>
<span style={{ color: TERMINAL_THEMES[theme]?.colors.blue }}>~</span>
<span>$ </span>
<span
className="inline-block"
style={{
width: cursorStyle === "block" ? "0.6em" : "0.1em",
height:
cursorStyle === "underline"
? "0.15em"
: cursorStyle === "bar"
? `${fontSize}px`
: `${fontSize}px`,
background: TERMINAL_THEMES[theme]?.colors.cursor || "#f7f7f7",
animation: cursorBlink ? "blink 1s step-end infinite" : "none",
verticalAlign:
cursorStyle === "underline" ? "bottom" : "text-bottom",
}}
/>
</div>
</div>
<style>{`
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
`}</style>
</div>
);
}

View File

@@ -0,0 +1,351 @@
import React, { useState } from "react";
import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Checkbox } from "@/components/ui/checkbox.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { useTranslation } from "react-i18next";
import { getCookie, setCookie } from "@/ui/main-axios.ts";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { X } from "lucide-react";
interface TabData {
id: number;
type: string;
title: string;
terminalRef?: {
current?: {
sendInput?: (data: string) => void;
};
};
[key: string]: unknown;
}
interface SshToolsSidebarProps {
isOpen: boolean;
onClose: () => void;
}
export function SshToolsSidebar({
isOpen,
onClose,
}: SshToolsSidebarProps): React.ReactElement | null {
const { t } = useTranslation();
const { tabs } = useTabs() as { tabs: TabData[] };
const [isRecording, setIsRecording] = useState(false);
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
const handleTabToggle = (tabId: number) => {
setSelectedTabIds((prev) =>
prev.includes(tabId)
? prev.filter((id) => id !== tabId)
: [...prev, tabId],
);
};
const handleStartRecording = () => {
setIsRecording(true);
setTimeout(() => {
const input = document.getElementById(
"ssh-tools-input",
) as HTMLInputElement;
if (input) input.focus();
}, 100);
};
const handleStopRecording = () => {
setIsRecording(false);
setSelectedTabIds([]);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return;
let commandToSend = "";
if (e.ctrlKey || e.metaKey) {
if (e.key === "c") {
commandToSend = "\x03"; // Ctrl+C (SIGINT)
e.preventDefault();
} else if (e.key === "d") {
commandToSend = "\x04"; // Ctrl+D (EOF)
e.preventDefault();
} else if (e.key === "l") {
commandToSend = "\x0c"; // Ctrl+L (clear screen)
e.preventDefault();
} else if (e.key === "u") {
commandToSend = "\x15"; // Ctrl+U (clear line)
e.preventDefault();
} else if (e.key === "k") {
commandToSend = "\x0b"; // Ctrl+K (clear from cursor to end)
e.preventDefault();
} else if (e.key === "a") {
commandToSend = "\x01"; // Ctrl+A (move to beginning of line)
e.preventDefault();
} else if (e.key === "e") {
commandToSend = "\x05"; // Ctrl+E (move to end of line)
e.preventDefault();
} else if (e.key === "w") {
commandToSend = "\x17"; // Ctrl+W (delete word before cursor)
e.preventDefault();
}
} else if (e.key === "Enter") {
commandToSend = "\n";
e.preventDefault();
} else if (e.key === "Backspace") {
commandToSend = "\x08"; // Backspace
e.preventDefault();
} else if (e.key === "Delete") {
commandToSend = "\x7f"; // Delete
e.preventDefault();
} else if (e.key === "Tab") {
commandToSend = "\x09"; // Tab
e.preventDefault();
} else if (e.key === "Escape") {
commandToSend = "\x1b"; // Escape
e.preventDefault();
} else if (e.key === "ArrowUp") {
commandToSend = "\x1b[A"; // Up arrow
e.preventDefault();
} else if (e.key === "ArrowDown") {
commandToSend = "\x1b[B"; // Down arrow
e.preventDefault();
} else if (e.key === "ArrowLeft") {
commandToSend = "\x1b[D"; // Left arrow
e.preventDefault();
} else if (e.key === "ArrowRight") {
commandToSend = "\x1b[C"; // Right arrow
e.preventDefault();
} else if (e.key === "Home") {
commandToSend = "\x1b[H"; // Home
e.preventDefault();
} else if (e.key === "End") {
commandToSend = "\x1b[F"; // End
e.preventDefault();
} else if (e.key === "PageUp") {
commandToSend = "\x1b[5~"; // Page Up
e.preventDefault();
} else if (e.key === "PageDown") {
commandToSend = "\x1b[6~"; // Page Down
e.preventDefault();
} else if (e.key === "Insert") {
commandToSend = "\x1b[2~"; // Insert
e.preventDefault();
} else if (e.key === "F1") {
commandToSend = "\x1bOP"; // F1
e.preventDefault();
} else if (e.key === "F2") {
commandToSend = "\x1bOQ"; // F2
e.preventDefault();
} else if (e.key === "F3") {
commandToSend = "\x1bOR"; // F3
e.preventDefault();
} else if (e.key === "F4") {
commandToSend = "\x1bOS"; // F4
e.preventDefault();
} else if (e.key === "F5") {
commandToSend = "\x1b[15~"; // F5
e.preventDefault();
} else if (e.key === "F6") {
commandToSend = "\x1b[17~"; // F6
e.preventDefault();
} else if (e.key === "F7") {
commandToSend = "\x1b[18~"; // F7
e.preventDefault();
} else if (e.key === "F8") {
commandToSend = "\x1b[19~"; // F8
e.preventDefault();
} else if (e.key === "F9") {
commandToSend = "\x1b[20~"; // F9
e.preventDefault();
} else if (e.key === "F10") {
commandToSend = "\x1b[21~"; // F10
e.preventDefault();
} else if (e.key === "F11") {
commandToSend = "\x1b[23~"; // F11
e.preventDefault();
} else if (e.key === "F12") {
commandToSend = "\x1b[24~"; // F12
e.preventDefault();
}
if (commandToSend) {
selectedTabIds.forEach((tabId) => {
const tab = tabs.find((t: TabData) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(commandToSend);
}
});
}
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (selectedTabIds.length === 0) return;
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
const char = e.key;
selectedTabIds.forEach((tabId) => {
const tab = tabs.find((t: TabData) => t.id === tabId);
if (tab?.terminalRef?.current?.sendInput) {
tab.terminalRef.current.sendInput(char);
}
});
}
};
const updateRightClickCopyPaste = (checked: boolean) => {
setCookie("rightClickCopyPaste", checked.toString());
};
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
if (!isOpen) return null;
return (
<div
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] flex justify-end pointer-events-auto isolate"
style={{
transform: "translateZ(0)",
}}
>
<div className="flex-1 cursor-pointer" onClick={onClose} />
<div
className="w-[400px] h-full bg-dark-bg border-l-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[999999]"
style={{
boxShadow: "-4px 0 20px rgba(0, 0, 0, 0.5)",
transform: "translateZ(0)",
}}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between p-4 border-b border-dark-border">
<h2 className="text-lg font-semibold text-white">
{t("sshTools.title")}
</h2>
<Button
variant="outline"
size="sm"
onClick={onClose}
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
title={t("sshTools.closeTools")}
>
<span className="text-lg font-bold leading-none">
<X />
</span>
</Button>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
<h1 className="font-semibold">{t("sshTools.keyRecording")}</h1>
<div className="space-y-4">
<div className="space-y-4">
<div className="flex gap-2">
{!isRecording ? (
<Button
onClick={handleStartRecording}
className="flex-1"
variant="outline"
>
{t("sshTools.startKeyRecording")}
</Button>
) : (
<Button
onClick={handleStopRecording}
className="flex-1"
variant="destructive"
>
{t("sshTools.stopKeyRecording")}
</Button>
)}
</div>
{isRecording && (
<>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("sshTools.selectTerminals")}
</label>
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto mt-2">
{terminalTabs.map((tab) => (
<Button
key={tab.id}
type="button"
variant="outline"
size="sm"
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
selectedTabIds.includes(tab.id)
? "text-white bg-gray-700"
: "text-gray-500"
}`}
onClick={() => handleTabToggle(tab.id)}
>
{tab.title}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-white">
{t("sshTools.typeCommands")}
</label>
<Input
id="ssh-tools-input"
placeholder={t("placeholders.typeHere")}
onKeyDown={handleKeyDown}
onKeyPress={handleKeyPress}
className="font-mono mt-2"
disabled={selectedTabIds.length === 0}
readOnly
/>
<p className="text-xs text-muted-foreground">
{t("sshTools.commandsWillBeSent", {
count: selectedTabIds.length,
})}
</p>
</div>
</>
)}
</div>
</div>
<Separator className="my-4" />
<h1 className="font-semibold">{t("sshTools.settings")}</h1>
<div className="flex items-center space-x-2">
<Checkbox
id="enable-copy-paste"
onCheckedChange={updateRightClickCopyPaste}
defaultChecked={getCookie("rightClickCopyPaste") === "true"}
/>
<label
htmlFor="enable-copy-paste"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 text-white"
>
{t("sshTools.enableRightClickCopyPaste")}
</label>
</div>
<Separator className="my-4" />
<p className="pt-2 pb-2 text-sm text-gray-500">
{t("sshTools.shareIdeas")}{" "}
<a
href="https://github.com/Termix-SSH/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:underline"
>
GitHub
</a>
!
</p>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
import React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Hammer, Wrench, FileText } from "lucide-react";
import { useTranslation } from "react-i18next";
interface ToolsMenuProps {
onOpenSshTools: () => void;
onOpenSnippets: () => void;
}
export function ToolsMenu({
onOpenSshTools,
onOpenSnippets,
}: ToolsMenuProps): React.ReactElement {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="w-[30px] h-[30px] border-dark-border"
title={t("nav.tools")}
>
<Hammer className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-56 bg-dark-bg border-dark-border text-white"
>
<DropdownMenuItem
onClick={onOpenSshTools}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<Wrench className="h-4 w-4" />
<span className="flex-1">{t("sshTools.title")}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={onOpenSnippets}
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
>
<FileText className="h-4 w-4" />
<span className="flex-1">{t("snippets.title")}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,208 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { TunnelViewer } from "@/ui/desktop/apps/tunnel/TunnelViewer.tsx";
import {
getSSHHosts,
getTunnelStatuses,
connectTunnel,
disconnectTunnel,
cancelTunnel,
} from "@/ui/main-axios.ts";
import type {
SSHHost,
TunnelConnection,
TunnelStatus,
SSHTunnelProps,
} from "../../../types/index.js";
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
const { t } = useTranslation();
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<
Record<string, TunnelStatus>
>({});
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
{},
);
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
const haveTunnelConnectionsChanged = (
a: TunnelConnection[] = [],
b: TunnelConnection[] = [],
): boolean => {
if (a.length !== b.length) return true;
for (let i = 0; i < a.length; i++) {
const x = a[i];
const y = b[i];
if (
x.sourcePort !== y.sourcePort ||
x.endpointPort !== y.endpointPort ||
x.endpointHost !== y.endpointHost ||
x.maxRetries !== y.maxRetries ||
x.retryInterval !== y.retryInterval ||
x.autoStart !== y.autoStart
) {
return true;
}
}
return false;
};
const fetchHosts = useCallback(async () => {
const hostsData = await getSSHHosts();
setAllHosts(hostsData);
const nextVisible = filterHostKey
? hostsData.filter((h) => {
const key =
h.name && h.name.trim() !== "" ? h.name : `${h.username}@${h.ip}`;
return key === filterHostKey;
})
: hostsData;
const prev = prevVisibleHostRef.current;
const curr = nextVisible[0] ?? null;
let changed = false;
if (!prev && curr) changed = true;
else if (prev && !curr) changed = true;
else if (prev && curr) {
if (
prev.id !== curr.id ||
prev.name !== curr.name ||
prev.ip !== curr.ip ||
prev.port !== curr.port ||
prev.username !== curr.username ||
haveTunnelConnectionsChanged(
prev.tunnelConnections,
curr.tunnelConnections,
)
) {
changed = true;
}
}
if (changed) {
setVisibleHosts(nextVisible);
prevVisibleHostRef.current = curr;
}
}, [filterHostKey]);
const fetchTunnelStatuses = useCallback(async () => {
const statusData = await getTunnelStatuses();
setTunnelStatuses(statusData);
}, []);
useEffect(() => {
fetchHosts();
const interval = setInterval(fetchHosts, 5000);
const handleHostsChanged = () => {
fetchHosts();
};
window.addEventListener(
"ssh-hosts:changed",
handleHostsChanged as EventListener,
);
return () => {
clearInterval(interval);
window.removeEventListener(
"ssh-hosts:changed",
handleHostsChanged as EventListener,
);
};
}, [fetchHosts]);
useEffect(() => {
fetchTunnelStatuses();
const interval = setInterval(fetchTunnelStatuses, 5000);
return () => clearInterval(interval);
}, [fetchTunnelStatuses]);
const handleTunnelAction = async (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
try {
if (action === "connect") {
const endpointHost = allHosts.find(
(h) =>
h.name === tunnel.endpointHost ||
`${h.username}@${h.ip}` === tunnel.endpointHost,
);
if (!endpointHost) {
throw new Error(t("tunnels.endpointHostNotFound"));
}
const tunnelConfig = {
name: tunnelName,
hostName: host.name || `${host.username}@${host.ip}`,
sourceIP: host.ip,
sourceSSHPort: host.port,
sourceUsername: host.username,
sourcePassword:
host.authType === "password" ? host.password : undefined,
sourceAuthMethod: host.authType,
sourceSSHKey: host.authType === "key" ? host.key : undefined,
sourceKeyPassword:
host.authType === "key" ? host.keyPassword : undefined,
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
sourceCredentialId: host.credentialId,
sourceUserId: host.userId,
endpointIP: endpointHost.ip,
endpointSSHPort: endpointHost.port,
endpointUsername: endpointHost.username,
endpointPassword:
endpointHost.authType === "password"
? endpointHost.password
: undefined,
endpointAuthMethod: endpointHost.authType,
endpointSSHKey:
endpointHost.authType === "key" ? endpointHost.key : undefined,
endpointKeyPassword:
endpointHost.authType === "key"
? endpointHost.keyPassword
: undefined,
endpointKeyType:
endpointHost.authType === "key" ? endpointHost.keyType : undefined,
endpointCredentialId: endpointHost.credentialId,
endpointUserId: endpointHost.userId,
sourcePort: tunnel.sourcePort,
endpointPort: tunnel.endpointPort,
maxRetries: tunnel.maxRetries,
retryInterval: tunnel.retryInterval * 1000,
autoStart: tunnel.autoStart,
isPinned: host.pin,
};
await connectTunnel(tunnelConfig);
} else if (action === "disconnect") {
await disconnectTunnel(tunnelName);
} else if (action === "cancel") {
await cancelTunnel(tunnelName);
}
await fetchTunnelStatuses();
} catch {
} finally {
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
}
};
return (
<TunnelViewer
hosts={visibleHosts}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={handleTunnelAction}
/>
);
}

View File

@@ -0,0 +1,533 @@
import React from "react";
import { Button } from "@/components/ui/button.tsx";
import { Card } from "@/components/ui/card.tsx";
import { Separator } from "@/components/ui/separator.tsx";
import { useTranslation } from "react-i18next";
import {
Loader2,
Pin,
Network,
Tag,
Play,
Square,
AlertCircle,
Clock,
Wifi,
WifiOff,
X,
} from "lucide-react";
import { Badge } from "@/components/ui/badge.tsx";
import type {
TunnelStatus,
SSHTunnelObjectProps,
} from "../../../types/index.js";
export function TunnelObject({
host,
tunnelStatuses,
tunnelActions,
onTunnelAction,
compact = false,
bare = false,
}: SSHTunnelObjectProps): React.ReactElement {
const { t } = useTranslation();
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
const tunnel = host.tunnelConnections[tunnelIndex];
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
return tunnelStatuses[tunnelName];
};
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
if (!status)
return {
icon: <WifiOff className="h-4 w-4" />,
text: t("tunnels.unknown"),
color: "text-muted-foreground",
bgColor: "bg-muted/50",
borderColor: "border-border",
};
const statusValue = status.status || "DISCONNECTED";
switch (statusValue.toUpperCase()) {
case "CONNECTED":
return {
icon: <Wifi className="h-4 w-4" />,
text: t("tunnels.connected"),
color: "text-green-600 dark:text-green-400",
bgColor: "bg-green-500/10 dark:bg-green-400/10",
borderColor: "border-green-500/20 dark:border-green-400/20",
};
case "CONNECTING":
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
text: t("tunnels.connecting"),
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
borderColor: "border-blue-500/20 dark:border-blue-400/20",
};
case "DISCONNECTING":
return {
icon: <Loader2 className="h-4 w-4 animate-spin" />,
text: t("tunnels.disconnecting"),
color: "text-orange-600 dark:text-orange-400",
bgColor: "bg-orange-500/10 dark:bg-orange-400/10",
borderColor: "border-orange-500/20 dark:border-orange-400/20",
};
case "DISCONNECTED":
return {
icon: <WifiOff className="h-4 w-4" />,
text: t("tunnels.disconnected"),
color: "text-muted-foreground",
bgColor: "bg-muted/30",
borderColor: "border-border",
};
case "WAITING":
return {
icon: <Clock className="h-4 w-4" />,
color: "text-blue-600 dark:text-blue-400",
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
borderColor: "border-blue-500/20 dark:border-blue-400/20",
};
case "ERROR":
case "FAILED":
return {
icon: <AlertCircle className="h-4 w-4" />,
text: status.reason || t("tunnels.error"),
color: "text-red-600 dark:text-red-400",
bgColor: "bg-red-500/10 dark:bg-red-400/10",
borderColor: "border-red-500/20 dark:border-red-400/20",
};
default:
return {
icon: <WifiOff className="h-4 w-4" />,
text: statusValue,
color: "text-muted-foreground",
bgColor: "bg-muted/30",
borderColor: "border-border",
};
}
};
if (bare) {
return (
<div className="w-full min-w-0">
<div className="space-y-3">
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName];
const statusValue =
status?.status?.toUpperCase() || "DISCONNECTED";
const isConnected = statusValue === "CONNECTED";
const isConnecting = statusValue === "CONNECTING";
const isDisconnecting = statusValue === "DISCONNECTING";
const isRetrying = statusValue === "RETRYING";
const isWaiting = statusValue === "WAITING";
return (
<div
key={tunnelIndex}
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
>
{statusDisplay.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">
{t("tunnels.port")} {tunnel.sourcePort} {" "}
{tunnel.endpointHost}:{tunnel.endpointPort}
</div>
<div
className={`text-xs ${statusDisplay.color} font-medium`}
>
{statusDisplay.text}
</div>
</div>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
{!isActionLoading ? (
<div className="flex flex-col gap-1">
{isConnected ? (
<>
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction(
"disconnect",
host,
tunnelIndex,
)
}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1" />
{t("tunnels.disconnect")}
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("cancel", host, tunnelIndex)
}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
<X className="h-3 w-3 mr-1" />
{t("tunnels.cancel")}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("connect", host, tunnelIndex)
}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1" />
{t("tunnels.connect")}
</Button>
)}
</div>
) : (
<Button
size="sm"
variant="outline"
disabled
className="h-7 px-2 text-muted-foreground border-border text-xs"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{isConnected
? t("tunnels.disconnecting")
: isRetrying || isWaiting
? t("tunnels.canceling")
: t("tunnels.connecting")}
</Button>
)}
</div>
</div>
{(statusValue === "ERROR" || statusValue === "FAILED") &&
status?.reason && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">
{t("tunnels.error")}:
</div>
{status.reason}
{status.reason &&
status.reason.includes("Max retries exhausted") && (
<>
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
{t("tunnels.checkDockerLogs")}{" "}
<a
href="https://discord.com/invite/jVQGdvHDrf"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
{t("tunnels.discord")}
</a>{" "}
or create a{" "}
<a
href="https://github.com/Termix-SSH/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
{t("tunnels.githubIssue")}
</a>{" "}
{t("tunnels.forHelp")}.
</div>
</>
)}
</div>
)}
{(statusValue === "RETRYING" ||
statusValue === "WAITING") &&
status?.retryCount &&
status?.maxRetries && (
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1">
{statusValue === "WAITING"
? t("tunnels.waitingForRetry")
: t("tunnels.retryingConnection")}
</div>
<div>
{t("tunnels.attempt", {
current: status.retryCount,
max: status.maxRetries,
})}
{status.nextRetryIn && (
<span>
{" "}
{" "}
{t("tunnels.nextRetryIn", {
seconds: status.nextRetryIn,
})}
</span>
)}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
</div>
)}
</div>
</div>
);
}
return (
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
<div className="p-4">
{!compact && (
<div className="flex items-center justify-between gap-2 mb-3">
<div className="flex items-center gap-2 flex-1 min-w-0">
{host.pin && (
<Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-card-foreground truncate">
{host.name || `${host.username}@${host.ip}`}
</h3>
<p className="text-xs text-muted-foreground truncate">
{host.ip}:{host.port} {host.username}
</p>
</div>
</div>
</div>
)}
{!compact && host.tags && host.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{host.tags.slice(0, 3).map((tag, index) => (
<Badge
key={index}
variant="secondary"
className="text-xs px-1 py-0"
>
<Tag className="h-2 w-2 mr-0.5" />
{tag}
</Badge>
))}
{host.tags.length > 3 && (
<Badge variant="outline" className="text-xs px-1 py-0">
+{host.tags.length - 3}
</Badge>
)}
</div>
)}
{!compact && <Separator className="mb-3" />}
<div className="space-y-3">
{!compact && (
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
<Network className="h-4 w-4" />
{t("tunnels.tunnelConnections")} ({host.tunnelConnections.length})
</h4>
)}
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
<div className="space-y-3">
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
const status = getTunnelStatus(tunnelIndex);
const statusDisplay = getTunnelStatusDisplay(status);
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
const isActionLoading = tunnelActions[tunnelName];
const statusValue =
status?.status?.toUpperCase() || "DISCONNECTED";
const isConnected = statusValue === "CONNECTED";
const isConnecting = statusValue === "CONNECTING";
const isDisconnecting = statusValue === "DISCONNECTING";
const isRetrying = statusValue === "RETRYING";
const isWaiting = statusValue === "WAITING";
return (
<div
key={tunnelIndex}
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
>
<div className="flex items-start justify-between gap-2">
<div className="flex items-start gap-2 flex-1 min-w-0">
<span
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
>
{statusDisplay.icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium break-words">
{t("tunnels.port")} {tunnel.sourcePort} {" "}
{tunnel.endpointHost}:{tunnel.endpointPort}
</div>
<div
className={`text-xs ${statusDisplay.color} font-medium`}
>
{statusDisplay.text}
</div>
</div>
</div>
<div className="flex items-center gap-1 flex-shrink-0">
{!isActionLoading && (
<div className="flex flex-col gap-1">
{isConnected ? (
<>
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction(
"disconnect",
host,
tunnelIndex,
)
}
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
>
<Square className="h-3 w-3 mr-1" />
{t("tunnels.disconnect")}
</Button>
</>
) : isRetrying || isWaiting ? (
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("cancel", host, tunnelIndex)
}
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
>
<X className="h-3 w-3 mr-1" />
{t("tunnels.cancel")}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() =>
onTunnelAction("connect", host, tunnelIndex)
}
disabled={isConnecting || isDisconnecting}
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
>
<Play className="h-3 w-3 mr-1" />
{t("tunnels.connect")}
</Button>
)}
</div>
)}
{isActionLoading && (
<Button
size="sm"
variant="outline"
disabled
className="h-7 px-2 text-muted-foreground border-border text-xs"
>
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
{isConnected
? t("tunnels.disconnecting")
: isRetrying || isWaiting
? t("tunnels.canceling")
: t("tunnels.connecting")}
</Button>
)}
</div>
</div>
{(statusValue === "ERROR" || statusValue === "FAILED") &&
status?.reason && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
<div className="font-medium mb-1">
{t("tunnels.error")}:
</div>
{status.reason}
{status.reason &&
status.reason.includes("Max retries exhausted") && (
<>
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
{t("tunnels.checkDockerLogs")}{" "}
<a
href="https://discord.com/invite/jVQGdvHDrf"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
{t("tunnels.discord")}
</a>{" "}
or create a{" "}
<a
href="https://github.com/Termix-SSH/Termix/issues/new"
target="_blank"
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
{t("tunnels.githubIssue")}
</a>{" "}
{t("tunnels.forHelp")}.
</div>
</>
)}
</div>
)}
{(statusValue === "RETRYING" ||
statusValue === "WAITING") &&
status?.retryCount &&
status?.maxRetries && (
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
<div className="font-medium mb-1">
{statusValue === "WAITING"
? t("tunnels.waitingForRetry")
: t("tunnels.retryingConnection")}
</div>
<div>
{t("tunnels.attempt", {
current: status.retryCount,
max: status.maxRetries,
})}
{status.nextRetryIn && (
<span>
{" "}
{" "}
{t("tunnels.nextRetryIn", {
seconds: status.nextRetryIn,
})}
</span>
)}
</div>
</div>
)}
</div>
);
})}
</div>
) : (
<div className="text-center py-4 text-muted-foreground">
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
</div>
)}
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,73 @@
import React from "react";
import { TunnelObject } from "./TunnelObject.tsx";
import { useTranslation } from "react-i18next";
import type { SSHHost, TunnelStatus } from "../../../types/index.js";
interface SSHTunnelViewerProps {
hosts: SSHHost[];
tunnelStatuses: Record<string, TunnelStatus>;
tunnelActions: Record<string, boolean>;
onTunnelAction: (
action: "connect" | "disconnect" | "cancel",
host: SSHHost,
tunnelIndex: number,
) => Promise<unknown>;
}
export function TunnelViewer({
hosts = [],
tunnelStatuses = {},
tunnelActions = {},
onTunnelAction,
}: SSHTunnelViewerProps): React.ReactElement {
const { t } = useTranslation();
const activeHost: SSHHost | undefined =
Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
if (
!activeHost ||
!activeHost.tunnelConnections ||
activeHost.tunnelConnections.length === 0
) {
return (
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
<h3 className="text-lg font-semibold text-foreground mb-2">
{t("tunnels.noSshTunnels")}
</h3>
<p className="text-muted-foreground max-w-md">
{t("tunnels.createFirstTunnelMessage")}
</p>
</div>
);
}
return (
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
<div className="w-full flex-shrink-0 mb-2">
<h1 className="text-xl font-semibold text-foreground">
{t("tunnels.title")}
</h1>
</div>
<div className="min-h-0 flex-1 overflow-auto pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{activeHost.tunnelConnections.map((t, idx) => (
<TunnelObject
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
host={{
...activeHost,
tunnelConnections: [activeHost.tunnelConnections[idx]],
}}
tunnelStatuses={tunnelStatuses}
tunnelActions={tunnelActions}
onTunnelAction={(action) =>
onTunnelAction(action, activeHost, idx)
}
compact
bare
/>
))}
</div>
</div>
</div>
);
}