chore: cleanup files (possible RC)
This commit is contained in:
@@ -72,7 +72,6 @@ export function AdminSettings({
|
||||
>([]);
|
||||
const [usersLoading, setUsersLoading] = React.useState(false);
|
||||
|
||||
// New dialog states
|
||||
const [createUserDialogOpen, setCreateUserDialogOpen] = React.useState(false);
|
||||
const [userEditDialogOpen, setUserEditDialogOpen] = React.useState(false);
|
||||
const [selectedUserForEdit, setSelectedUserForEdit] = React.useState<{
|
||||
@@ -216,7 +215,6 @@ export function AdminSettings({
|
||||
}
|
||||
};
|
||||
|
||||
// New dialog handlers
|
||||
const handleEditUser = (user: (typeof users)[0]) => {
|
||||
setSelectedUserForEdit(user);
|
||||
setUserEditDialogOpen(true);
|
||||
|
||||
@@ -34,7 +34,6 @@ export function CreateUserDialog({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset form when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setUsername("");
|
||||
|
||||
@@ -33,7 +33,6 @@ export function LinkAccountDialog({
|
||||
const [linkTargetUsername, setLinkTargetUsername] = useState("");
|
||||
const [linkLoading, setLinkLoading] = useState(false);
|
||||
|
||||
// Reset form when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setLinkTargetUsername("");
|
||||
|
||||
@@ -114,7 +114,6 @@ export function UserEditDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Close dialog temporarily to show confirmation toast on top
|
||||
const userToUpdate = user;
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -165,7 +164,6 @@ export function UserEditDialog({
|
||||
const handlePasswordReset = async () => {
|
||||
if (!user) return;
|
||||
|
||||
// Close dialog temporarily to show confirmation toast on top
|
||||
const userToReset = user;
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -217,7 +215,6 @@ export function UserEditDialog({
|
||||
const handleRemoveRole = async (roleId: number) => {
|
||||
if (!user) return;
|
||||
|
||||
// Close dialog temporarily to show confirmation toast on top
|
||||
const userToUpdate = user;
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -253,7 +250,6 @@ export function UserEditDialog({
|
||||
|
||||
const isRevokingSelf = isCurrentUser;
|
||||
|
||||
// Close dialog temporarily to show confirmation toast on top
|
||||
const userToUpdate = user;
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -302,7 +298,6 @@ export function UserEditDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Close dialog temporarily to show confirmation toast on top
|
||||
const userToDelete = user;
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -315,7 +310,6 @@ export function UserEditDialog({
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
// Reopen dialog if user cancels
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
@@ -366,7 +360,6 @@ export function UserEditDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4 max-h-[70vh] overflow-y-auto thin-scrollbar pr-2">
|
||||
{/* READ-ONLY INFO SECTION */}
|
||||
<div className="grid grid-cols-2 gap-4 p-4 bg-surface rounded-lg border border-edge">
|
||||
<div>
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
@@ -402,7 +395,6 @@ export function UserEditDialog({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ADMIN TOGGLE SECTION */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
@@ -430,7 +422,6 @@ export function UserEditDialog({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* PASSWORD RESET SECTION */}
|
||||
{showPasswordReset && (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
@@ -460,7 +451,6 @@ export function UserEditDialog({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ROLE MANAGEMENT SECTION */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<UserCog className="h-4 w-4" />
|
||||
@@ -473,7 +463,6 @@ export function UserEditDialog({
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Current Roles */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("rbac.currentRoles")}
|
||||
@@ -520,7 +509,6 @@ export function UserEditDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Assign New Role */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">
|
||||
{t("rbac.assignNewRole")}
|
||||
@@ -560,7 +548,6 @@ export function UserEditDialog({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* SESSION MANAGEMENT SECTION */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
@@ -588,7 +575,6 @@ export function UserEditDialog({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* DANGER ZONE - DELETE USER */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
|
||||
@@ -39,14 +39,12 @@ export function RolesTab(): React.ReactElement {
|
||||
const [roles, setRoles] = React.useState<Role[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
// Create/Edit Role Dialog
|
||||
const [roleDialogOpen, setRoleDialogOpen] = React.useState(false);
|
||||
const [editingRole, setEditingRole] = React.useState<Role | null>(null);
|
||||
const [roleName, setRoleName] = React.useState("");
|
||||
const [roleDisplayName, setRoleDisplayName] = React.useState("");
|
||||
const [roleDescription, setRoleDescription] = React.useState("");
|
||||
|
||||
// Load roles
|
||||
const loadRoles = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -65,7 +63,6 @@ export function RolesTab(): React.ReactElement {
|
||||
loadRoles();
|
||||
}, [loadRoles]);
|
||||
|
||||
// Create role
|
||||
const handleCreateRole = () => {
|
||||
setEditingRole(null);
|
||||
setRoleName("");
|
||||
@@ -74,7 +71,6 @@ export function RolesTab(): React.ReactElement {
|
||||
setRoleDialogOpen(true);
|
||||
};
|
||||
|
||||
// Edit role
|
||||
const handleEditRole = (role: Role) => {
|
||||
setEditingRole(role);
|
||||
setRoleName(role.name);
|
||||
@@ -83,7 +79,6 @@ export function RolesTab(): React.ReactElement {
|
||||
setRoleDialogOpen(true);
|
||||
};
|
||||
|
||||
// Save role
|
||||
const handleSaveRole = async () => {
|
||||
if (!roleDisplayName.trim()) {
|
||||
toast.error(t("rbac.roleDisplayNameRequired"));
|
||||
@@ -97,14 +92,12 @@ export function RolesTab(): React.ReactElement {
|
||||
|
||||
try {
|
||||
if (editingRole) {
|
||||
// Update existing role
|
||||
await updateRole(editingRole.id, {
|
||||
displayName: roleDisplayName,
|
||||
description: roleDescription || null,
|
||||
});
|
||||
toast.success(t("rbac.roleUpdatedSuccessfully"));
|
||||
} else {
|
||||
// Create new role
|
||||
await createRole({
|
||||
name: roleName,
|
||||
displayName: roleDisplayName,
|
||||
@@ -120,7 +113,6 @@ export function RolesTab(): React.ReactElement {
|
||||
}
|
||||
};
|
||||
|
||||
// Delete role
|
||||
const handleDeleteRole = async (role: Role) => {
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("rbac.confirmDeleteRole"),
|
||||
|
||||
@@ -329,7 +329,6 @@ export function CommandPalette({
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
|
||||
// Parse statsConfig to determine if metrics should be shown
|
||||
let shouldShowMetrics = true;
|
||||
try {
|
||||
const statsConfig = host.statsConfig
|
||||
@@ -340,7 +339,6 @@ export function CommandPalette({
|
||||
shouldShowMetrics = true;
|
||||
}
|
||||
|
||||
// Check if host has at least one tunnel connection
|
||||
let hasTunnelConnections = false;
|
||||
try {
|
||||
const tunnelConnections = Array.isArray(
|
||||
|
||||
@@ -600,10 +600,8 @@ export function Dashboard({
|
||||
) : (
|
||||
recentActivity
|
||||
.filter((item, index, array) => {
|
||||
// Always show the first item
|
||||
if (index === 0) return true;
|
||||
|
||||
// Show if different from previous item (by hostId and type)
|
||||
const prevItem = array[index - 1];
|
||||
return !(
|
||||
item.hostId === prevItem.hostId &&
|
||||
|
||||
@@ -21,9 +21,6 @@ import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.ts
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { ContainerList } from "./components/ContainerList.tsx";
|
||||
import { LogViewer } from "./components/LogViewer.tsx";
|
||||
import { ContainerStats } from "./components/ContainerStats.tsx";
|
||||
import { ConsoleTerminal } from "./components/ConsoleTerminal.tsx";
|
||||
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
||||
|
||||
interface DockerManagerProps {
|
||||
@@ -105,7 +102,6 @@ export function DockerManager({
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
// SSH session lifecycle
|
||||
React.useEffect(() => {
|
||||
const initSession = async () => {
|
||||
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
|
||||
@@ -119,7 +115,6 @@ export function DockerManager({
|
||||
await connectDockerSession(sid, currentHostConfig.id);
|
||||
setSessionId(sid);
|
||||
|
||||
// Validate Docker availability
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(sid);
|
||||
setDockerValidation(validation);
|
||||
@@ -152,7 +147,6 @@ export function DockerManager({
|
||||
};
|
||||
}, [currentHostConfig?.id, currentHostConfig?.enableDocker]);
|
||||
|
||||
// Keepalive interval
|
||||
React.useEffect(() => {
|
||||
if (!sessionId || !isVisible) return;
|
||||
|
||||
@@ -163,12 +157,11 @@ export function DockerManager({
|
||||
});
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
); // Every 10 minutes
|
||||
);
|
||||
|
||||
return () => clearInterval(keepalive);
|
||||
}, [sessionId, isVisible]);
|
||||
|
||||
// Refresh containers function
|
||||
const refreshContainers = React.useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
@@ -179,7 +172,6 @@ export function DockerManager({
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// Poll containers
|
||||
React.useEffect(() => {
|
||||
if (!sessionId || !isVisible || !dockerValidation?.available) return;
|
||||
|
||||
@@ -196,8 +188,8 @@ export function DockerManager({
|
||||
}
|
||||
};
|
||||
|
||||
pollContainers(); // Initial fetch
|
||||
const interval = setInterval(pollContainers, 5000); // Poll every 5 seconds
|
||||
pollContainers();
|
||||
const interval = setInterval(pollContainers, 5000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -229,7 +221,6 @@ export function DockerManager({
|
||||
? "h-full w-full text-foreground overflow-hidden bg-transparent"
|
||||
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||
|
||||
// Check if Docker is enabled
|
||||
if (!currentHostConfig?.enableDocker) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
@@ -256,7 +247,6 @@ export function DockerManager({
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (isConnecting || isValidating) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
@@ -287,7 +277,6 @@ export function DockerManager({
|
||||
);
|
||||
}
|
||||
|
||||
// Docker not available
|
||||
if (dockerValidation && !dockerValidation.available) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
|
||||
@@ -46,7 +46,6 @@ export function ConsoleTerminal({
|
||||
const getWebSocketBaseUrl = React.useCallback(() => {
|
||||
const isElectronApp = isElectron();
|
||||
|
||||
// Development mode check (similar to Terminal.tsx)
|
||||
const isDev =
|
||||
!isElectronApp &&
|
||||
process.env.NODE_ENV === "development" &&
|
||||
@@ -55,28 +54,23 @@ export function ConsoleTerminal({
|
||||
window.location.port === "");
|
||||
|
||||
if (isDev) {
|
||||
// Development: connect directly to port 30008
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//localhost:30008`;
|
||||
}
|
||||
|
||||
if (isElectronApp) {
|
||||
// Electron: construct URL from configured server
|
||||
const baseUrl =
|
||||
(window as { configuredServerUrl?: string }).configuredServerUrl ||
|
||||
"http://127.0.0.1:30001";
|
||||
const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
// Use nginx path routing, not direct port
|
||||
return `${wsProtocol}${wsHost}/docker/console/`;
|
||||
}
|
||||
|
||||
// Production web: use nginx proxy path (same as Terminal uses /ssh/websocket/)
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/docker/console/`;
|
||||
}, []);
|
||||
|
||||
// Initialize terminal
|
||||
React.useEffect(() => {
|
||||
if (!terminal) return;
|
||||
|
||||
@@ -94,7 +88,6 @@ export function ConsoleTerminal({
|
||||
terminal.options.fontSize = 14;
|
||||
terminal.options.fontFamily = "monospace";
|
||||
|
||||
// Get theme colors from CSS variables
|
||||
const backgroundColor = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue("--bg-elevated")
|
||||
.trim();
|
||||
@@ -132,13 +125,10 @@ export function ConsoleTerminal({
|
||||
return () => {
|
||||
window.removeEventListener("resize", resizeHandler);
|
||||
|
||||
// Clean up WebSocket before disposing terminal
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch (error) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
} catch (error) {}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
@@ -151,9 +141,7 @@ export function ConsoleTerminal({
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch (error) {
|
||||
// WebSocket might already be closed
|
||||
}
|
||||
} catch (error) {}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
@@ -161,9 +149,7 @@ export function ConsoleTerminal({
|
||||
if (terminal) {
|
||||
try {
|
||||
terminal.clear();
|
||||
} catch (error) {
|
||||
// Terminal might be disposed
|
||||
}
|
||||
} catch (error) {}
|
||||
}
|
||||
}, [terminal, t]);
|
||||
|
||||
@@ -185,7 +171,6 @@ export function ConsoleTerminal({
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure terminal is fitted before connecting
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
@@ -194,7 +179,6 @@ export function ConsoleTerminal({
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
// Double-check terminal dimensions
|
||||
const cols = terminal.cols || 80;
|
||||
const rows = terminal.rows || 24;
|
||||
|
||||
@@ -225,7 +209,6 @@ export function ConsoleTerminal({
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
|
||||
// Check if shell was changed due to unavailability
|
||||
if (msg.data?.shellChanged) {
|
||||
toast.warning(
|
||||
`Shell "${msg.data.requestedShell}" not available. Using "${msg.data.shell}" instead.`,
|
||||
@@ -234,13 +217,11 @@ export function ConsoleTerminal({
|
||||
toast.success(t("docker.connectedTo", { containerName }));
|
||||
}
|
||||
|
||||
// Fit terminal and send resize to ensure correct dimensions
|
||||
setTimeout(() => {
|
||||
if (fitAddonRef.current) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
|
||||
// Send resize message with correct dimensions
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -284,7 +265,6 @@ export function ConsoleTerminal({
|
||||
toast.error(t("docker.failedToConnect"));
|
||||
};
|
||||
|
||||
// Set up periodic ping to keep connection alive
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
}
|
||||
@@ -292,7 +272,7 @@ export function ConsoleTerminal({
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 30000); // Ping every 30 seconds
|
||||
}, 30000);
|
||||
|
||||
ws.onclose = () => {
|
||||
if (pingIntervalRef.current) {
|
||||
@@ -308,7 +288,6 @@ export function ConsoleTerminal({
|
||||
|
||||
wsRef.current = ws;
|
||||
|
||||
// Handle terminal input
|
||||
terminal.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
@@ -335,7 +314,6 @@ export function ConsoleTerminal({
|
||||
containerName,
|
||||
]);
|
||||
|
||||
// Cleanup WebSocket on unmount (terminal cleanup is handled in the terminal effect)
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (pingIntervalRef.current) {
|
||||
@@ -345,9 +323,7 @@ export function ConsoleTerminal({
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.send(JSON.stringify({ type: "disconnect" }));
|
||||
} catch (error) {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
} catch (error) {}
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
@@ -373,7 +349,6 @@ export function ConsoleTerminal({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Controls */}
|
||||
<Card className="py-3">
|
||||
<CardContent className="px-3">
|
||||
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
|
||||
@@ -431,17 +406,14 @@ export function ConsoleTerminal({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Terminal */}
|
||||
<Card className="flex-1 overflow-hidden pt-1 pb-0">
|
||||
<CardContent className="p-0 h-full relative">
|
||||
{/* Terminal container - always rendered */}
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className="h-full w-full"
|
||||
style={{ display: isConnected ? "block" : "none" }}
|
||||
/>
|
||||
|
||||
{/* Not connected message */}
|
||||
{!isConnected && !isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
@@ -456,7 +428,6 @@ export function ConsoleTerminal({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connecting message */}
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
|
||||
@@ -213,10 +213,8 @@ export function ContainerCard({
|
||||
const isLoading =
|
||||
isStarting || isStopping || isRestarting || isPausing || isRemoving;
|
||||
|
||||
// Format the created date to be more readable
|
||||
const formatCreatedDate = (dateStr: string): string => {
|
||||
try {
|
||||
// Remove the timezone suffix like "+0000 UTC"
|
||||
const cleanDate = dateStr.replace(/\s*\+\d{4}\s*UTC\s*$/, "").trim();
|
||||
return cleanDate;
|
||||
} catch {
|
||||
@@ -224,11 +222,9 @@ export function ContainerCard({
|
||||
}
|
||||
};
|
||||
|
||||
// Parse ports into array of port mappings
|
||||
const parsePorts = (portsStr: string | undefined): string[] => {
|
||||
if (!portsStr || portsStr.trim() === "") return [];
|
||||
|
||||
// Split by comma and clean up
|
||||
return portsStr
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
|
||||
@@ -52,7 +52,6 @@ export function ContainerDetail({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header with back button */}
|
||||
<div className="flex items-center gap-4 px-4 pt-3 pb-3">
|
||||
<Button variant="ghost" onClick={onBack} size="sm">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
@@ -67,7 +66,6 @@ export function ContainerDetail({
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
{/* Tabs for Logs, Stats, Console */}
|
||||
<div className="flex-1 overflow-hidden min-h-0">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
|
||||
@@ -70,7 +70,6 @@ export function ContainerList({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Search and Filter Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
@@ -106,7 +105,6 @@ export function ContainerList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Container Grid */}
|
||||
{filteredContainers.length === 0 ? (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<div className="text-center space-y-2">
|
||||
|
||||
@@ -53,7 +53,6 @@ export function ContainerStats({
|
||||
React.useEffect(() => {
|
||||
fetchStats();
|
||||
|
||||
// Poll stats every 2 seconds
|
||||
const interval = setInterval(fetchStats, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
@@ -114,7 +113,6 @@ export function ContainerStats({
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 h-full overflow-auto thin-scrollbar">
|
||||
{/* CPU Usage */}
|
||||
<Card className="py-3">
|
||||
<CardHeader className="pb-2 px-4">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
@@ -137,7 +135,6 @@ export function ContainerStats({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Memory Usage */}
|
||||
<Card className="py-3">
|
||||
<CardHeader className="pb-2 px-4">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
@@ -168,7 +165,6 @@ export function ContainerStats({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Network I/O */}
|
||||
<Card className="py-3">
|
||||
<CardHeader className="pb-2 px-4">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
@@ -194,7 +190,6 @@ export function ContainerStats({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Block I/O */}
|
||||
<Card className="py-3">
|
||||
<CardHeader className="pb-2 px-4">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
@@ -228,7 +223,6 @@ export function ContainerStats({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Container Info */}
|
||||
<Card className="md:col-span-2 py-3">
|
||||
<CardHeader className="pb-2 px-4">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
|
||||
@@ -59,18 +59,16 @@ export function LogViewer({
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
// Auto-refresh
|
||||
React.useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchLogs();
|
||||
}, 3000); // Refresh every 3 seconds
|
||||
}, 3000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [autoRefresh, fetchLogs]);
|
||||
|
||||
// Auto-scroll to bottom when new logs arrive
|
||||
React.useEffect(() => {
|
||||
if (autoRefresh && logsEndRef.current) {
|
||||
logsEndRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
@@ -115,11 +113,9 @@ export function LogViewer({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-3">
|
||||
{/* Controls */}
|
||||
<Card className="py-3">
|
||||
<CardContent className="px-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{/* Tail Lines */}
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="tail-lines" className="mb-1">
|
||||
Lines to show
|
||||
@@ -138,7 +134,6 @@ export function LogViewer({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="timestamps" className="mb-1">
|
||||
Show Timestamps
|
||||
@@ -155,7 +150,6 @@ export function LogViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto Refresh */}
|
||||
<div className="flex flex-col">
|
||||
<Label htmlFor="auto-refresh" className="mb-1">
|
||||
Auto Refresh
|
||||
@@ -172,7 +166,6 @@ export function LogViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col">
|
||||
<Label className="mb-1">Actions</Label>
|
||||
<div className="flex gap-2 h-10">
|
||||
@@ -206,7 +199,6 @@ export function LogViewer({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search Filter */}
|
||||
<div className="mt-2">
|
||||
<div className="relative">
|
||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
@@ -222,7 +214,6 @@ export function LogViewer({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Logs Display */}
|
||||
<Card className="flex-1 overflow-hidden py-0">
|
||||
<CardContent className="p-0 h-full">
|
||||
{isLoading && !logs ? (
|
||||
|
||||
@@ -60,7 +60,6 @@ export function TerminalWindow({
|
||||
|
||||
const handleMaximize = () => {
|
||||
maximizeWindow(windowId);
|
||||
// Trigger resize after maximize/restore
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
|
||||
|
||||
// Auto-switch terminal theme based on app theme when using "termix" (default)
|
||||
const isDarkMode =
|
||||
appTheme === "dark" ||
|
||||
(appTheme === "system" &&
|
||||
@@ -108,7 +107,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
let themeColors;
|
||||
if (config.theme === "termix") {
|
||||
// Auto-switch between termixDark and termixLight based on app theme
|
||||
themeColors = isDarkMode
|
||||
? TERMINAL_THEMES.termixDark.colors
|
||||
: TERMINAL_THEMES.termixLight.colors;
|
||||
@@ -679,7 +677,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "data") {
|
||||
if (typeof msg.data === "string") {
|
||||
// Apply syntax highlighting if enabled (BETA - defaults to false/off)
|
||||
const syntaxHighlightingEnabled =
|
||||
localStorage.getItem("terminalSyntaxHighlighting") === "true";
|
||||
|
||||
@@ -688,7 +685,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
: msg.data;
|
||||
|
||||
terminal.write(outputData);
|
||||
// Sudo password prompt detection
|
||||
const sudoPasswordPattern =
|
||||
/(?:\[sudo\] password for \S+:|sudo: a password is required)/;
|
||||
const passwordToFill =
|
||||
@@ -724,7 +720,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}, 15000);
|
||||
}
|
||||
} else {
|
||||
// Apply syntax highlighting to non-string data as well (BETA - defaults to false/off)
|
||||
const syntaxHighlightingEnabled =
|
||||
localStorage.getItem("terminalSyntaxHighlighting") === "true";
|
||||
|
||||
@@ -799,7 +794,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
|
||||
// Send all environment variables immediately without delays
|
||||
if (
|
||||
terminalConfig.environmentVariables &&
|
||||
terminalConfig.environmentVariables.length > 0
|
||||
@@ -816,7 +810,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// Send startup snippet immediately after env vars
|
||||
if (terminalConfig.startupSnippetId) {
|
||||
try {
|
||||
const snippets = await getSnippets();
|
||||
@@ -837,7 +830,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}
|
||||
|
||||
// Execute mosh command immediately if enabled
|
||||
if (terminalConfig.autoMosh && ws.readyState === 1) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
@@ -1019,8 +1011,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
setTimeout(() => {
|
||||
terminal?.focus();
|
||||
}, 50);
|
||||
|
||||
console.log(`[Autocomplete] ${currentCmd} → ${selectedCommand}`);
|
||||
},
|
||||
[terminal, updateCurrentCommand],
|
||||
);
|
||||
@@ -1043,8 +1033,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
autocompleteHistory.current = autocompleteHistory.current.filter(
|
||||
(cmd) => cmd !== command,
|
||||
);
|
||||
|
||||
console.log(`[Terminal] Command deleted from history: ${command}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete command from history:", error);
|
||||
}
|
||||
@@ -1064,7 +1052,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
...hostConfig.terminalConfig,
|
||||
};
|
||||
|
||||
// Auto-switch terminal theme based on app theme when using "termix" (default)
|
||||
let themeColors;
|
||||
if (config.theme === "termix") {
|
||||
themeColors = isDarkMode
|
||||
@@ -1142,10 +1129,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
terminal.open(xtermRef.current);
|
||||
|
||||
// Immediately fit to establish correct dimensions
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal.cols < 10 || terminal.rows < 3) {
|
||||
// Terminal opened with invalid dimensions, retry fit in next frame
|
||||
requestAnimationFrame(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
});
|
||||
@@ -1448,14 +1433,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
terminal.attachCustomKeyEventHandler(handleCustomKey);
|
||||
}, [terminal]);
|
||||
|
||||
// Connection initialization effect
|
||||
useEffect(() => {
|
||||
if (!terminal || !hostConfig || !isVisible) return;
|
||||
if (isConnected || isConnecting) return;
|
||||
|
||||
// Ensure terminal has valid dimensions before connecting
|
||||
if (terminal.cols < 10 || terminal.rows < 3) {
|
||||
// Wait for next frame when dimensions will be valid
|
||||
requestAnimationFrame(() => {
|
||||
if (terminal.cols > 0 && terminal.rows > 0) {
|
||||
setIsConnecting(true);
|
||||
@@ -1475,7 +1457,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}, [terminal, hostConfig, isVisible, isConnected, isConnecting]);
|
||||
|
||||
// Consolidated fitting and focus effect
|
||||
useEffect(() => {
|
||||
if (!terminal || !fitAddonRef.current || !isVisible) return;
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ export function TerminalPreview({
|
||||
}: TerminalPreviewProps) {
|
||||
const { theme: appTheme } = useTheme();
|
||||
|
||||
// Resolve "termix" to termixDark or termixLight based on app theme
|
||||
const resolvedTheme =
|
||||
theme === "termix"
|
||||
? appTheme === "dark" ||
|
||||
|
||||
@@ -132,15 +132,12 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
|
||||
try {
|
||||
if (action === "connect") {
|
||||
// Try to find endpoint host in user's accessible hosts
|
||||
const endpointHost = allHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
|
||||
// For shared users who don't have access to endpoint host,
|
||||
// send a minimal config and let backend resolve endpoint details
|
||||
const tunnelConfig = {
|
||||
name: tunnelName,
|
||||
sourceHostId: host.id,
|
||||
@@ -190,20 +187,6 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
socks5Password: host.socks5Password,
|
||||
socks5ProxyChain: host.socks5ProxyChain,
|
||||
};
|
||||
|
||||
console.log("Tunnel connect config:", {
|
||||
tunnelName,
|
||||
sourceHostId: tunnelConfig.sourceHostId,
|
||||
sourceCredentialId: tunnelConfig.sourceCredentialId,
|
||||
sourceUserId: tunnelConfig.sourceUserId,
|
||||
hasSourcePassword: !!tunnelConfig.sourcePassword,
|
||||
hasSourceKey: !!tunnelConfig.sourceSSHKey,
|
||||
hasEndpointHost: !!endpointHost,
|
||||
endpointHost: tunnel.endpointHost,
|
||||
isShared: (host as any).isShared,
|
||||
ownerId: (host as any).ownerId,
|
||||
});
|
||||
|
||||
await connectTunnel(tunnelConfig);
|
||||
} else if (action === "disconnect") {
|
||||
await disconnectTunnel(tunnelName);
|
||||
|
||||
@@ -42,7 +42,6 @@ export function CredentialEditor({
|
||||
const { t } = useTranslation();
|
||||
const { theme: appTheme } = useTheme();
|
||||
|
||||
// Determine CodeMirror theme based on app theme
|
||||
const isDarkMode =
|
||||
appTheme === "dark" ||
|
||||
(appTheme === "system" &&
|
||||
|
||||
@@ -30,6 +30,8 @@ export function HostManager({
|
||||
hostConfig || null,
|
||||
);
|
||||
|
||||
useEffect(() => {}, [editingHost]);
|
||||
|
||||
const [editingCredential, setEditingCredential] = useState<{
|
||||
id: number;
|
||||
name?: string;
|
||||
@@ -39,30 +41,27 @@ export function HostManager({
|
||||
const ignoreNextHostConfigChangeRef = useRef<boolean>(false);
|
||||
const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
|
||||
|
||||
// Sync state when tab is updated externally (via updateTab or addTab)
|
||||
useEffect(() => {
|
||||
// Always sync on timestamp changes
|
||||
if (_updateTimestamp !== undefined) {
|
||||
// Update activeTab if initialTab has changed
|
||||
if (initialTab && initialTab !== activeTab) {
|
||||
setActiveTab(initialTab);
|
||||
}
|
||||
|
||||
// Update editingHost if hostConfig has changed
|
||||
if (hostConfig && hostConfig.id !== editingHost?.id) {
|
||||
setEditingHost(hostConfig);
|
||||
lastProcessedHostIdRef.current = hostConfig.id;
|
||||
} else if (!hostConfig && editingHost) {
|
||||
// Clear editingHost if hostConfig is now undefined
|
||||
} else if (
|
||||
!hostConfig &&
|
||||
editingHost &&
|
||||
editingHost.id !== lastProcessedHostIdRef.current
|
||||
) {
|
||||
setEditingHost(null);
|
||||
}
|
||||
|
||||
// Clear editingCredential if switching away from add_credential
|
||||
if (initialTab !== "add_credential" && editingCredential) {
|
||||
setEditingCredential(null);
|
||||
}
|
||||
} else {
|
||||
// Initial mount - set state from props
|
||||
if (initialTab) {
|
||||
setActiveTab(initialTab);
|
||||
}
|
||||
@@ -78,7 +77,6 @@ export function HostManager({
|
||||
setActiveTab("add_host");
|
||||
lastProcessedHostIdRef.current = host.id;
|
||||
|
||||
// Persist to tab context
|
||||
if (updateTab && currentTabId !== undefined) {
|
||||
updateTab(currentTabId, { initialTab: "add_host" });
|
||||
}
|
||||
@@ -101,7 +99,6 @@ export function HostManager({
|
||||
setEditingCredential(credential);
|
||||
setActiveTab("add_credential");
|
||||
|
||||
// Persist to tab context
|
||||
if (updateTab && currentTabId !== undefined) {
|
||||
updateTab(currentTabId, { initialTab: "add_credential" });
|
||||
}
|
||||
@@ -121,7 +118,6 @@ export function HostManager({
|
||||
}
|
||||
setActiveTab(value);
|
||||
|
||||
// Persist to tab context
|
||||
if (updateTab && currentTabId !== undefined) {
|
||||
updateTab(currentTabId, { initialTab: value });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -363,7 +363,6 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
),
|
||||
);
|
||||
|
||||
// Wrap in hosts array for valid import format
|
||||
const exportFormat = {
|
||||
hosts: [cleanExportData],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import type { HostDockerTabProps } from "./shared/tab-types";
|
||||
|
||||
export function HostDockerTab({ form, t }: HostDockerTabProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableDocker"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.enableDocker")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>{t("hosts.enableDockerDesc")}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import type { HostFileManagerTabProps } from "./shared/tab-types";
|
||||
|
||||
export function HostFileManagerTab({ form, t }: HostFileManagerTabProps) {
|
||||
return (
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableFileManager"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.enableFileManager")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.enableFileManagerDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("enableFileManager") && (
|
||||
<div className="mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="defaultPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.defaultPath")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.homePath")}
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t("hosts.defaultPathDesc")}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1047
src/ui/desktop/apps/host-manager/hosts/tabs/HostGeneralTab.tsx
Normal file
1047
src/ui/desktop/apps/host-manager/hosts/tabs/HostGeneralTab.tsx
Normal file
File diff suppressed because it is too large
Load Diff
560
src/ui/desktop/apps/host-manager/hosts/tabs/HostSharingTab.tsx
Normal file
560
src/ui/desktop/apps/host-manager/hosts/tabs/HostSharingTab.tsx
Normal file
@@ -0,0 +1,560 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getRoles,
|
||||
getUserList,
|
||||
getUserInfo,
|
||||
shareHost,
|
||||
getHostAccess,
|
||||
revokeHostAccess,
|
||||
getSSHHostById,
|
||||
type Role,
|
||||
type AccessRecord,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command.tsx";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover.tsx";
|
||||
import {
|
||||
Plus,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
AlertCircle,
|
||||
Trash2,
|
||||
Users,
|
||||
Shield,
|
||||
Clock,
|
||||
UserCircle,
|
||||
} from "lucide-react";
|
||||
import type { SSHHost } from "@/types";
|
||||
import type { HostSharingTabProps } from "./shared/tab-types";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
interface HostSharingTabProps {
|
||||
hostId: number | undefined;
|
||||
isNewHost: boolean;
|
||||
}
|
||||
|
||||
export function HostSharingTab({
|
||||
hostId,
|
||||
isNewHost,
|
||||
}: SharingTabContentProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
|
||||
const [shareType, setShareType] = React.useState<"user" | "role">("user");
|
||||
const [selectedUserId, setSelectedUserId] = React.useState<string>("");
|
||||
const [selectedRoleId, setSelectedRoleId] = React.useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const [permissionLevel, setPermissionLevel] = React.useState("view");
|
||||
const [expiresInHours, setExpiresInHours] = React.useState<string>("");
|
||||
|
||||
const [roles, setRoles] = React.useState<Role[]>([]);
|
||||
const [users, setUsers] = React.useState<User[]>([]);
|
||||
const [accessList, setAccessList] = React.useState<AccessRecord[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [currentUserId, setCurrentUserId] = React.useState<string>("");
|
||||
const [hostData, setHostData] = React.useState<SSHHost | null>(null);
|
||||
|
||||
const [userComboOpen, setUserComboOpen] = React.useState(false);
|
||||
const [roleComboOpen, setRoleComboOpen] = React.useState(false);
|
||||
|
||||
const loadRoles = React.useCallback(async () => {
|
||||
try {
|
||||
const response = await getRoles();
|
||||
setRoles(response.roles || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load roles:", error);
|
||||
setRoles([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUsers = React.useCallback(async () => {
|
||||
try {
|
||||
const response = await getUserList();
|
||||
const mappedUsers = (response.users || []).map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
is_admin: user.is_admin,
|
||||
}));
|
||||
setUsers(mappedUsers);
|
||||
} catch (error) {
|
||||
console.error("Failed to load users:", error);
|
||||
setUsers([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAccessList = React.useCallback(async () => {
|
||||
if (!hostId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await getHostAccess(hostId);
|
||||
setAccessList(response.accessList || []);
|
||||
} catch (error) {
|
||||
console.error("Failed to load access list:", error);
|
||||
setAccessList([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [hostId]);
|
||||
|
||||
const loadHostData = React.useCallback(async () => {
|
||||
if (!hostId) return;
|
||||
|
||||
try {
|
||||
const host = await getSSHHostById(hostId);
|
||||
setHostData(host);
|
||||
} catch (error) {
|
||||
console.error("Failed to load host data:", error);
|
||||
setHostData(null);
|
||||
}
|
||||
}, [hostId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
loadRoles();
|
||||
loadUsers();
|
||||
if (!isNewHost) {
|
||||
loadAccessList();
|
||||
loadHostData();
|
||||
}
|
||||
}, [loadRoles, loadUsers, loadAccessList, loadHostData, isNewHost]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchCurrentUser = async () => {
|
||||
try {
|
||||
const userInfo = await getUserInfo();
|
||||
setCurrentUserId(userInfo.userId);
|
||||
} catch (error) {
|
||||
console.error("Failed to load current user:", error);
|
||||
}
|
||||
};
|
||||
fetchCurrentUser();
|
||||
}, []);
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!hostId) {
|
||||
toast.error(t("rbac.saveHostFirst"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (shareType === "user" && !selectedUserId) {
|
||||
toast.error(t("rbac.selectUser"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (shareType === "role" && !selectedRoleId) {
|
||||
toast.error(t("rbac.selectRole"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (shareType === "user" && selectedUserId === currentUserId) {
|
||||
toast.error(t("rbac.cannotShareWithSelf"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await shareHost(hostId, {
|
||||
targetType: shareType,
|
||||
targetUserId: shareType === "user" ? selectedUserId : undefined,
|
||||
targetRoleId: shareType === "role" ? selectedRoleId : undefined,
|
||||
permissionLevel,
|
||||
durationHours: expiresInHours
|
||||
? parseInt(expiresInHours, 10)
|
||||
: undefined,
|
||||
});
|
||||
|
||||
toast.success(t("rbac.sharedSuccessfully"));
|
||||
setSelectedUserId("");
|
||||
setSelectedRoleId(null);
|
||||
setExpiresInHours("");
|
||||
loadAccessList();
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToShare"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (accessId: number) => {
|
||||
if (!hostId) return;
|
||||
|
||||
const confirmed = await confirmWithToast({
|
||||
title: t("rbac.confirmRevokeAccess"),
|
||||
description: t("rbac.confirmRevokeAccessDescription"),
|
||||
confirmText: t("common.revoke"),
|
||||
cancelText: t("common.cancel"),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
await revokeHostAccess(hostId, accessId);
|
||||
toast.success(t("rbac.accessRevokedSuccessfully"));
|
||||
loadAccessList();
|
||||
} catch (error) {
|
||||
toast.error(t("rbac.failedToRevokeAccess"));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "-";
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const isExpired = (expiresAt: string | null) => {
|
||||
if (!expiresAt) return false;
|
||||
return new Date(expiresAt) < new Date();
|
||||
};
|
||||
|
||||
const availableUsers = React.useMemo(() => {
|
||||
return users.filter((user) => user.id !== currentUserId);
|
||||
}, [users, currentUserId]);
|
||||
|
||||
const selectedUser = availableUsers.find((u) => u.id === selectedUserId);
|
||||
const selectedRole = roles.find((r) => r.id === selectedRoleId);
|
||||
|
||||
if (isNewHost) {
|
||||
return (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("rbac.saveHostFirst")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("rbac.saveHostFirstDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!hostData?.credentialId && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>{t("rbac.credentialRequired")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("rbac.credentialRequiredDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{hostData?.credentialId && (
|
||||
<>
|
||||
<div className="space-y-4 border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Plus className="h-5 w-5" />
|
||||
{t("rbac.shareHost")}
|
||||
</h3>
|
||||
|
||||
<Tabs
|
||||
value={shareType}
|
||||
onValueChange={(v) => setShareType(v as "user" | "role")}
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="user" className="flex items-center gap-2">
|
||||
<UserCircle className="h-4 w-4" />
|
||||
{t("rbac.shareWithUser")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="role" className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
{t("rbac.shareWithRole")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="user" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="user-select">{t("rbac.selectUser")}</label>
|
||||
<Popover open={userComboOpen} onOpenChange={setUserComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={userComboOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedUser
|
||||
? `${selectedUser.username}${selectedUser.is_admin ? " (Admin)" : ""}`
|
||||
: t("rbac.selectUserPlaceholder")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("rbac.searchUsers")} />
|
||||
<CommandEmpty>{t("rbac.noUserFound")}</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
|
||||
{availableUsers.map((user) => (
|
||||
<CommandItem
|
||||
key={user.id}
|
||||
value={`${user.username} ${user.id}`}
|
||||
onSelect={() => {
|
||||
setSelectedUserId(user.id);
|
||||
setUserComboOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedUserId === user.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{user.username}
|
||||
{user.is_admin ? " (Admin)" : ""}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="role" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="role-select">{t("rbac.selectRole")}</label>
|
||||
<Popover open={roleComboOpen} onOpenChange={setRoleComboOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={roleComboOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedRole
|
||||
? `${t(selectedRole.displayName)}${selectedRole.isSystem ? ` (${t("rbac.systemRole")})` : ""}`
|
||||
: t("rbac.selectRolePlaceholder")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={t("rbac.searchRoles")} />
|
||||
<CommandEmpty>{t("rbac.noRoleFound")}</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
|
||||
{roles.map((role) => (
|
||||
<CommandItem
|
||||
key={role.id}
|
||||
value={`${role.displayName} ${role.name} ${role.id}`}
|
||||
onSelect={() => {
|
||||
setSelectedRoleId(role.id);
|
||||
setRoleComboOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedRoleId === role.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{t(role.displayName)}
|
||||
{role.isSystem
|
||||
? ` (${t("rbac.systemRole")})`
|
||||
: ""}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label>{t("rbac.permissionLevel")}</label>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("rbac.view")} - {t("rbac.viewDesc")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="expires-in">{t("rbac.durationHours")}</label>
|
||||
<Input
|
||||
id="expires-in"
|
||||
type="number"
|
||||
value={expiresInHours}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "" || /^\d+$/.test(value)) {
|
||||
setExpiresInHours(value);
|
||||
}
|
||||
}}
|
||||
placeholder={t("rbac.neverExpires")}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleShare}
|
||||
className="w-full"
|
||||
disabled={!hostData?.credentialId}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("rbac.share")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
{t("rbac.accessList")}
|
||||
</h3>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("rbac.type")}</TableHead>
|
||||
<TableHead>{t("rbac.target")}</TableHead>
|
||||
<TableHead>{t("rbac.permissionLevel")}</TableHead>
|
||||
<TableHead>{t("rbac.grantedBy")}</TableHead>
|
||||
<TableHead>{t("rbac.expires")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("common.loading")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : accessList.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("rbac.noAccessRecords")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
accessList.map((access) => (
|
||||
<TableRow
|
||||
key={access.id}
|
||||
className={
|
||||
isExpired(access.expiresAt) ? "opacity-50" : ""
|
||||
}
|
||||
>
|
||||
<TableCell>
|
||||
{access.targetType === "user" ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 w-fit"
|
||||
>
|
||||
<UserCircle className="h-3 w-3" />
|
||||
{t("rbac.user")}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1 w-fit"
|
||||
>
|
||||
<Shield className="h-3 w-3" />
|
||||
{t("rbac.role")}
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{access.targetType === "user"
|
||||
? access.username
|
||||
: t(access.roleDisplayName || access.roleName || "")}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{access.permissionLevel}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{access.grantedByUsername}</TableCell>
|
||||
<TableCell>
|
||||
{access.expiresAt ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span
|
||||
className={
|
||||
isExpired(access.expiresAt)
|
||||
? "text-red-500"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{formatDate(access.expiresAt)}
|
||||
{isExpired(access.expiresAt) && (
|
||||
<span className="ml-2">
|
||||
({t("rbac.expired")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
t("rbac.never")
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRevoke(access.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import { Plus, X } from "lucide-react";
|
||||
import type { HostStatisticsTabProps } from "./shared/tab-types";
|
||||
import { QuickActionItem } from "./shared/QuickActionItem";
|
||||
|
||||
export function HostStatisticsTab({
|
||||
form,
|
||||
statusIntervalUnit,
|
||||
setStatusIntervalUnit,
|
||||
metricsIntervalUnit,
|
||||
setMetricsIntervalUnit,
|
||||
snippets,
|
||||
t,
|
||||
}: HostStatisticsTabProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() =>
|
||||
window.open("https://docs.termix.site/server-stats", "_blank")
|
||||
}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="statsConfig.statusCheckEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t("hosts.statusCheckEnabled")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.statusCheckEnabledDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("statsConfig.statusCheckEnabled") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="statsConfig.statusCheckInterval"
|
||||
render={({ field }) => {
|
||||
const displayValue =
|
||||
statusIntervalUnit === "minutes"
|
||||
? Math.round((field.value || 30) / 60)
|
||||
: field.value || 30;
|
||||
|
||||
const handleIntervalChange = (value: string) => {
|
||||
const numValue = parseInt(value) || 0;
|
||||
const seconds =
|
||||
statusIntervalUnit === "minutes" ? numValue * 60 : numValue;
|
||||
field.onChange(seconds);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.statusCheckInterval")}</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
value={displayValue}
|
||||
onChange={(e) => handleIntervalChange(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
<Select
|
||||
value={statusIntervalUnit}
|
||||
onValueChange={(value: "seconds" | "minutes") => {
|
||||
setStatusIntervalUnit(value);
|
||||
const currentSeconds = field.value || 30;
|
||||
if (value === "minutes") {
|
||||
const minutes = Math.round(currentSeconds / 60);
|
||||
field.onChange(minutes * 60);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="seconds">
|
||||
{t("hosts.intervalSeconds")}
|
||||
</SelectItem>
|
||||
<SelectItem value="minutes">
|
||||
{t("hosts.intervalMinutes")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t("hosts.statusCheckIntervalDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="statsConfig.metricsEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t("hosts.metricsEnabled")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.metricsEnabledDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("statsConfig.metricsEnabled") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="statsConfig.metricsInterval"
|
||||
render={({ field }) => {
|
||||
const displayValue =
|
||||
metricsIntervalUnit === "minutes"
|
||||
? Math.round((field.value || 30) / 60)
|
||||
: field.value || 30;
|
||||
|
||||
const handleIntervalChange = (value: string) => {
|
||||
const numValue = parseInt(value) || 0;
|
||||
const seconds =
|
||||
metricsIntervalUnit === "minutes"
|
||||
? numValue * 60
|
||||
: numValue;
|
||||
field.onChange(seconds);
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.metricsInterval")}</FormLabel>
|
||||
<div className="flex gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
value={displayValue}
|
||||
onChange={(e) => handleIntervalChange(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
<Select
|
||||
value={metricsIntervalUnit}
|
||||
onValueChange={(value: "seconds" | "minutes") => {
|
||||
setMetricsIntervalUnit(value);
|
||||
const currentSeconds = field.value || 30;
|
||||
if (value === "minutes") {
|
||||
const minutes = Math.round(currentSeconds / 60);
|
||||
field.onChange(minutes * 60);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="seconds">
|
||||
{t("hosts.intervalSeconds")}
|
||||
</SelectItem>
|
||||
<SelectItem value="minutes">
|
||||
{t("hosts.intervalMinutes")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t("hosts.metricsIntervalDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.watch("statsConfig.metricsEnabled") && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="statsConfig.enabledWidgets"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.enabledWidgets")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.enabledWidgetsDesc")}
|
||||
</FormDescription>
|
||||
<div className="space-y-3 mt-3">
|
||||
{(
|
||||
[
|
||||
"cpu",
|
||||
"memory",
|
||||
"disk",
|
||||
"network",
|
||||
"uptime",
|
||||
"processes",
|
||||
"system",
|
||||
"login_stats",
|
||||
] as const
|
||||
).map((widget) => (
|
||||
<div key={widget} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={field.value?.includes(widget)}
|
||||
onCheckedChange={(checked) => {
|
||||
const currentWidgets = field.value || [];
|
||||
if (checked) {
|
||||
field.onChange([...currentWidgets, widget]);
|
||||
} else {
|
||||
field.onChange(
|
||||
currentWidgets.filter((w) => w !== widget),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
|
||||
{widget === "cpu" && t("serverStats.cpuUsage")}
|
||||
{widget === "memory" && t("serverStats.memoryUsage")}
|
||||
{widget === "disk" && t("serverStats.diskUsage")}
|
||||
{widget === "network" &&
|
||||
t("serverStats.networkInterfaces")}
|
||||
{widget === "uptime" && t("serverStats.uptime")}
|
||||
{widget === "processes" && t("serverStats.processes")}
|
||||
{widget === "system" && t("serverStats.systemInfo")}
|
||||
{widget === "login_stats" &&
|
||||
t("serverStats.loginStats")}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t("hosts.quickActions")}</h3>
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
{t("hosts.quickActionsDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="quickActions"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.quickActionsList")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-3">
|
||||
{field.value.map((quickAction, index) => (
|
||||
<QuickActionItem
|
||||
key={index}
|
||||
quickAction={quickAction}
|
||||
index={index}
|
||||
snippets={snippets}
|
||||
onUpdate={(name, snippetId) => {
|
||||
const newQuickActions = [...field.value];
|
||||
newQuickActions[index] = {
|
||||
name,
|
||||
snippetId,
|
||||
};
|
||||
field.onChange(newQuickActions);
|
||||
}}
|
||||
onRemove={() => {
|
||||
const newQuickActions = field.value.filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
field.onChange(newQuickActions);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
field.onChange([
|
||||
...field.value,
|
||||
{ name: "", snippetId: 0 },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("hosts.addQuickAction")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormDescription>{t("hosts.quickActionsOrder")}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
767
src/ui/desktop/apps/host-manager/hosts/tabs/HostTerminalTab.tsx
Normal file
767
src/ui/desktop/apps/host-manager/hosts/tabs/HostTerminalTab.tsx
Normal file
@@ -0,0 +1,767 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion.tsx";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover.tsx";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command.tsx";
|
||||
import { Slider } from "@/components/ui/slider.tsx";
|
||||
import { PasswordInput } from "@/components/ui/password-input.tsx";
|
||||
import { Check, ChevronsUpDown, Plus, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
TERMINAL_FONTS,
|
||||
CURSOR_STYLES,
|
||||
BELL_STYLES,
|
||||
FAST_SCROLL_MODIFIERS,
|
||||
} from "@/constants/terminal-themes.ts";
|
||||
import { TerminalPreview } from "@/ui/desktop/apps/features/terminal/TerminalPreview.tsx";
|
||||
import type { HostTerminalTabProps } from "./shared/tab-types";
|
||||
import React from "react";
|
||||
|
||||
export function HostTerminalTab({ form, snippets, t }: HostTerminalTabProps) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableTerminal"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.enableTerminal")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>{t("hosts.enableTerminalDesc")}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<h1 className="text-xl font-semibold mt-7">
|
||||
{t("hosts.terminalCustomization")}
|
||||
</h1>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
className="w-full"
|
||||
defaultValue={["appearance", "behavior", "advanced"]}
|
||||
>
|
||||
<AccordionItem value="appearance">
|
||||
<AccordionTrigger>{t("hosts.appearance")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("hosts.themePreview")}
|
||||
</label>
|
||||
<TerminalPreview
|
||||
theme={form.watch("terminalConfig.theme")}
|
||||
fontSize={form.watch("terminalConfig.fontSize")}
|
||||
fontFamily={form.watch("terminalConfig.fontFamily")}
|
||||
cursorStyle={form.watch("terminalConfig.cursorStyle")}
|
||||
cursorBlink={form.watch("terminalConfig.cursorBlink")}
|
||||
letterSpacing={form.watch("terminalConfig.letterSpacing")}
|
||||
lineHeight={form.watch("terminalConfig.lineHeight")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.theme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.theme")}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("hosts.selectTheme")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{Object.entries(TERMINAL_THEMES).map(([key, theme]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t("hosts.chooseColorTheme")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.fontFamily"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.fontFamily")}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("hosts.selectFont")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{TERMINAL_FONTS.map((font) => (
|
||||
<SelectItem key={font.value} value={font.value}>
|
||||
{font.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>{t("hosts.selectFontDesc")}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.fontSize"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("hosts.fontSizeValue", {
|
||||
value: field.value,
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={8}
|
||||
max={24}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) => field.onChange(value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>{t("hosts.adjustFontSize")}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.letterSpacing"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("hosts.letterSpacingValue", {
|
||||
value: field.value,
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={-2}
|
||||
max={10}
|
||||
step={0.5}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) => field.onChange(value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.adjustLetterSpacing")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.lineHeight"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("hosts.lineHeightValue", {
|
||||
value: field.value,
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={1}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) => field.onChange(value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.adjustLineHeight")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.cursorStyle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.cursorStyle")}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("hosts.selectCursorStyle")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="block">
|
||||
{t("hosts.cursorStyleBlock")}
|
||||
</SelectItem>
|
||||
<SelectItem value="underline">
|
||||
{t("hosts.cursorStyleUnderline")}
|
||||
</SelectItem>
|
||||
<SelectItem value="bar">
|
||||
{t("hosts.cursorStyleBar")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t("hosts.chooseCursorAppearance")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.cursorBlink"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t("hosts.cursorBlink")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.enableCursorBlink")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="behavior">
|
||||
<AccordionTrigger>{t("hosts.behavior")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.scrollback"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("hosts.scrollbackBufferValue", {
|
||||
value: field.value,
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={1000}
|
||||
max={100000}
|
||||
step={1000}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) => field.onChange(value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.scrollbackBufferDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.bellStyle"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.bellStyle")}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("hosts.selectBellStyle")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("hosts.bellStyleNone")}
|
||||
</SelectItem>
|
||||
<SelectItem value="sound">
|
||||
{t("hosts.bellStyleSound")}
|
||||
</SelectItem>
|
||||
<SelectItem value="visual">
|
||||
{t("hosts.bellStyleVisual")}
|
||||
</SelectItem>
|
||||
<SelectItem value="both">
|
||||
{t("hosts.bellStyleBoth")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>{t("hosts.bellStyleDesc")}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.rightClickSelectsWord"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t("hosts.rightClickSelectsWord")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.rightClickSelectsWordDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.fastScrollModifier"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.fastScrollModifier")}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("hosts.selectModifier")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="alt">
|
||||
{t("hosts.modifierAlt")}
|
||||
</SelectItem>
|
||||
<SelectItem value="ctrl">
|
||||
{t("hosts.modifierCtrl")}
|
||||
</SelectItem>
|
||||
<SelectItem value="shift">
|
||||
{t("hosts.modifierShift")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t("hosts.fastScrollModifierDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.fastScrollSensitivity"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("hosts.fastScrollSensitivityValue", {
|
||||
value: field.value,
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) => field.onChange(value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.fastScrollSensitivityDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.minimumContrastRatio"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("hosts.minimumContrastRatioValue", {
|
||||
value: field.value,
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Slider
|
||||
min={1}
|
||||
max={21}
|
||||
step={1}
|
||||
value={[field.value]}
|
||||
onValueChange={([value]) => field.onChange(value)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.minimumContrastRatioDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="advanced">
|
||||
<AccordionTrigger>{t("hosts.advanced")}</AccordionTrigger>
|
||||
<AccordionContent className="space-y-4 pt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.agentForwarding"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t("hosts.sshAgentForwarding")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.sshAgentForwardingDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.backspaceMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.backspaceMode")}</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("hosts.selectBackspaceMode")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="normal">
|
||||
{t("hosts.backspaceModeNormal")}
|
||||
</SelectItem>
|
||||
<SelectItem value="control-h">
|
||||
{t("hosts.backspaceModeControlH")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t("hosts.backspaceModeDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.startupSnippetId"
|
||||
render={({ field }) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const selectedSnippet = snippets.find(
|
||||
(s) => s.id === field.value,
|
||||
);
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.startupSnippet")}</FormLabel>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedSnippet
|
||||
? selectedSnippet.name
|
||||
: t("hosts.selectSnippet")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{
|
||||
width: "var(--radix-popover-trigger-width)",
|
||||
}}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("hosts.searchSnippets")}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
{t("hosts.noSnippetFound")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-y-auto thin-scrollbar">
|
||||
<CommandItem
|
||||
value="none"
|
||||
onSelect={() => {
|
||||
field.onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!field.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{t("hosts.snippetNone")}
|
||||
</CommandItem>
|
||||
{snippets.map((snippet) => (
|
||||
<CommandItem
|
||||
key={snippet.id}
|
||||
value={`${snippet.name} ${snippet.content} ${snippet.id}`}
|
||||
onSelect={() => {
|
||||
field.onChange(snippet.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
field.value === snippet.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">
|
||||
{snippet.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-[350px]">
|
||||
{snippet.content}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormDescription>
|
||||
{t("hosts.executeSnippetOnConnect")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.autoMosh"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 bg-elevated dark:bg-input/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t("hosts.autoMosh")}</FormLabel>
|
||||
<FormDescription>{t("hosts.autoMoshDesc")}</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("terminalConfig.autoMosh") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.moshCommand"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.moshCommand")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.moshCommand")}
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.moshCommandDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.sudoPasswordAutoFill"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t("hosts.sudoPasswordAutoFill")}</FormLabel>
|
||||
<FormDescription>
|
||||
{t("hosts.sudoPasswordAutoFillDesc")}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("terminalConfig.sudoPasswordAutoFill") && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="terminalConfig.sudoPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.sudoPassword")}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t("placeholders.sudoPassword")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.sudoPasswordDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">
|
||||
{t("hosts.environmentVariables")}
|
||||
</label>
|
||||
<FormDescription>
|
||||
{t("hosts.environmentVariablesDesc")}
|
||||
</FormDescription>
|
||||
{form
|
||||
.watch("terminalConfig.environmentVariables")
|
||||
?.map((_, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`terminalConfig.environmentVariables.${index}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("hosts.variableName")}
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`terminalConfig.environmentVariables.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("hosts.variableValue")}
|
||||
{...field}
|
||||
onBlur={(e) => {
|
||||
field.onChange(e.target.value.trim());
|
||||
field.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const current = form.getValues(
|
||||
"terminalConfig.environmentVariables",
|
||||
);
|
||||
form.setValue(
|
||||
"terminalConfig.environmentVariables",
|
||||
current.filter((_, i) => i !== index),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const current =
|
||||
form.getValues("terminalConfig.environmentVariables") || [];
|
||||
form.setValue("terminalConfig.environmentVariables", [
|
||||
...current,
|
||||
{ key: "", value: "" },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t("hosts.addVariable")}
|
||||
</Button>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
361
src/ui/desktop/apps/host-manager/hosts/tabs/HostTunnelTab.tsx
Normal file
361
src/ui/desktop/apps/host-manager/hosts/tabs/HostTunnelTab.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Switch } from "@/components/ui/switch.tsx";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import type { HostTunnelTabProps } from "./shared/tab-types";
|
||||
|
||||
export function HostTunnelTab({
|
||||
form,
|
||||
sshConfigDropdownOpen,
|
||||
setSshConfigDropdownOpen,
|
||||
sshConfigInputRefs,
|
||||
sshConfigDropdownRefs,
|
||||
getFilteredSshConfigs,
|
||||
handleSshConfigClick,
|
||||
t,
|
||||
}: HostTunnelTabProps) {
|
||||
return (
|
||||
<div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enableTunnel"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.enableTunnel")}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
<FormDescription>{t("hosts.enableTunnelDesc")}</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.watch("enableTunnel") && (
|
||||
<>
|
||||
<Alert className="mt-4">
|
||||
<AlertDescription>
|
||||
<strong>{t("hosts.sshpassRequired")}</strong>
|
||||
<div>
|
||||
{t("hosts.sshpassRequiredDesc")}{" "}
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
sudo apt install sshpass
|
||||
</code>{" "}
|
||||
{t("hosts.debianUbuntuEquivalent")}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<strong>{t("hosts.otherInstallMethods")}</strong>
|
||||
<div>
|
||||
• {t("hosts.centosRhelFedora")}{" "}
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
sudo yum install sshpass
|
||||
</code>{" "}
|
||||
{t("hosts.or")}{" "}
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
sudo dnf install sshpass
|
||||
</code>
|
||||
</div>
|
||||
<div>
|
||||
• {t("hosts.macos")}{" "}
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
brew install hudochenkov/sshpass/sshpass
|
||||
</code>
|
||||
</div>
|
||||
<div>• {t("hosts.windows")}</div>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Alert className="mt-4">
|
||||
<AlertDescription>
|
||||
<strong>{t("hosts.sshServerConfigRequired")}</strong>
|
||||
<div>{t("hosts.sshServerConfigDesc")}</div>
|
||||
<div>
|
||||
•{" "}
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
GatewayPorts yes
|
||||
</code>{" "}
|
||||
{t("hosts.gatewayPortsYes")}
|
||||
</div>
|
||||
<div>
|
||||
•{" "}
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
AllowTcpForwarding yes
|
||||
</code>{" "}
|
||||
{t("hosts.allowTcpForwardingYes")}
|
||||
</div>
|
||||
<div>
|
||||
•{" "}
|
||||
<code className="bg-muted px-1 rounded inline">
|
||||
PermitRootLogin yes
|
||||
</code>{" "}
|
||||
{t("hosts.permitRootLoginYes")}
|
||||
</div>
|
||||
<div className="mt-2">{t("hosts.editSshConfig")}</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="mt-3 flex justify-between">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={() =>
|
||||
window.open("https://docs.termix.site/tunnels", "_blank")
|
||||
}
|
||||
>
|
||||
{t("common.documentation")}
|
||||
</Button>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tunnelConnections"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mt-4">
|
||||
<FormLabel>{t("hosts.tunnelConnections")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="space-y-4">
|
||||
{field.value.map((connection, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 border rounded-lg bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-bold">
|
||||
{t("hosts.connection")} {index + 1}
|
||||
</h4>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newConnections = field.value.filter(
|
||||
(_, i) => i !== index,
|
||||
);
|
||||
field.onChange(newConnections);
|
||||
}}
|
||||
>
|
||||
{t("hosts.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.sourcePort`}
|
||||
render={({ field: sourcePortField }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>
|
||||
{t("hosts.sourcePort")}
|
||||
{t("hosts.sourcePortDesc")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.defaultPort")}
|
||||
{...sourcePortField}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.endpointPort`}
|
||||
render={({ field: endpointPortField }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>{t("hosts.endpointPort")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"placeholders.defaultEndpointPort",
|
||||
)}
|
||||
{...endpointPortField}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.endpointHost`}
|
||||
render={({ field: endpointHostField }) => (
|
||||
<FormItem className="col-span-4 relative">
|
||||
<FormLabel>
|
||||
{t("hosts.endpointSshConfig")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
ref={(el) => {
|
||||
sshConfigInputRefs.current[index] = el;
|
||||
}}
|
||||
placeholder={t("placeholders.sshConfig")}
|
||||
className="min-h-[40px]"
|
||||
autoComplete="off"
|
||||
value={endpointHostField.value}
|
||||
onFocus={() =>
|
||||
setSshConfigDropdownOpen((prev) => ({
|
||||
...prev,
|
||||
[index]: true,
|
||||
}))
|
||||
}
|
||||
onChange={(e) => {
|
||||
endpointHostField.onChange(e);
|
||||
setSshConfigDropdownOpen((prev) => ({
|
||||
...prev,
|
||||
[index]: true,
|
||||
}));
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
endpointHostField.onChange(
|
||||
e.target.value.trim(),
|
||||
);
|
||||
endpointHostField.onBlur();
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{sshConfigDropdownOpen[index] &&
|
||||
getFilteredSshConfigs(index).length > 0 && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
sshConfigDropdownRefs.current[index] =
|
||||
el;
|
||||
}}
|
||||
className="absolute top-full left-0 z-50 mt-1 w-full bg-canvas border border-input rounded-md shadow-lg max-h-40 overflow-y-auto thin-scrollbar p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
{getFilteredSshConfigs(index).map(
|
||||
(config) => (
|
||||
<Button
|
||||
key={config}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
|
||||
onClick={() =>
|
||||
handleSshConfigClick(
|
||||
config,
|
||||
index,
|
||||
)
|
||||
}
|
||||
>
|
||||
{config}
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{t("hosts.tunnelForwardDescription", {
|
||||
sourcePort:
|
||||
form.watch(
|
||||
`tunnelConnections.${index}.sourcePort`,
|
||||
) || "22",
|
||||
endpointPort:
|
||||
form.watch(
|
||||
`tunnelConnections.${index}.endpointPort`,
|
||||
) || "224",
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-12 gap-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.maxRetries`}
|
||||
render={({ field: maxRetriesField }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>{t("hosts.maxRetries")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.maxRetries")}
|
||||
{...maxRetriesField}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.maxRetriesDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.retryInterval`}
|
||||
render={({ field: retryIntervalField }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>
|
||||
{t("hosts.retryInterval")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"placeholders.retryInterval",
|
||||
)}
|
||||
{...retryIntervalField}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.retryIntervalDescription")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`tunnelConnections.${index}.autoStart`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-4">
|
||||
<FormLabel>
|
||||
{t("hosts.autoStartContainer")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t("hosts.autoStartDesc")}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
field.onChange([
|
||||
...field.value,
|
||||
{
|
||||
sourcePort: 22,
|
||||
endpointPort: 224,
|
||||
endpointHost: "",
|
||||
maxRetries: 3,
|
||||
retryInterval: 10,
|
||||
autoStart: false,
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{t("hosts.addConnection")}
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import {
|
||||
Popover,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
CommandItem,
|
||||
} from "@/components/ui/command.tsx";
|
||||
import { Check, ChevronsUpDown, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import type { JumpHostItemProps } from "./tab-types";
|
||||
|
||||
export function JumpHostItem({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import {
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
CommandItem,
|
||||
} from "@/components/ui/command.tsx";
|
||||
import { Check, ChevronsUpDown, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import type { QuickActionItemProps } from "./tab-types";
|
||||
|
||||
export function QuickActionItem({
|
||||
|
||||
@@ -1,4 +1,84 @@
|
||||
import type { SSHHost } from "@/types";
|
||||
import type { UseFormReturn } from "react-hook-form";
|
||||
import type React from "react";
|
||||
import type { SSHHost, Credential } from "@/types";
|
||||
|
||||
export interface HostGeneralTabProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
authTab: "password" | "key" | "credential" | "none";
|
||||
setAuthTab: (value: "password" | "key" | "credential" | "none") => void;
|
||||
keyInputMethod: "upload" | "paste";
|
||||
setKeyInputMethod: (value: "upload" | "paste") => void;
|
||||
proxyMode: "single" | "chain";
|
||||
setProxyMode: (value: "single" | "chain") => void;
|
||||
tagInput: string;
|
||||
setTagInput: (value: string) => void;
|
||||
folderDropdownOpen: boolean;
|
||||
setFolderDropdownOpen: (value: boolean) => void;
|
||||
folderInputRef: React.RefObject<HTMLInputElement>;
|
||||
folderDropdownRef: React.RefObject<HTMLDivElement>;
|
||||
filteredFolders: string[];
|
||||
handleFolderClick: (folder: string) => void;
|
||||
keyTypeDropdownOpen: boolean;
|
||||
setKeyTypeDropdownOpen: (value: boolean) => void;
|
||||
keyTypeButtonRef: React.RefObject<HTMLButtonElement>;
|
||||
keyTypeDropdownRef: React.RefObject<HTMLDivElement>;
|
||||
keyTypeOptions: Array<{ value: string; label: string }>;
|
||||
ipInputRef: React.RefObject<HTMLInputElement>;
|
||||
editorTheme: unknown;
|
||||
hosts: SSHHost[];
|
||||
editingHost?: SSHHost | null;
|
||||
folders: string[];
|
||||
credentials: Credential[];
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface HostTerminalTabProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
snippets: Array<{ id: number; name: string; content: string }>;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface HostDockerTabProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface HostTunnelTabProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
sshConfigDropdownOpen: { [key: number]: boolean };
|
||||
setSshConfigDropdownOpen: React.Dispatch<
|
||||
React.SetStateAction<{ [key: number]: boolean }>
|
||||
>;
|
||||
sshConfigInputRefs: React.MutableRefObject<{
|
||||
[key: number]: HTMLInputElement | null;
|
||||
}>;
|
||||
sshConfigDropdownRefs: React.MutableRefObject<{
|
||||
[key: number]: HTMLDivElement | null;
|
||||
}>;
|
||||
getFilteredSshConfigs: (index: number) => string[];
|
||||
handleSshConfigClick: (config: string, index: number) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface HostFileManagerTabProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface HostStatisticsTabProps {
|
||||
form: UseFormReturn<FormData>;
|
||||
statusIntervalUnit: "seconds" | "minutes";
|
||||
setStatusIntervalUnit: (value: "seconds" | "minutes") => void;
|
||||
metricsIntervalUnit: "seconds" | "minutes";
|
||||
setMetricsIntervalUnit: (value: "seconds" | "minutes") => void;
|
||||
snippets: Array<{ id: number; name: string; content: string }>;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
export interface HostSharingTabProps {
|
||||
hostId: number | undefined;
|
||||
isNewHost: boolean;
|
||||
}
|
||||
|
||||
export interface JumpHostItemProps {
|
||||
jumpHost: { hostId: number };
|
||||
|
||||
Reference in New Issue
Block a user