Fix credentials UI

This commit is contained in:
LukeGus
2025-09-28 01:06:44 -05:00
parent bc8aa69099
commit a79b640914
14 changed files with 343 additions and 328 deletions

View File

@@ -77,12 +77,6 @@ const wss = new WebSocketServer({
},
});
sshLogger.success("SSH Terminal WebSocket server started with authentication", {
operation: "server_start",
port: 30002,
features: ["JWT_auth", "connection_limits", "data_access_control"],
});
wss.on("connection", async (ws: WebSocket, req) => {
let userId: string | undefined;
let userPayload: any;

View File

@@ -88,6 +88,19 @@
"leaveEmptyToKeepCurrent": "Leave empty to keep current value",
"uploadKeyFile": "Upload Key File",
"generateKeyPairButton": "Generate Key Pair",
"generateKeyPair": "Generate Key Pair",
"generateKeyPairDescription": "Generate a new SSH key pair. If you want to protect the key with a passphrase, enter it in the Key Password field below first.",
"deploySSHKey": "Deploy SSH Key",
"deploySSHKeyDescription": "Deploy public key to target server",
"sourceCredential": "Source Credential",
"targetHost": "Target Host",
"deploymentProcess": "Deployment Process",
"deploymentProcessDescription": "This will safely add the public key to the target host's ~/.ssh/authorized_keys file without overwriting existing keys. The operation is reversible.",
"chooseHostToDeploy": "Choose a host to deploy to...",
"deploying": "Deploying...",
"name": "Name",
"noHostsAvailable": "No hosts available",
"noHostsMatchSearch": "No hosts match your search",
"sshKeyGenerationNotImplemented": "SSH key generation feature coming soon",
"connectionTestingNotImplemented": "Connection testing feature coming soon",
"testConnection": "Test Connection",
@@ -266,6 +279,9 @@
"sshTools": "SSH Tools",
"english": "English",
"chinese": "Chinese",
"cancel": "Cancel",
"username": "Username",
"name": "Name",
"login": "Login",
"logout": "Logout",
"register": "Register",
@@ -966,6 +982,7 @@
"connecting": "Connecting...",
"disconnecting": "Disconnecting...",
"unknownTunnelStatus": "Unknown",
"unknown": "Unknown",
"error": "Error",
"failed": "Failed",
"retrying": "Retrying",
@@ -1011,7 +1028,10 @@
"disconnect": "Disconnect",
"connect": "Connect",
"canceling": "Canceling...",
"endpointHostNotFound": "Endpoint host not found"
"endpointHostNotFound": "Endpoint host not found",
"discord": "Discord",
"githubIssue": "GitHub issue",
"forHelp": "for help"
},
"serverStats": {
"title": "Server Statistics",

View File

@@ -88,6 +88,19 @@
"leaveEmptyToKeepCurrent": "留空以保持当前值",
"uploadKeyFile": "上传密钥文件",
"generateKeyPairButton": "生成密钥对",
"generateKeyPair": "生成密钥对",
"generateKeyPairDescription": "生成新的SSH密钥对。如果您想用密码保护密钥请先在下面的密钥密码字段中输入密码。",
"deploySSHKey": "部署SSH密钥",
"deploySSHKeyDescription": "将公钥部署到目标服务器",
"sourceCredential": "源凭据",
"targetHost": "目标主机",
"deploymentProcess": "部署过程",
"deploymentProcessDescription": "这将安全地将公钥添加到目标主机的~/.ssh/authorized_keys文件中而不会覆盖现有密钥。此操作是可逆的。",
"chooseHostToDeploy": "选择要部署到的主机...",
"deploying": "部署中...",
"name": "名称",
"noHostsAvailable": "没有可用的主机",
"noHostsMatchSearch": "没有匹配搜索的主机",
"sshKeyGenerationNotImplemented": "SSH密钥生成功能即将推出",
"connectionTestingNotImplemented": "连接测试功能即将推出",
"testConnection": "测试连接",
@@ -260,6 +273,9 @@
"sshTools": "SSH 工具",
"english": "英语",
"chinese": "中文",
"cancel": "取消",
"username": "用户名",
"name": "名称",
"login": "登录",
"logout": "登出",
"register": "注册",
@@ -438,7 +454,6 @@
"verificationCompleted": "兼容性验证完成 - 未修改任何数据",
"verificationInProgress": "验证完成",
"dataMigrationCompleted": "数据迁移完成!",
"migrationCompleted": "迁移完成",
"verificationFailed": "兼容性验证失败",
"migrationFailed": "迁移失败",
"runningVerification": "正在进行兼容性验证...",
@@ -760,7 +775,6 @@
"renaming": "重命名中...",
"fileUploadedSuccessfully": "文件 \"{{name}}\" 上传成功",
"failedToUploadFile": "上传文件失败",
"fileDownloadedSuccessfully": "文件 \"{{name}}\" 下载成功",
"failedToDownloadFile": "下载文件失败",
"noFileContent": "未收到文件内容",
"filePath": "文件路径",
@@ -786,12 +800,8 @@
"noSSHConnection": "无SSH连接可用",
"enterFolderName": "输入文件夹名称:",
"enterFileName": "输入文件名称:",
"copy": "复制",
"cut": "剪切",
"paste": "粘贴",
"delete": "删除",
"properties": "属性",
"preview": "预览",
"refresh": "刷新",
"downloadFiles": "下载 {{count}} 个文件",
"copyFiles": "复制 {{count}} 个项目",
@@ -811,13 +821,6 @@
"failedToDeleteItem": "删除项目失败",
"itemRenamedSuccessfully": "{{type}}重命名成功",
"failedToRenameItem": "重命名项目失败",
"upload": "上传",
"download": "下载",
"delete": "删除",
"permissions": "权限",
"size": "大小",
"modified": "修改时间",
"path": "路径",
"confirmDelete": "确定要删除 {{name}} 吗?",
"uploadSuccess": "文件上传成功",
"uploadFailed": "文件上传失败",
@@ -834,7 +837,6 @@
"noSshSessionId": "没有可用的 SSH 会话 ID",
"noFilePath": "没有可用的文件路径",
"noCurrentHost": "没有可用的当前主机",
"fileSavedSuccessfully": "文件保存成功",
"saveTimeout": "保存操作超时。文件可能已成功保存,但操作用时过长。请检查 Docker 日志以确认。",
"failedToSaveFile": "保存文件失败",
"deletedSuccessfully": "删除成功",
@@ -889,10 +891,8 @@
"unpinFile": "取消固定",
"removeShortcut": "移除快捷方式",
"saveFilesToSystem": "另存 {{count}} 个文件为...",
"saveToSystem": "另存为...",
"pinFile": "固定文件",
"addToShortcuts": "添加到快捷方式",
"selectLocationToSave": "选择位置保存",
"downloadToDefaultLocation": "下载到默认位置",
"pasteFailed": "粘贴失败",
"noUndoableActions": "没有可撤销的操作",
@@ -910,7 +910,6 @@
"editPath": "编辑路径",
"confirm": "确认",
"cancel": "取消",
"folderName": "文件夹名",
"find": "查找...",
"replaceWith": "替换为...",
"replace": "替换",
@@ -936,15 +935,9 @@
"outdent": "减少缩进",
"autoComplete": "自动补全",
"imageLoadError": "图片加载失败",
"zoomIn": "放大",
"zoomOut": "缩小",
"rotate": "旋转",
"originalSize": "原始大小",
"startTyping": "开始输入...",
"fileSavedSuccessfully": "文件保存成功",
"autoSaveFailed": "自动保存失败",
"fileAutoSaved": "文件已自动保存",
"fileDownloadedSuccessfully": "文件下载成功",
"moveFileFailed": "移动 {{name}} 失败",
"moveOperationFailed": "移动操作失败",
"canOnlyCompareFiles": "只能对比两个文件",
@@ -980,6 +973,7 @@
"connecting": "连接中...",
"disconnecting": "断开连接中...",
"unknownTunnelStatus": "未知",
"unknown": "未知",
"error": "错误",
"failed": "失败",
"retrying": "重试中",
@@ -1015,7 +1009,10 @@
"remote": "远程",
"dynamic": "动态",
"portMapping": "端口 {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
"endpointHostNotFound": "未找到端点主机"
"endpointHostNotFound": "未找到端点主机",
"discord": "Discord",
"githubIssue": "GitHub 问题",
"forHelp": "寻求帮助"
},
"serverStats": {
"title": "服务器统计",
@@ -1283,101 +1280,30 @@
"discord": "Discord",
"connectToSshForOperations": "连接 SSH 以使用文件操作",
"uploadFile": "上传文件",
"newFile": "新建文件",
"newFolder": "新建文件夹",
"rename": "重命名",
"deleteItem": "删除项目",
"createNewFile": "创建新文件",
"createNewFolder": "创建新文件夹",
"renameItem": "重命名项目",
"clickToSelectFile": "点击选择文件",
"noSshHosts": "没有 SSH 主机",
"sshHosts": "SSH 主机",
"importSshHosts": "从 JSON 导入 SSH 主机",
"clientId": "客户端 ID",
"clientSecret": "客户端密钥",
"error": "错误",
"warning": "警告",
"deleteAccount": "删除账户",
"closeDeleteAccount": "关闭删除账户",
"cannotDeleteAccount": "无法删除账户",
"confirmPassword": "确认密码",
"deleting": "删除中...",
"externalAuth": "外部认证 (OIDC)",
"configureExternalProvider": "配置外部身份提供者",
"waitingForRetry": "等待重试",
"retryingConnection": "重试连接中",
"resetSplitSizes": "重置分屏大小",
"sshManagerAlreadyOpen": "SSH 管理器已打开",
"disabledDuringSplitScreen": "分屏期间禁用",
"unknown": "未知",
"connected": "已连接",
"disconnected": "已断开连接",
"maxRetriesExhausted": "已达到最大重试次数",
"endpointHostNotFound": "未找到端点主机",
"administrator": "管理员",
"user": "用户",
"external": "外部",
"local": "本地",
"saving": "保存中...",
"saveConfiguration": "保存配置",
"loading": "加载中...",
"refresh": "刷新",
"adding": "添加中...",
"makeAdmin": "设为管理员",
"verifying": "验证中...",
"verifyAndEnable": "验证并启用",
"secretKey": "密钥",
"totpQrCode": "TOTP 二维码",
"passwordRequired": "使用密码认证时需要密码",
"sshKeyRequired": "使用密钥认证时需要 SSH 私钥",
"keyTypeRequired": "使用密钥认证时需要密钥类型",
"validSshConfigRequired": "必须从列表中选择有效的 SSH 配置",
"updateHost": "更新主机",
"addHost": "添加主机",
"editHost": "编辑主机",
"pinConnection": "固定连接",
"authentication": "认证",
"password": "密码",
"key": "密钥",
"sshPrivateKey": "SSH 私钥",
"keyPassword": "密钥密码",
"keyType": "密钥类型",
"enableTerminal": "启用终端",
"enableTunnel": "启用隧道",
"enableFileManager": "启用文件管理器",
"defaultPath": "默认路径",
"tunnelConnections": "隧道连接",
"maxRetries": "最大重试次数",
"upload": "上传",
"updateKey": "更新密钥",
"sshpassRequired": "密码认证需要 Sshpass",
"sshServerConfigRequired": "需要 SSH 服务器配置",
"productionFolder": "生产环境",
"databaseServer": "数据库服务器",
"developmentServer": "开发服务器",
"developmentFolder": "开发环境",
"webServerProduction": "Web 服务器 - 生产环境",
"unknownError": "未知错误",
"failedToInitiatePasswordReset": "启动密码重置失败",
"failedToVerifyResetCode": "验证重置代码失败",
"failedToCompletePasswordReset": "完成密码重置失败",
"invalidTotpCode": "无效的 TOTP 代码",
"failedToStartOidcLogin": "启动 OIDC 登录失败",
"failedToGetUserInfoAfterOidc": "OIDC 登录后获取用户信息失败",
"loginWithExternalProvider": "使用外部提供者登录",
"loginWithExternal": "使用外部提供者登录",
"sendResetCode": "发送重置代码",
"verifyCode": "验证代码",
"resetPassword": "重置密码",
"login": "登录",
"signUp": "注册",
"failedToUpdateOidcConfig": "更新 OIDC 配置失败",
"failedToMakeUserAdmin": "设为管理员失败",
"failedToStartTotpSetup": "启动 TOTP 设置失败",
"invalidVerificationCode": "无效的验证码",
"failedToDisableTotp": "禁用 TOTP 失败",
"failedToGenerateBackupCodes": "生成备用码失败"
"failedToStartTotpSetup": "启动 TOTP 设置失败"
},
"mobile": {
"selectHostToStart": "选择一个主机以开始您的终端会话",

View File

@@ -377,7 +377,6 @@ export function CredentialEditor({
};
const [tagInput, setTagInput] = useState("");
const [keyGenerationPassphrase, setKeyGenerationPassphrase] = useState("");
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
const folderInputRef = useRef<HTMLInputElement>(null);
@@ -440,10 +439,10 @@ export function CredentialEditor({
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="pt-2">
<FormLabel className="mb-3 font-bold">
<FormLabel className="mb-2 font-bold">
{t("credentials.basicInformation")}
</FormLabel>
<div className="grid grid-cols-12 gap-4">
<div className="grid grid-cols-12 gap-3">
<FormField
control={form.control}
name="name"
@@ -476,10 +475,10 @@ export function CredentialEditor({
)}
/>
</div>
<FormLabel className="mb-3 mt-3 font-bold">
<FormLabel className="mb-2 mt-4 font-bold">
{t("credentials.organization")}
</FormLabel>
<div className="grid grid-cols-26 gap-4">
<div className="grid grid-cols-26 gap-3">
<FormField
control={form.control}
name="description"
@@ -623,7 +622,7 @@ export function CredentialEditor({
</div>
</TabsContent>
<TabsContent value="authentication">
<FormLabel className="mb-3 font-bold">
<FormLabel className="mb-2 font-bold">
{t("credentials.authentication")}
</FormLabel>
<Tabs
@@ -670,29 +669,15 @@ export function CredentialEditor({
/>
</TabsContent>
<TabsContent value="key">
<div className="mt-4">
{/* Generate Key Pair Buttons */}
<div className="mb-4 p-4 bg-muted/20 border border-muted rounded-md">
<FormLabel className="mb-3 font-bold block">
<div className="mt-2">
<div className="mb-3 p-3 bg-muted/20 border border-muted rounded-md">
<FormLabel className="mb-2 font-bold block">
{t("credentials.generateKeyPair")}
</FormLabel>
{/* Key Generation Passphrase Input */}
<div className="mb-3">
<FormLabel className="text-sm mb-2 block">
{t("credentials.keyPassword")} (
{t("credentials.optional")})
</FormLabel>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
value={keyGenerationPassphrase}
onChange={(e) =>
setKeyGenerationPassphrase(e.target.value)
}
className="max-w-xs"
/>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.keyPassphraseOptional")}
<div className="mb-2">
<div className="text-sm text-muted-foreground">
{t("credentials.generateKeyPairDescription")}
</div>
</div>
@@ -703,24 +688,20 @@ export function CredentialEditor({
size="sm"
onClick={async () => {
try {
const currentKeyPassword =
form.watch("keyPassword");
const result = await generateKeyPair(
"ssh-ed25519",
undefined,
keyGenerationPassphrase,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
if (keyGenerationPassphrase) {
form.setValue(
"keyPassword",
keyGenerationPassphrase,
);
}
debouncedKeyDetection(
result.privateKey,
keyGenerationPassphrase,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
@@ -754,24 +735,20 @@ export function CredentialEditor({
size="sm"
onClick={async () => {
try {
const currentKeyPassword =
form.watch("keyPassword");
const result = await generateKeyPair(
"ecdsa-sha2-nistp256",
undefined,
keyGenerationPassphrase,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
if (keyGenerationPassphrase) {
form.setValue(
"keyPassword",
keyGenerationPassphrase,
);
}
debouncedKeyDetection(
result.privateKey,
keyGenerationPassphrase,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
@@ -805,24 +782,20 @@ export function CredentialEditor({
size="sm"
onClick={async () => {
try {
const currentKeyPassword =
form.watch("keyPassword");
const result = await generateKeyPair(
"ssh-rsa",
2048,
keyGenerationPassphrase,
currentKeyPassword,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
if (keyGenerationPassphrase) {
form.setValue(
"keyPassword",
keyGenerationPassphrase,
);
}
debouncedKeyDetection(
result.privateKey,
keyGenerationPassphrase,
currentKeyPassword,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(
@@ -851,20 +824,17 @@ export function CredentialEditor({
{t("credentials.generateRSA")}
</Button>
</div>
<div className="text-xs text-muted-foreground mt-2">
{t("credentials.generateKeyPairNote")}
</div>
</div>
<div className="grid grid-cols-2 gap-4 items-start">
<div className="grid grid-cols-2 gap-3 items-start">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4 flex flex-col">
<FormLabel className="mb-2 min-h-[20px]">
<FormItem className="mb-3 flex flex-col">
<FormLabel className="mb-1 min-h-[20px]">
{t("credentials.sshPrivateKey")}
</FormLabel>
<div className="mb-2">
<div className="mb-1">
<div className="relative inline-block w-full">
<input
id="key-upload"
@@ -968,12 +938,11 @@ export function CredentialEditor({
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="mb-4 flex flex-col">
<FormLabel className="mb-2 min-h-[20px]">
{t("credentials.sshPublicKey")} (
{t("credentials.optional")})
<FormItem className="mb-3 flex flex-col">
<FormLabel className="mb-1 min-h-[20px]">
{t("credentials.sshPublicKey")}
</FormLabel>
<div className="mb-2 flex gap-2">
<div className="mb-1 flex gap-2">
<div className="relative inline-block flex-1">
<input
id="public-key-upload"
@@ -1100,9 +1069,6 @@ export function CredentialEditor({
]}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.publicKeyNote")}
</div>
{detectedPublicKeyType && field.value && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
@@ -1131,7 +1097,7 @@ export function CredentialEditor({
)}
/>
</div>
<div className="grid grid-cols-8 gap-4 mt-4">
<div className="grid grid-cols-8 gap-3 mt-3">
<FormField
control={form.control}
name="keyPassword"

View File

@@ -91,6 +91,9 @@ export function CredentialsManager({
const [availableHosts, setAvailableHosts] = useState<any[]>([]);
const [selectedHostId, setSelectedHostId] = useState<string>("");
const [deployLoading, setDeployLoading] = useState(false);
const [hostSearchQuery, setHostSearchQuery] = useState("");
const [dropdownOpen, setDropdownOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const dragCounter = useRef(0);
useEffect(() => {
@@ -98,6 +101,40 @@ export function CredentialsManager({
fetchHosts();
}, []);
useEffect(() => {
if (showDeployDialog) {
setDropdownOpen(false);
setHostSearchQuery("");
setTimeout(() => {
const activeElement = document.activeElement as HTMLElement;
if (activeElement && activeElement.blur) {
activeElement.blur();
}
}, 100);
}
}, [showDeployDialog]);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
}
if (dropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
} else {
document.removeEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [dropdownOpen]);
const fetchHosts = async () => {
try {
const hosts = await getSSHHosts();
@@ -137,6 +174,8 @@ export function CredentialsManager({
}
setDeployingCredential(credential);
setSelectedHostId("");
setHostSearchQuery("");
setDropdownOpen(false);
setShowDeployDialog(true);
};
@@ -789,145 +828,209 @@ export function CredentialsManager({
<Sheet open={showDeployDialog} onOpenChange={setShowDeployDialog}>
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto">
<SheetHeader className="space-y-6 pb-8">
<SheetTitle className="flex items-center space-x-4">
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
<Upload className="h-5 w-5 text-green-600" />
</div>
<div className="flex-1">
<div className="text-xl font-semibold">Deploy SSH Key</div>
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
Deploy public key to target server
<div className="px-4 py-4">
<div className="space-y-3 pb-4">
<div className="flex items-center space-x-3">
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
<Upload className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
</div>
</SheetTitle>
</SheetHeader>
<div className="space-y-6">
{deployingCredential && (
<div className="border border-zinc-200 dark:border-zinc-700 rounded-lg p-4 bg-zinc-50 dark:bg-zinc-900/50">
<h4 className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 mb-3 flex items-center">
<Key className="h-4 w-4 mr-2 text-zinc-500" />
Source Credential
</h4>
<div className="space-y-3">
<div className="flex items-center space-x-3">
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
Name
</div>
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
{deployingCredential.name ||
deployingCredential.username}
</div>
</div>
<div className="flex-1">
<div className="text-lg font-semibold">
{t("credentials.deploySSHKey")}
</div>
<div className="flex items-center space-x-3">
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
Username
</div>
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
{deployingCredential.username}
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
<Key className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
Key Type
</div>
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
{deployingCredential.keyType || "SSH Key"}
</div>
</div>
<div className="text-sm text-muted-foreground">
{t("credentials.deploySSHKeyDescription")}
</div>
</div>
</div>
)}
<div className="space-y-3">
<label className="text-sm font-semibold text-zinc-800 dark:text-zinc-200 flex items-center">
<Server className="h-4 w-4 mr-2 text-zinc-500" />
Target Host
</label>
<Select value={selectedHostId} onValueChange={setSelectedHostId}>
<SelectTrigger className="h-12 border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-900/50">
<SelectValue placeholder="Choose a host to deploy to..." />
</SelectTrigger>
<SelectContent>
{availableHosts.map((host) => (
<SelectItem key={host.id} value={host.id.toString()}>
<div className="flex items-center gap-3 py-1">
<div className="p-1.5 rounded-md bg-zinc-100 dark:bg-zinc-800">
<Server className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="font-medium text-zinc-800 dark:text-zinc-200">
{host.name || host.ip}
</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
{host.username}@{host.ip}:{host.port}
</div>
</div>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-4 bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-start space-x-3">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">Deployment Process</p>
<p className="text-blue-700 dark:text-blue-300">
This will safely add the public key to the target host's
~/.ssh/authorized_keys file without overwriting existing
keys. The operation is reversible.
</p>
<div className="space-y-4">
{deployingCredential && (
<div className="border rounded-lg p-3 bg-muted/20">
<h4 className="text-sm font-semibold mb-2 flex items-center">
<Key className="h-4 w-4 mr-2 text-muted-foreground" />
{t("credentials.sourceCredential")}
</h4>
<div className="space-y-2">
<div className="flex items-center space-x-3 px-2 py-1">
<div className="p-1.5 rounded bg-muted">
<User className="h-3 w-3 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="text-xs text-muted-foreground">
{t("common.name")}
</div>
<div className="text-sm font-medium">
{deployingCredential.name ||
deployingCredential.username}
</div>
</div>
</div>
<div className="flex items-center space-x-3 px-2 py-1">
<div className="p-1.5 rounded bg-muted">
<User className="h-3 w-3 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="text-xs text-muted-foreground">
{t("common.username")}
</div>
<div className="text-sm font-medium">
{deployingCredential.username}
</div>
</div>
</div>
<div className="flex items-center space-x-3 px-2 py-1">
<div className="p-1.5 rounded bg-muted">
<Key className="h-3 w-3 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="text-xs text-muted-foreground">
{t("credentials.keyType")}
</div>
<div className="text-sm font-medium">
{deployingCredential.keyType || "SSH Key"}
</div>
</div>
</div>
</div>
</div>
)}
<div className="space-y-2">
<label className="text-sm font-semibold flex items-center">
<Server className="h-4 w-4 mr-2 text-muted-foreground" />
{t("credentials.targetHost")}
</label>
<div className="relative" ref={dropdownRef}>
<Input
placeholder={t("credentials.chooseHostToDeploy")}
value={hostSearchQuery}
onChange={(e) => {
setHostSearchQuery(e.target.value);
if (e.target.value.trim() !== "") {
setDropdownOpen(true);
} else {
setDropdownOpen(false);
}
}}
onFocus={() => {
setDropdownOpen(true);
}}
className="w-full"
autoFocus={false}
/>
{dropdownOpen && (
<div className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-60 overflow-y-auto">
{availableHosts.length === 0 ? (
<div className="p-3 text-sm text-muted-foreground text-center">
{t("credentials.noHostsAvailable")}
</div>
) : availableHosts.filter(
(host) =>
!hostSearchQuery ||
host.name
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()) ||
host.ip
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()) ||
host.username
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()),
).length === 0 ? (
<div className="p-3 text-sm text-muted-foreground text-center">
{t("credentials.noHostsMatchSearch")}
</div>
) : (
availableHosts
.filter(
(host) =>
!hostSearchQuery ||
host.name
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()) ||
host.ip
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()) ||
host.username
?.toLowerCase()
.includes(hostSearchQuery.toLowerCase()),
)
.map((host) => (
<div
key={host.id}
className="flex items-center gap-3 py-2 px-3 hover:bg-muted cursor-pointer"
onClick={() => {
setSelectedHostId(host.id.toString());
setHostSearchQuery(host.name || host.ip);
setDropdownOpen(false);
}}
>
<div className="p-1.5 rounded bg-muted">
<Server className="h-3 w-3 text-muted-foreground" />
</div>
<div className="flex-1">
<div className="font-medium text-foreground">
{host.name || host.ip}
</div>
<div className="text-xs text-muted-foreground">
{host.username}@{host.ip}:{host.port}
</div>
</div>
</div>
))
)}
</div>
)}
</div>
</div>
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-3 bg-blue-50 dark:bg-blue-900/20">
<div className="flex items-start space-x-2">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
<div className="text-sm">
<p className="font-medium text-blue-800 dark:text-blue-200 mb-1">
{t("credentials.deploymentProcess")}
</p>
<p className="text-blue-700 dark:text-blue-300">
{t("credentials.deploymentProcessDescription")}
</p>
</div>
</div>
</div>
<div className="mt-4">
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => setShowDeployDialog(false)}
disabled={deployLoading}
className="flex-1"
>
{t("common.cancel")}
</Button>
<Button
onClick={performDeploy}
disabled={!selectedHostId || deployLoading}
className="flex-1 bg-green-600 hover:bg-green-700 text-white"
>
{deployLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></div>
{t("credentials.deploying")}
</div>
) : (
<div className="flex items-center">
<Upload className="h-4 w-4 mr-2" />
{t("credentials.deploySSHKey")}
</div>
)}
</Button>
</div>
</div>
</div>
</div>
<SheetFooter className="mt-8 flex space-x-3">
<Button
variant="outline"
onClick={() => setShowDeployDialog(false)}
disabled={deployLoading}
className="flex-1"
>
Cancel
</Button>
<Button
onClick={performDeploy}
disabled={!selectedHostId || deployLoading}
className="flex-1 bg-green-600 hover:bg-green-700 text-white"
>
{deployLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent mr-2"></div>
Deploying...
</div>
) : (
<div className="flex items-center">
<Upload className="h-4 w-4 mr-2" />
Deploy SSH Key
</div>
)}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
</div>

View File

@@ -96,6 +96,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [pinnedFiles, setPinnedFiles] = useState<Set<string>>(new Set());
const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0);
const [isClosing, setIsClosing] = useState<boolean>(false);
const [contextMenu, setContextMenu] = useState<{
x: number;
@@ -179,6 +180,18 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
}, []);
const handleCloseWithError = useCallback(
(errorMessage: string) => {
if (isClosing) return; // Prevent duplicate calls
setIsClosing(true);
toast.error(errorMessage);
if (onClose) {
onClose();
}
},
[isClosing, onClose],
);
useEffect(() => {
if (currentHost) {
initializeSSHConnection();
@@ -286,12 +299,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
}
} catch (error: any) {
console.error("SSH connection failed:", error);
toast.error(
handleCloseWithError(
t("fileManager.failedToConnect") + ": " + (error.message || error),
);
if (onClose) {
onClose();
}
} finally {
setIsLoading(false);
}
@@ -342,9 +352,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
error.message?.includes("connection") ||
error.message?.includes("SSH")
) {
if (onClose) {
onClose();
}
handleCloseWithError(
t("fileManager.failedToLoadDirectory") +
": " +
(error.message || error),
);
}
}
} finally {
@@ -1104,9 +1116,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
});
}
} catch (error) {
if (onClose) {
onClose();
}
handleCloseWithError(
`SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`,
);
throw error;
} finally {
setIsReconnecting(false);

View File

@@ -136,10 +136,8 @@ export function Server({
fetchStatus();
fetchMetrics();
intervalId = window.setInterval(() => {
if (isVisible) {
fetchStatus();
fetchMetrics();
}
fetchStatus();
fetchMetrics();
}, 30000);
}

View File

@@ -617,9 +617,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const jwtToken = getCookie("jwt");
if (!jwtToken || jwtToken.trim() === "") {
console.warn(
"WebSocket connection delayed - no authentication token",
);
setIsConnected(false);
setIsConnecting(false);
setConnectionError("Authentication required");

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { TunnelViewer } from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
import {
getSSHHosts,
@@ -15,6 +16,7 @@ import type {
} from "../../../types/index.js";
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
const { t } = useTranslation();
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
const [tunnelStatuses, setTunnelStatuses] = useState<
@@ -114,7 +116,7 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
useEffect(() => {
fetchTunnelStatuses();
const interval = setInterval(fetchTunnelStatuses, 500);
const interval = setInterval(fetchTunnelStatuses, 5000);
return () => clearInterval(interval);
}, [fetchTunnelStatuses]);
@@ -137,7 +139,7 @@ export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
);
if (!endpointHost) {
throw new Error("Endpoint host not found");
throw new Error(t("tunnels.endpointHostNotFound"));
}
const tunnelConfig = {

View File

@@ -237,7 +237,7 @@ export function TunnelObject({
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
Discord
{t("tunnels.discord")}
</a>{" "}
or create a{" "}
<a
@@ -246,9 +246,9 @@ export function TunnelObject({
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
GitHub issue
{t("tunnels.githubIssue")}
</a>{" "}
for help.
{t("tunnels.forHelp")}.
</div>
</>
)}
@@ -471,7 +471,7 @@ export function TunnelObject({
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
Discord
{t("tunnels.discord")}
</a>{" "}
or create a{" "}
<a
@@ -480,9 +480,9 @@ export function TunnelObject({
rel="noopener noreferrer"
className="underline text-blue-600 dark:text-blue-400"
>
GitHub issue
{t("tunnels.githubIssue")}
</a>{" "}
for help.
{t("tunnels.forHelp")}.
</div>
</>
)}

View File

@@ -46,7 +46,7 @@ export function Host({ host }: HostProps): React.ReactElement {
fetchStatus();
intervalId = window.setInterval(fetchStatus, 10000);
intervalId = window.setInterval(fetchStatus, 30000);
return () => {
cancelled = true;

View File

@@ -46,7 +46,7 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
fetchStatus();
intervalId = window.setInterval(fetchStatus, 10000);
intervalId = window.setInterval(fetchStatus, 30000);
return () => {
cancelled = true;

View File

@@ -271,9 +271,6 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const jwtToken = getCookie("jwt");
if (!jwtToken || jwtToken.trim() === "") {
console.warn(
"WebSocket connection delayed - no authentication token",
);
setIsConnected(false);
setIsConnecting(false);
setConnectionError("Authentication required");

View File

@@ -46,7 +46,7 @@ export function Host({ host, onHostConnect }: HostProps): React.ReactElement {
fetchStatus();
intervalId = window.setInterval(fetchStatus, 10000);
intervalId = window.setInterval(fetchStatus, 30000);
return () => {
cancelled = true;