v1.6.0 #221

Merged
LukeGus merged 74 commits from dev-1.6.0 into main 2025-09-12 19:42:00 +00:00
9 changed files with 228 additions and 173 deletions
Showing only changes of commit 3e8e15508a - Show all commits

View File

@@ -18,7 +18,7 @@ http {
index index.html index.htm; index index.html index.htm;
} }
location /users/ { location ~ ^/users(/.*)?$ {
proxy_pass http://127.0.0.1:8081; proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -27,7 +27,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /version/ { location ~ ^/version(/.*)?$ {
proxy_pass http://127.0.0.1:8081; proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -36,7 +36,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /releases/ { location ~ ^/releases(/.*)?$ {
proxy_pass http://127.0.0.1:8081; proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -45,7 +45,16 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /alerts/ { location ~ ^/alerts(/.*)?$ {
proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/credentials(/.*)?$ {
proxy_pass http://127.0.0.1:8081; proxy_pass http://127.0.0.1:8081;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -129,7 +138,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /status/ { location ~ ^/status(/.*)?$ {
proxy_pass http://127.0.0.1:8085; proxy_pass http://127.0.0.1:8085;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
@@ -138,7 +147,7 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
location /metrics/ { location ~ ^/metrics(/.*)?$ {
proxy_pass http://127.0.0.1:8085; proxy_pass http://127.0.0.1:8085;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;

View File

@@ -129,7 +129,7 @@ ipcMain.handle('save-server-config', (event, config) => {
ipcMain.handle('test-server-connection', async (event, serverUrl) => { ipcMain.handle('test-server-connection', async (event, serverUrl) => {
try { try {
const { default: fetch } = await import('node-fetch'); const fetch = require('node-fetch');
// Try multiple endpoints to test the connection // Try multiple endpoints to test the connection
const testUrls = [ const testUrls = [

View File

@@ -507,9 +507,11 @@
"unknownError": "Unknown error occurred", "unknownError": "Unknown error occurred",
"messageParseError": "Failed to parse server message", "messageParseError": "Failed to parse server message",
"websocketError": "WebSocket connection error", "websocketError": "WebSocket connection error",
"connecting": "Connecting...",
"reconnecting": "Reconnecting... ({{attempt}}/{{max}})", "reconnecting": "Reconnecting... ({{attempt}}/{{max}})",
"reconnected": "Reconnected successfully", "reconnected": "Reconnected successfully",
"maxReconnectAttemptsReached": "Maximum reconnection attempts reached" "maxReconnectAttemptsReached": "Maximum reconnection attempts reached",
"connectionTimeout": "Connection timeout"
}, },
"fileManager": { "fileManager": {
"title": "File Manager", "title": "File Manager",
@@ -816,7 +818,6 @@
"oidcAuthFailed": "OIDC authentication failed", "oidcAuthFailed": "OIDC authentication failed",
"noTokenReceived": "No token received from login", "noTokenReceived": "No token received from login",
"invalidAuthUrl": "Invalid authorization URL received from backend", "invalidAuthUrl": "Invalid authorization URL received from backend",
"connectionTimeout": "Connection timeout",
"invalidInput": "Invalid input", "invalidInput": "Invalid input",
"requiredField": "This field is required", "requiredField": "This field is required",
"minLength": "Minimum length is {{min}}", "minLength": "Minimum length is {{min}}",

View File

@@ -58,9 +58,8 @@
"keyPassword": "密钥密码(可选)", "keyPassword": "密钥密码(可选)",
"keyType": "密钥类型", "keyType": "密钥类型",
"keyTypeRSA": "RSA", "keyTypeRSA": "RSA",
"keyTypeECDSA": "ECDSA", "keyTypeECDSA": "ECDSA",
"keyTypeEd25519": "Ed25519", "keyTypeEd25519": "Ed25519",
"updateCredential": "更新凭据",
"basicInfo": "基本信息", "basicInfo": "基本信息",
"authentication": "认证方式", "authentication": "认证方式",
"organization": "组织管理", "organization": "组织管理",
@@ -106,7 +105,6 @@
"credentialSecuredDescription": "所有敏感数据均使用AES-256加密", "credentialSecuredDescription": "所有敏感数据均使用AES-256加密",
"passwordAuthentication": "密码认证", "passwordAuthentication": "密码认证",
"keyAuthentication": "密钥认证", "keyAuthentication": "密钥认证",
"keyType": "密钥类型",
"securityReminder": "安全提醒", "securityReminder": "安全提醒",
"securityReminderText": "请勿分享您的凭据。所有数据均已静态加密。", "securityReminderText": "请勿分享您的凭据。所有数据均已静态加密。",
"hostsUsingCredential": "使用此凭据的主机", "hostsUsingCredential": "使用此凭据的主机",
@@ -181,7 +179,7 @@
"warning": "警告", "warning": "警告",
"info": "信息", "info": "信息",
"success": "成功", "success": "成功",
"loading": "加载中", "loading": "加载中...",
"required": "必填", "required": "必填",
"optional": "可选", "optional": "可选",
"clear": "清除", "clear": "清除",
@@ -195,8 +193,7 @@
"updateAvailable": "有可用更新", "updateAvailable": "有可用更新",
"sshPath": "SSH 路径", "sshPath": "SSH 路径",
"localPath": "本地路径", "localPath": "本地路径",
"loading": "加载中...", "noAuthCredentials": "此 SSH 主机没有可用的认证凭据",
"noAuthCredentials": "此 SSH 主机没有可用的身份验证凭据",
"noReleases": "没有发布版本", "noReleases": "没有发布版本",
"updatesAndReleases": "更新与发布", "updatesAndReleases": "更新与发布",
"newVersionAvailable": "有新版本 ({{version}}) 可用。", "newVersionAvailable": "有新版本 ({{version}}) 可用。",
@@ -209,13 +206,10 @@
"resetPassword": "重置密码", "resetPassword": "重置密码",
"resetCode": "重置代码", "resetCode": "重置代码",
"newPassword": "新密码", "newPassword": "新密码",
"sshPath": "SSH 路径",
"localPath": "本地路径",
"folder": "文件夹", "folder": "文件夹",
"file": "文件", "file": "文件",
"renamedSuccessfully": "重命名成功", "renamedSuccessfully": "重命名成功",
"deletedSuccessfully": "删除成功", "deletedSuccessfully": "删除成功",
"noAuthCredentials": "此 SSH 主机没有可用的认证凭据",
"noTunnelConnections": "没有配置隧道连接", "noTunnelConnections": "没有配置隧道连接",
"sshTools": "SSH 工具", "sshTools": "SSH 工具",
"english": "英语", "english": "英语",
@@ -236,22 +230,15 @@
"edit": "编辑", "edit": "编辑",
"add": "添加", "add": "添加",
"search": "搜索", "search": "搜索",
"loading": "加载中...",
"error": "错误",
"success": "成功",
"warning": "警告",
"info": "信息",
"confirm": "确认", "confirm": "确认",
"yes": "是", "yes": "是",
"no": "否", "no": "否",
"ok": "确定", "ok": "确定",
"close": "关闭",
"enabled": "已启用", "enabled": "已启用",
"disabled": "已禁用", "disabled": "已禁用",
"important": "重要", "important": "重要",
"notEnabled": "未启用", "notEnabled": "未启用",
"settingUp": "设置中...", "settingUp": "设置中...",
"back": "返回",
"next": "下一步", "next": "下一步",
"previous": "上一步", "previous": "上一步",
"refresh": "刷新", "refresh": "刷新",
@@ -297,7 +284,7 @@
"userManagement": "用户管理", "userManagement": "用户管理",
"makeAdmin": "设为管理员", "makeAdmin": "设为管理员",
"removeAdmin": "移除管理员", "removeAdmin": "移除管理员",
"deleteUser": "删除用户", "deleteUser": "删除用户 {{username}} 吗?此操作无法撤销。",
"allowRegistration": "允许注册", "allowRegistration": "允许注册",
"oidcSettings": "OIDC 设置", "oidcSettings": "OIDC 设置",
"clientId": "客户端 ID", "clientId": "客户端 ID",
@@ -346,7 +333,6 @@
"removeAdminStatus": "移除 {{username}} 的管理员权限吗?", "removeAdminStatus": "移除 {{username}} 的管理员权限吗?",
"adminStatusRemoved": "已移除 {{username}} 的管理员权限", "adminStatusRemoved": "已移除 {{username}} 的管理员权限",
"failedToRemoveAdminStatus": "移除管理员权限失败", "failedToRemoveAdminStatus": "移除管理员权限失败",
"deleteUser": "删除用户 {{username}} 吗?此操作无法撤销。",
"userDeletedSuccessfully": "用户 {{username}} 删除成功", "userDeletedSuccessfully": "用户 {{username}} 删除成功",
"failedToDeleteUser": "删除用户失败", "failedToDeleteUser": "删除用户失败",
"overrideUserInfoUrl": "覆盖用户信息 URL非必填" "overrideUserInfoUrl": "覆盖用户信息 URL非必填"
@@ -380,7 +366,7 @@
"importError": "导入错误", "importError": "导入错误",
"failedToImportJson": "导入 JSON 文件失败", "failedToImportJson": "导入 JSON 文件失败",
"connectionDetails": "连接详情", "connectionDetails": "连接详情",
"organization": "组织", "organization": "组织管理",
"ipAddress": "IP 地址", "ipAddress": "IP 地址",
"port": "端口", "port": "端口",
"name": "名称", "name": "名称",
@@ -395,16 +381,11 @@
"addHost": "添加主机", "addHost": "添加主机",
"editHost": "编辑主机", "editHost": "编辑主机",
"deleteHost": "删除主机", "deleteHost": "删除主机",
"hostName": "主机名",
"ipAddress": "IP 地址",
"port": "端口",
"authType": "认证类型", "authType": "认证类型",
"passwordAuth": "密码", "passwordAuth": "密码",
"keyAuth": "SSH 密钥", "keyAuth": "SSH 密钥",
"keyPassword": "密钥密码", "keyPassword": "密钥密码",
"keyType": "密钥类型", "keyType": "密钥类型",
"folder": "文件夹",
"tags": "标签",
"pin": "固定", "pin": "固定",
"enableTerminal": "启用终端", "enableTerminal": "启用终端",
"enableTunnel": "启用隧道", "enableTunnel": "启用隧道",
@@ -418,8 +399,6 @@
"connecting": "连接中...", "connecting": "连接中...",
"connectionFailed": "连接失败", "connectionFailed": "连接失败",
"connectionSuccess": "连接成功", "connectionSuccess": "连接成功",
"connectionDetails": "连接详情",
"organization": "组织管理",
"addTags": "添加标签(空格添加)", "addTags": "添加标签(空格添加)",
"sourcePort": "源端口", "sourcePort": "源端口",
"sourcePortDesc": "(源指通用标签页中的当前连接详情)", "sourcePortDesc": "(源指通用标签页中的当前连接详情)",
@@ -465,8 +444,6 @@
"credentialRequired": "使用凭证认证时需要选择凭证", "credentialRequired": "使用凭证认证时需要选择凭证",
"credentialDescription": "选择凭证将覆盖当前用户名并使用凭证的认证详细信息。", "credentialDescription": "选择凭证将覆盖当前用户名并使用凭证的认证详细信息。",
"sshPrivateKey": "SSH 私钥", "sshPrivateKey": "SSH 私钥",
"keyPassword": "密钥密码",
"keyType": "密钥类型",
"maxRetriesDescription": "隧道连接的最大重试次数。", "maxRetriesDescription": "隧道连接的最大重试次数。",
"retryIntervalDescription": "重试尝试之间的等待时间。", "retryIntervalDescription": "重试尝试之间的等待时间。",
"otherInstallMethods": "其他安装方法:", "otherInstallMethods": "其他安装方法:",
@@ -533,7 +510,18 @@
"error": "错误", "error": "错误",
"disconnected": "已断开连接", "disconnected": "已断开连接",
"connectionClosed": "连接已关闭", "connectionClosed": "连接已关闭",
"connectionError": "连接错误" "connectionError": "连接错误",
"connected": "已连接",
"sshConnected": "SSH 连接已建立",
"authError": "认证失败:{{message}}",
"unknownError": "发生未知错误",
"messageParseError": "解析服务器消息失败",
"websocketError": "WebSocket 连接错误",
"connecting": "连接中...",
"reconnecting": "重新连接中... ({{attempt}}/{{max}})",
"reconnected": "重新连接成功",
"maxReconnectAttemptsReached": "已达到最大重连尝试次数",
"connectionTimeout": "连接超时"
}, },
"fileManager": { "fileManager": {
"title": "文件管理器", "title": "文件管理器",
@@ -580,16 +568,11 @@
"failedToRenameItem": "重命名项目失败", "failedToRenameItem": "重命名项目失败",
"upload": "上传", "upload": "上传",
"download": "下载", "download": "下载",
"newFile": "新建文件",
"newFolder": "新建文件夹",
"rename": "重命名",
"delete": "删除", "delete": "删除",
"permissions": "权限", "permissions": "权限",
"size": "大小", "size": "大小",
"modified": "修改时间", "modified": "修改时间",
"path": "路径", "path": "路径",
"fileName": "文件名",
"folderName": "文件夹名",
"confirmDelete": "确定要删除 {{name}} 吗?", "confirmDelete": "确定要删除 {{name}} 吗?",
"uploadSuccess": "文件上传成功", "uploadSuccess": "文件上传成功",
"uploadFailed": "文件上传失败", "uploadFailed": "文件上传失败",
@@ -609,10 +592,7 @@
"fileSavedSuccessfully": "文件保存成功", "fileSavedSuccessfully": "文件保存成功",
"saveTimeout": "保存操作超时。文件可能已成功保存,但操作用时过长。请检查 Docker 日志以确认。", "saveTimeout": "保存操作超时。文件可能已成功保存,但操作用时过长。请检查 Docker 日志以确认。",
"failedToSaveFile": "保存文件失败", "failedToSaveFile": "保存文件失败",
"folder": "文件夹",
"file": "文件",
"deletedSuccessfully": "删除成功", "deletedSuccessfully": "删除成功",
"failedToDeleteItem": "删除项目失败",
"connectToServer": "连接到服务器", "connectToServer": "连接到服务器",
"selectServerToEdit": "从侧边栏选择服务器以开始编辑文件", "selectServerToEdit": "从侧边栏选择服务器以开始编辑文件",
"fileOperations": "文件操作", "fileOperations": "文件操作",
@@ -640,11 +620,11 @@
"tunnels": { "tunnels": {
"title": "SSH 隧道", "title": "SSH 隧道",
"noSshTunnels": "没有 SSH 隧道", "noSshTunnels": "没有 SSH 隧道",
"createFirstTunnelMessage": "您还没有创建任何 SSH 隧道。在主机管理器中配置隧道连接以开始使用。", "createFirstTunnelMessage": "创建您的第一个 SSH 隧道以开始使用。使用 SSH 管理器添加具有隧道连接的主机。",
"connected": "已连接", "connected": "已连接",
"disconnected": "已断开", "disconnected": "已断开连接",
"connecting": "连接中...", "connecting": "连接中...",
"disconnecting": "断开中...", "disconnecting": "断开连接中...",
"unknown": "未知", "unknown": "未知",
"error": "错误", "error": "错误",
"failed": "失败", "failed": "失败",
@@ -680,17 +660,7 @@
"local": "本地", "local": "本地",
"remote": "远程", "remote": "远程",
"dynamic": "动态", "dynamic": "动态",
"noSshTunnels": "没有 SSH 隧道",
"createFirstTunnelMessage": "创建您的第一个 SSH 隧道以开始使用。使用 SSH 管理器添加具有隧道连接的主机。",
"unknown": "未知",
"connected": "已连接",
"connecting": "连接中...",
"disconnecting": "断开连接中...",
"disconnected": "已断开连接",
"portMapping": "端口 {{sourcePort}} → {{endpointHost}}:{{endpointPort}}", "portMapping": "端口 {{sourcePort}} → {{endpointHost}}:{{endpointPort}}",
"disconnect": "断开连接",
"connect": "连接",
"canceling": "取消中...",
"endpointHostNotFound": "未找到端点主机" "endpointHostNotFound": "未找到端点主机"
}, },
"serverStats": { "serverStats": {
@@ -700,7 +670,7 @@
"disk": "磁盘", "disk": "磁盘",
"network": "网络", "network": "网络",
"uptime": "运行时间", "uptime": "运行时间",
"loadAverage": "平均负载", "loadAverage": "平均: {{avg1}}, {{avg5}}, {{avg15}}",
"processes": "进程", "processes": "进程",
"connections": "连接", "connections": "连接",
"usage": "使用率", "usage": "使用率",
@@ -716,20 +686,20 @@
"cpuCores_one": "{{count}} 个 CPU", "cpuCores_one": "{{count}} 个 CPU",
"cpuCores_other": "{{count}} 个 CPU", "cpuCores_other": "{{count}} 个 CPU",
"naCpus": "N/A CPU", "naCpus": "N/A CPU",
"loadAverage": "平均: {{avg1}}, {{avg5}}, {{avg15}}",
"loadAverageNA": "平均: N/A", "loadAverageNA": "平均: N/A",
"cpuUsage": "CPU 使用率", "cpuUsage": "CPU 使用率",
"memoryUsage": "内存使用率", "memoryUsage": "内存使用率",
"rootStorageSpace": "根目录存储空间", "rootStorageSpace": "根目录存储空间",
"of": "的", "of": "的",
"feedbackMessage": "对服务器管理的下一步功能有想法?在这里分享吧", "feedbackMessage": "对服务器管理的下一步功能有想法?在这里分享吧",
"failedToFetchHostConfig": "获取主机配置失败",
"failedToFetchStatus": "获取服务器状态失败",
"failedToFetchMetrics": "获取服务器指标失败",
"loadingMetrics": "正在加载指标...", "loadingMetrics": "正在加载指标...",
"refreshing": "正在刷新...", "refreshing": "正在刷新...",
"serverOffline": "服务器离线", "serverOffline": "服务器离线",
"cannotFetchMetrics": "无法从离线服务器获取指标", "cannotFetchMetrics": "无法从离线服务器获取指标",
"load": "负载", "load": "负载"
"free": "空闲",
"available": "可用"
}, },
"auth": { "auth": {
"loginTitle": "登录 Termix", "loginTitle": "登录 Termix",
@@ -831,7 +801,6 @@
"oidcAuthFailed": "OIDC 认证失败", "oidcAuthFailed": "OIDC 认证失败",
"noTokenReceived": "登录未收到令牌", "noTokenReceived": "登录未收到令牌",
"invalidAuthUrl": "从后端收到无效的授权 URL", "invalidAuthUrl": "从后端收到无效的授权 URL",
"connectionTimeout": "连接超时",
"invalidInput": "输入无效", "invalidInput": "输入无效",
"requiredField": "此字段为必填项", "requiredField": "此字段为必填项",
"minLength": "最小长度为 {{min}}", "minLength": "最小长度为 {{min}}",
@@ -876,6 +845,9 @@
"external": "外部 (OIDC)", "external": "外部 (OIDC)",
"selectPreferredLanguage": "选择您的界面首选语言" "selectPreferredLanguage": "选择您的界面首选语言"
}, },
"user": {
"failedToLoadVersionInfo": "加载版本信息失败"
},
"placeholders": { "placeholders": {
"enterCode": "000000", "enterCode": "000000",
"ipAddress": "127.0.0.1", "ipAddress": "127.0.0.1",
@@ -953,7 +925,6 @@
"deleteItem": "删除项目", "deleteItem": "删除项目",
"createNewFile": "创建新文件", "createNewFile": "创建新文件",
"createNewFolder": "创建新文件夹", "createNewFolder": "创建新文件夹",
"deleteItem": "删除项目",
"renameItem": "重命名项目", "renameItem": "重命名项目",
"clickToSelectFile": "点击选择文件", "clickToSelectFile": "点击选择文件",
"noSshHosts": "没有 SSH 主机", "noSshHosts": "没有 SSH 主机",
@@ -1018,8 +989,6 @@
"updateKey": "更新密钥", "updateKey": "更新密钥",
"sshpassRequired": "密码认证需要 Sshpass", "sshpassRequired": "密码认证需要 Sshpass",
"sshServerConfigRequired": "需要 SSH 服务器配置", "sshServerConfigRequired": "需要 SSH 服务器配置",
"sshManagerAlreadyOpen": "SSH 管理器已打开",
"disabledDuringSplitScreen": "分屏期间禁用",
"productionFolder": "生产环境", "productionFolder": "生产环境",
"databaseServer": "数据库服务器", "databaseServer": "数据库服务器",
"unknownError": "未知错误", "unknownError": "未知错误",

View File

@@ -306,9 +306,6 @@ export function Server({
value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0} value={typeof metrics?.cpu?.percent === 'number' ? metrics!.cpu!.percent! : 0}
className="h-2" className="h-2"
/> />
{typeof metrics?.cpu?.percent === 'number' && metrics.cpu.percent > 80 && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
)}
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
@@ -347,9 +344,6 @@ export function Server({
value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0} value={typeof metrics?.memory?.percent === 'number' ? metrics!.memory!.percent! : 0}
className="h-2" className="h-2"
/> />
{typeof metrics?.memory?.percent === 'number' && metrics.memory.percent > 85 && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
)}
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
@@ -390,9 +384,6 @@ export function Server({
value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0} value={typeof metrics?.disk?.percent === 'number' ? metrics!.disk!.percent! : 0}
className="h-2" className="h-2"
/> />
{typeof metrics?.disk?.percent === 'number' && metrics.disk.percent > 90 && (
<div className="absolute -top-1 -right-1 w-3 h-3 bg-red-500 rounded-full animate-pulse"></div>
)}
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">

View File

@@ -29,6 +29,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null); const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null); const [connectionError, setConnectionError] = useState<string | null>(null);
const isVisibleRef = useRef<boolean>(false); const isVisibleRef = useRef<boolean>(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null); const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -36,7 +37,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const maxReconnectAttempts = 3; const maxReconnectAttempts = 3;
const isUnmountingRef = useRef(false); const isUnmountingRef = useRef(false);
const shouldNotReconnectRef = useRef(false); const shouldNotReconnectRef = useRef(false);
const isReconnectingRef = useRef(false);
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null); const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
@@ -76,6 +78,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
disconnect: () => { disconnect: () => {
isUnmountingRef.current = true; isUnmountingRef.current = true;
shouldNotReconnectRef.current = true; shouldNotReconnectRef.current = true;
isReconnectingRef.current = false;
if (pingIntervalRef.current) { if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current); clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null; pingIntervalRef.current = null;
@@ -84,8 +87,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
clearTimeout(reconnectTimeoutRef.current); clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null; reconnectTimeoutRef.current = null;
} }
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
webSocketRef.current?.close(); webSocketRef.current?.close();
setIsConnected(false); setIsConnected(false);
setIsConnecting(false); // Clear connecting state
}, },
fit: () => { fit: () => {
fitAddonRef.current?.fit(); fitAddonRef.current?.fit();
@@ -135,31 +143,54 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
} }
function attemptReconnection() { function attemptReconnection() {
// Don't attempt reconnection if component is unmounting or if we shouldn't reconnect // Don't attempt reconnection if component is unmounting, shouldn't reconnect, or already reconnecting
if (isUnmountingRef.current || shouldNotReconnectRef.current) { if (isUnmountingRef.current || shouldNotReconnectRef.current || isReconnectingRef.current) {
return; return;
} }
// Check if we've already reached max attempts
if (reconnectAttempts.current >= maxReconnectAttempts) { if (reconnectAttempts.current >= maxReconnectAttempts) {
toast.error(t('terminal.maxReconnectAttemptsReached')); toast.error(t('terminal.maxReconnectAttemptsReached'));
return; return;
} }
// Set reconnecting flag to prevent multiple simultaneous attempts
isReconnectingRef.current = true;
// Clear terminal immediately to prevent showing last line
if (terminal) {
terminal.clear();
}
// Increment attempt counter
reconnectAttempts.current++; reconnectAttempts.current++;
// Show toast with current attempt number
toast.info(t('terminal.reconnecting', { attempt: reconnectAttempts.current, max: maxReconnectAttempts })); toast.info(t('terminal.reconnecting', { attempt: reconnectAttempts.current, max: maxReconnectAttempts }));
reconnectTimeoutRef.current = setTimeout(() => { reconnectTimeoutRef.current = setTimeout(() => {
// Check again if component is still mounted and should reconnect // Check again if component is still mounted and should reconnect
if (isUnmountingRef.current || shouldNotReconnectRef.current) { if (isUnmountingRef.current || shouldNotReconnectRef.current) {
isReconnectingRef.current = false;
return; return;
} }
// Check if we haven't exceeded max attempts during the timeout
if (reconnectAttempts.current > maxReconnectAttempts) {
isReconnectingRef.current = false;
return;
}
if (terminal && hostConfig) { if (terminal && hostConfig) {
// Clear terminal before reconnecting // Ensure terminal is clear before reconnecting
terminal.clear(); terminal.clear();
const cols = terminal.cols; const cols = terminal.cols;
const rows = terminal.rows; const rows = terminal.rows;
connectToHost(cols, rows); connectToHost(cols, rows);
} }
// Reset reconnecting flag after attempting connection
isReconnectingRef.current = false;
}, 2000 * reconnectAttempts.current); // Exponential backoff }, 2000 * reconnectAttempts.current); // Exponential backoff
} }
@@ -187,6 +218,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
wasDisconnectedBySSH.current = false; wasDisconnectedBySSH.current = false;
setConnectionError(null); setConnectionError(null);
shouldNotReconnectRef.current = false; // Reset reconnection flag shouldNotReconnectRef.current = false; // Reset reconnection flag
isReconnectingRef.current = false; // Reset reconnecting flag
setIsConnecting(true); // Set connecting state
setupWebSocketListeners(ws, cols, rows); setupWebSocketListeners(ws, cols, rows);
} }
@@ -195,12 +228,27 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) { function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
ws.addEventListener('open', () => { ws.addEventListener('open', () => {
setIsConnected(true); // Don't set isConnected to true here - wait for actual SSH connection
// Show reconnected toast if this was a reconnection attempt // Don't show reconnected toast here - wait for actual connection confirmation
if (reconnectAttempts.current > 0) {
toast.success(t('terminal.reconnected')); // Set a timeout for SSH connection establishment
} connectionTimeoutRef.current = setTimeout(() => {
reconnectAttempts.current = 0; // Reset on successful connection if (!isConnected) {
// SSH connection didn't establish within timeout
// Clear terminal immediately when connection times out
if (terminal) {
terminal.clear();
}
toast.error(t('terminal.connectionTimeout'));
if (webSocketRef.current) {
webSocketRef.current.close();
}
// Attempt reconnection if this was a reconnection attempt
if (reconnectAttempts.current > 0) {
attemptReconnection();
}
}
}, 10000); // 10 second timeout for SSH connection
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}})); ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
terminal.onData((data) => { terminal.onData((data) => {
@@ -250,6 +298,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
errorMessage.toLowerCase().includes('network')) { errorMessage.toLowerCase().includes('network')) {
toast.error(t('terminal.connectionError', { message: errorMessage })); toast.error(t('terminal.connectionError', { message: errorMessage }));
setIsConnected(false); setIsConnected(false);
// Clear terminal immediately when connection error occurs
if (terminal) {
terminal.clear();
}
// Set connecting state immediately for reconnection
setIsConnecting(true);
attemptReconnection(); attemptReconnection();
return; return;
} }
@@ -258,9 +312,28 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
toast.error(t('terminal.error', { message: errorMessage })); toast.error(t('terminal.error', { message: errorMessage }));
} else if (msg.type === 'connected') { } else if (msg.type === 'connected') {
setIsConnected(true); setIsConnected(true);
setIsConnecting(false); // Clear connecting state
// Clear connection timeout since SSH connection is established
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
// Show reconnected toast if this was a reconnection attempt
if (reconnectAttempts.current > 0) {
toast.success(t('terminal.reconnected'));
}
// Reset reconnection counter and flags on successful connection
reconnectAttempts.current = 0;
isReconnectingRef.current = false;
} else if (msg.type === 'disconnected') { } else if (msg.type === 'disconnected') {
wasDisconnectedBySSH.current = true; wasDisconnectedBySSH.current = true;
setIsConnected(false); setIsConnected(false);
// Clear terminal immediately when disconnected
if (terminal) {
terminal.clear();
}
// Set connecting state immediately for reconnection
setIsConnecting(true);
// Attempt reconnection for disconnections // Attempt reconnection for disconnections
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) { if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
attemptReconnection(); attemptReconnection();
@@ -273,6 +346,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
ws.addEventListener('close', (event) => { ws.addEventListener('close', (event) => {
setIsConnected(false); setIsConnected(false);
// Clear terminal immediately when connection closes
if (terminal) {
terminal.clear();
}
// Set connecting state immediately for reconnection
setIsConnecting(true);
if (!wasDisconnectedBySSH.current && !isUnmountingRef.current && !shouldNotReconnectRef.current) { if (!wasDisconnectedBySSH.current && !isUnmountingRef.current && !shouldNotReconnectRef.current) {
// Attempt reconnection for unexpected disconnections // Attempt reconnection for unexpected disconnections
attemptReconnection(); attemptReconnection();
@@ -282,6 +361,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
ws.addEventListener('error', (event) => { ws.addEventListener('error', (event) => {
setIsConnected(false); setIsConnected(false);
setConnectionError(t('terminal.websocketError')); setConnectionError(t('terminal.websocketError'));
// Clear terminal immediately when WebSocket error occurs
if (terminal) {
terminal.clear();
}
// Set connecting state immediately for reconnection
setIsConnecting(true);
// Attempt reconnection for WebSocket errors // Attempt reconnection for WebSocket errors
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) { if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
attemptReconnection(); attemptReconnection();
@@ -429,11 +514,14 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
return () => { return () => {
isUnmountingRef.current = true; isUnmountingRef.current = true;
shouldNotReconnectRef.current = true; shouldNotReconnectRef.current = true;
isReconnectingRef.current = false;
setIsConnecting(false); // Clear connecting state
resizeObserver.disconnect(); resizeObserver.disconnect();
element?.removeEventListener('contextmenu', handleContextMenu); element?.removeEventListener('contextmenu', handleContextMenu);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current); if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
if (resizeTimeout.current) clearTimeout(resizeTimeout.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current); if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
if (connectionTimeoutRef.current) clearTimeout(connectionTimeoutRef.current);
if (pingIntervalRef.current) { if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current); clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null; pingIntervalRef.current = null;
@@ -474,15 +562,28 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}, [splitScreen, isVisible, terminal]); }, [splitScreen, isVisible, terminal]);
return ( return (
<div <div className="h-full w-full m-1 relative">
ref={xtermRef} {/* Terminal */}
className={`h-full w-full m-1 transition-opacity duration-200 ${visible && isVisible ? 'opacity-100' : 'opacity-0'} overflow-hidden`} <div
onClick={() => { ref={xtermRef}
if (terminal && !splitScreen) { className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? 'opacity-100' : 'opacity-0'} overflow-hidden`}
terminal.focus(); onClick={() => {
} if (terminal && !splitScreen) {
}} terminal.focus();
/> }
}}
/>
{/* Connecting State */}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-dark-bg">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">{t('terminal.connecting')}</span>
</div>
</div>
)}
</div>
); );
}); });

View File

@@ -115,7 +115,6 @@ function AppContent() {
isAuthenticated={isAuthenticated} isAuthenticated={isAuthenticated}
authLoading={authLoading} authLoading={authLoading}
onAuthSuccess={handleAuthSuccess} onAuthSuccess={handleAuthSuccess}
isTopbarOpen={isTopbarOpen}
/> />
</div> </div>
)} )}

View File

@@ -11,7 +11,6 @@ interface HomepageProps {
isAuthenticated: boolean; isAuthenticated: boolean;
authLoading: boolean; authLoading: boolean;
onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void; onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; userId: string | null }) => void;
isTopbarOpen?: boolean;
} }
function getCookie(name: string) { function getCookie(name: string) {
@@ -30,8 +29,7 @@ export function Homepage({
onSelectView, onSelectView,
isAuthenticated, isAuthenticated,
authLoading, authLoading,
onAuthSuccess, onAuthSuccess
isTopbarOpen = true
}: HomepageProps): React.ReactElement { }: HomepageProps): React.ReactElement {
const {t} = useTranslation(); const {t} = useTranslation();
const [loggedIn, setLoggedIn] = useState(isAuthenticated); const [loggedIn, setLoggedIn] = useState(isAuthenticated);
@@ -72,82 +70,64 @@ export function Homepage({
} }
}, [isAuthenticated]); }, [isAuthenticated]);
const topOffset = isTopbarOpen ? 66 : 0;
const topPadding = isTopbarOpen ? 66 : 0;
return ( return (
<div <div className="w-full h-full flex items-center justify-center">
className="w-full min-h-svh relative transition-[padding-top] duration-300 ease-in-out"
style={{ paddingTop: `${topPadding}px` }}>
{!loggedIn ? ( {!loggedIn ? (
<div <HomepageAuth
className="absolute left-0 w-full flex items-center justify-center" setLoggedIn={setLoggedIn}
style={{ setIsAdmin={setIsAdmin}
top: `${topOffset}px`, setUsername={setUsername}
height: `calc(100% - ${topOffset}px)` setUserId={setUserId}
}}> loggedIn={loggedIn}
<HomepageAuth authLoading={authLoading}
setLoggedIn={setLoggedIn} dbError={dbError}
setIsAdmin={setIsAdmin} setDbError={setDbError}
setUsername={setUsername} onAuthSuccess={onAuthSuccess}
setUserId={setUserId} />
loggedIn={loggedIn}
authLoading={authLoading}
dbError={dbError}
setDbError={setDbError}
onAuthSuccess={onAuthSuccess}
/>
</div>
) : ( ) : (
<div <div className="flex flex-row items-center justify-center gap-8 relative z-10">
className="absolute left-0 w-full flex items-center justify-center" <div className="flex flex-col items-center gap-6 w-[400px]">
style={{ <HomepageUpdateLog
top: `${topOffset}px`, loggedIn={loggedIn}
height: `calc(100% - ${topOffset}px)` />
}}>
<div className="flex flex-row items-center justify-center gap-8 relative z-10">
<div className="flex flex-col items-center gap-6 w-[400px]">
<HomepageUpdateLog
loggedIn={loggedIn}
/>
<div className="flex flex-row items-center gap-3"> <div className="flex flex-row items-center gap-3">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors" className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')} onClick={() => window.open('https://github.com/LukeGus/Termix', '_blank')}
> >
GitHub GitHub
</Button> </Button>
<div className="w-px h-4 bg-dark-border"></div> <div className="w-px h-4 bg-dark-border"></div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors" className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')} onClick={() => window.open('https://github.com/LukeGus/Termix/issues/new', '_blank')}
> >
Feedback Feedback
</Button> </Button>
<div className="w-px h-4 bg-dark-border"></div> <div className="w-px h-4 bg-dark-border"></div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors" className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')} onClick={() => window.open('https://discord.com/invite/jVQGdvHDrf', '_blank')}
> >
Discord Discord
</Button> </Button>
<div className="w-px h-4 bg-dark-border"></div> <div className="w-px h-4 bg-dark-border"></div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors" className="text-sm border-dark-border text-gray-300 hover:text-white hover:bg-dark-bg transition-colors"
onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')} onClick={() => window.open('https://github.com/sponsors/LukeGus', '_blank')}
> >
Donate Donate
</Button> </Button>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -237,8 +237,13 @@ function createApiInstance(baseURL: string, serviceName: string = 'API'): AxiosI
// Handle auth token clearing // Handle auth token clearing
if (status === 401) { if (status === 401) {
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;'; const isElectron = (window as any).IS_ELECTRON === true || (window as any).electronAPI?.isElectron === true;
localStorage.removeItem('jwt'); if (isElectron) {
localStorage.removeItem('jwt');
} else {
document.cookie = 'jwt=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
localStorage.removeItem('jwt');
}
} }
return Promise.reject(error); return Promise.reject(error);