Add versioning system to electron, update nginx configurations for file uploads, fix UI issues in file manager

This commit is contained in:
LukeGus
2025-09-26 16:28:37 -05:00
parent 9b12515676
commit 0da4652a31
13 changed files with 501 additions and 10 deletions

View File

@@ -21,6 +21,15 @@ services:
PORT: "4300"
ENABLE_SSL: "true"
SSL_DOMAIN: "termix-dev.karmaa.site"
# Resource limits for better large file handling
deploy:
resources:
limits:
memory: 2G
cpus: '1.0'
reservations:
memory: 512M
cpus: '0.5'
volumes:
termix-dev-data:

View File

@@ -186,12 +186,23 @@ http {
}
location /ssh/file_manager/ssh/ {
client_max_body_size 5G;
client_body_timeout 300s;
client_header_timeout 300s;
proxy_pass http://127.0.0.1:30004;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_request_buffering off;
proxy_buffering off;
}
location /health {

View File

@@ -172,12 +172,23 @@ http {
}
location /ssh/file_manager/ssh/ {
client_max_body_size 5G;
client_body_timeout 300s;
client_header_timeout 300s;
proxy_pass http://127.0.0.1:30004;
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;
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
proxy_request_buffering off;
proxy_buffering off;
}
location /health {

View File

@@ -97,6 +97,163 @@ ipcMain.handle("get-app-version", () => {
return app.getVersion();
});
// GitHub API service for version checking
const GITHUB_API_BASE = "https://api.github.com";
const REPO_OWNER = "LukeGus";
const REPO_NAME = "Termix";
// Simple cache for GitHub API responses
const githubCache = new Map();
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes
async function fetchGitHubAPI(endpoint, cacheKey) {
// Check cache first
const cached = githubCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < CACHE_DURATION) {
return {
data: cached.data,
cached: true,
cache_age: Date.now() - cached.timestamp,
};
}
try {
let fetch;
try {
fetch = globalThis.fetch || require("node-fetch");
} catch (e) {
const https = require("https");
const http = require("http");
const { URL } = require("url");
fetch = (url, options = {}) => {
return new Promise((resolve, reject) => {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === "https:";
const client = isHttps ? https : http;
const req = client.request(
url,
{
method: options.method || "GET",
headers: options.headers || {},
timeout: options.timeout || 10000,
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 300,
status: res.statusCode,
text: () => Promise.resolve(data),
json: () => Promise.resolve(JSON.parse(data)),
});
});
},
);
req.on("error", reject);
req.on("timeout", () => {
req.destroy();
reject(new Error("Request timeout"));
});
if (options.body) {
req.write(options.body);
}
req.end();
});
};
}
const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, {
headers: {
Accept: "application/vnd.github+json",
"User-Agent": "TermixElectronUpdateChecker/1.0",
"X-GitHub-Api-Version": "2022-11-28",
},
timeout: 10000,
});
if (!response.ok) {
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const data = await response.json();
// Cache the response
githubCache.set(cacheKey, {
data,
timestamp: Date.now(),
});
return {
data: data,
cached: false,
};
} catch (error) {
console.error("Failed to fetch from GitHub API:", error);
throw error;
}
}
// Check for Electron app updates
ipcMain.handle("check-electron-update", async () => {
try {
const localVersion = app.getVersion();
console.log(`Checking for updates. Local version: ${localVersion}`);
const releaseData = await fetchGitHubAPI(
`/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`,
"latest_release_electron"
);
const rawTag = releaseData.data.tag_name || releaseData.data.name || "";
const remoteVersionMatch = rawTag.match(/(\d+\.\d+(\.\d+)?)/);
const remoteVersion = remoteVersionMatch ? remoteVersionMatch[1] : null;
if (!remoteVersion) {
console.warn("Remote version not found in GitHub response:", rawTag);
return {
success: false,
error: "Remote version not found",
localVersion,
};
}
const isUpToDate = localVersion === remoteVersion;
const result = {
success: true,
status: isUpToDate ? "up_to_date" : "requires_update",
localVersion: localVersion,
remoteVersion: remoteVersion,
latest_release: {
tag_name: releaseData.data.tag_name,
name: releaseData.data.name,
published_at: releaseData.data.published_at,
html_url: releaseData.data.html_url,
body: releaseData.data.body,
},
cached: releaseData.cached,
cache_age: releaseData.cache_age,
};
console.log(`Version check result: ${result.status}`);
return result;
} catch (error) {
console.error("Version check failed:", error);
return {
success: false,
error: error.message,
localVersion: app.getVersion(),
};
}
});
ipcMain.handle("get-platform", () => {
return process.platform;
});

View File

@@ -3,6 +3,7 @@ const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electronAPI", {
getAppVersion: () => ipcRenderer.invoke("get-app-version"),
getPlatform: () => ipcRenderer.invoke("get-platform"),
checkElectronUpdate: () => ipcRenderer.invoke("check-electron-update"),
getServerConfig: () => ipcRenderer.invoke("get-server-config"),
saveServerConfig: (config) =>

View File

@@ -860,7 +860,22 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
.json({ error: "File path, name, and content are required" });
}
// Update last active time and extend keepalive for large file operations
sshConn.lastActive = Date.now();
// For large files, extend the keepalive interval to prevent connection drops
const contentSize = typeof content === 'string' ? Buffer.byteLength(content, 'utf8') : content.length;
if (contentSize > 10 * 1024 * 1024) { // 10MB threshold
fileLogger.info("Large file upload detected, extending SSH keepalive", {
operation: "file_upload",
sessionId,
fileName,
fileSize: contentSize,
});
// Extend keepalive interval for large files
// Note: SSH2 client doesn't expose config directly, but we can set keepalive
sshConn.client.setKeepAlive(true, 10000); // 10 seconds
}
const fullPath = filePath.endsWith("/")
? filePath + fileName
@@ -906,6 +921,13 @@ app.post("/ssh/file_manager/ssh/uploadFile", async (req, res) => {
hasError = true;
fileLogger.warn(
`SFTP write failed, trying fallback method: ${streamErr.message}`,
{
operation: "file_upload",
sessionId,
fileName,
fileSize: contentSize,
error: streamErr.message,
}
);
tryFallbackMethod();
});

View File

@@ -0,0 +1,122 @@
import React from "react";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import { Button } from "@/components/ui/button.tsx";
import { ExternalLink, Download, X, AlertTriangle } from "lucide-react";
import { useTranslation } from "react-i18next";
interface VersionAlertProps {
updateInfo: {
success: boolean;
status?: "up_to_date" | "requires_update";
localVersion?: string;
remoteVersion?: string;
latest_release?: {
tag_name: string;
name: string;
published_at: string;
html_url: string;
body: string;
};
cached?: boolean;
cache_age?: number;
error?: string;
};
onDismiss?: () => void;
onDownload?: () => void;
showDismiss?: boolean;
}
export function VersionAlert({
updateInfo,
onDismiss,
onDownload,
showDismiss = true,
}: VersionAlertProps) {
const { t } = useTranslation();
if (!updateInfo.success) {
return (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t("versionCheck.error")}</AlertTitle>
<AlertDescription>
{updateInfo.error || t("versionCheck.checkFailed")}
</AlertDescription>
</Alert>
);
}
if (updateInfo.status === "up_to_date") {
return (
<Alert>
<Download className="h-4 w-4" />
<AlertTitle>{t("versionCheck.upToDate")}</AlertTitle>
<AlertDescription>
{t("versionCheck.currentVersion", { version: updateInfo.localVersion })}
</AlertDescription>
</Alert>
);
}
if (updateInfo.status === "requires_update") {
return (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>{t("versionCheck.updateAvailable")}</AlertTitle>
<AlertDescription className="space-y-3">
<div>
{t("versionCheck.newVersionAvailable", {
current: updateInfo.localVersion,
latest: updateInfo.remoteVersion,
})}
</div>
{updateInfo.latest_release && (
<div className="text-sm text-muted-foreground">
<div className="font-medium">{updateInfo.latest_release.name}</div>
<div className="text-xs">
{t("versionCheck.releasedOn", {
date: new Date(updateInfo.latest_release.published_at).toLocaleDateString(),
})}
</div>
</div>
)}
<div className="flex gap-2 pt-2">
{updateInfo.latest_release?.html_url && (
<Button
variant="outline"
size="sm"
onClick={() => {
if (onDownload) {
onDownload();
} else {
window.open(updateInfo.latest_release!.html_url, "_blank");
}
}}
className="flex items-center gap-1"
>
<ExternalLink className="h-3 w-3" />
{t("versionCheck.downloadUpdate")}
</Button>
)}
{showDismiss && onDismiss && (
<Button
variant="ghost"
size="sm"
onClick={onDismiss}
className="flex items-center gap-1"
>
<X className="h-3 w-3" />
{t("versionCheck.dismiss")}
</Button>
)}
</div>
</AlertDescription>
</Alert>
);
}
return null;
}

View File

@@ -202,6 +202,19 @@
"saveConfig": "Save Configuration",
"helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)"
},
"versionCheck": {
"error": "Version Check Error",
"checkFailed": "Failed to check for updates",
"upToDate": "App is Up to Date",
"currentVersion": "You are running version {{version}}",
"updateAvailable": "Update Available",
"newVersionAvailable": "A new version is available! You are running {{current}}, but {{latest}} is available.",
"releasedOn": "Released on {{date}}",
"downloadUpdate": "Download Update",
"dismiss": "Dismiss",
"checking": "Checking for updates...",
"checkUpdates": "Check for Updates"
},
"common": {
"close": "Close",
"minimize": "Minimize",
@@ -702,6 +715,7 @@
"uploading": "Uploading...",
"downloading": "Downloading...",
"uploadingFile": "Uploading {{name}}...",
"uploadingLargeFile": "Uploading large file {{name}} ({{size}})...",
"downloadingFile": "Downloading {{name}}...",
"creatingFile": "Creating {{name}}...",
"creatingFolder": "Creating {{name}}...",

View File

@@ -200,6 +200,19 @@
"saveConfig": "保存配置",
"helpText": "输入您的 Termix 服务器运行地址例如http://localhost:30001 或 https://your-server.com"
},
"versionCheck": {
"error": "版本检查错误",
"checkFailed": "检查更新失败",
"upToDate": "应用已是最新版本",
"currentVersion": "您正在运行版本 {{version}}",
"updateAvailable": "有可用更新",
"newVersionAvailable": "有新版本可用!您正在运行 {{current}},但 {{latest}} 已可用。",
"releasedOn": "发布于 {{date}}",
"downloadUpdate": "下载更新",
"dismiss": "忽略",
"checking": "正在检查更新...",
"checkUpdates": "检查更新"
},
"common": {
"close": "关闭",
"minimize": "最小化",
@@ -724,6 +737,7 @@
"uploading": "上传中...",
"downloading": "下载中...",
"uploadingFile": "正在上传 {{name}}...",
"uploadingLargeFile": "正在上传大文件 {{name}} ({{size}})...",
"downloadingFile": "正在下载 {{name}}...",
"creatingFile": "正在创建 {{name}}...",
"creatingFolder": "正在创建 {{name}}...",

View File

@@ -62,6 +62,24 @@ interface CreateIntent {
currentName: string;
}
// Format file size helper function
function formatFileSize(bytes?: number): string {
if (bytes === undefined || bytes === null) return "-";
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const formattedSize = size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
return `${formattedSize} ${units[unitIndex]}`;
}
// Internal component, uses window manager
function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const { openWindow } = useWindowManager();
@@ -226,13 +244,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const handleFileDragEnd = useCallback(
(e: DragEvent, draggedFiles: FileItem[]) => {
// More conservative detection - only trigger download if clearly outside window
const margin = 10; // Very small margin to reduce false positives
// Allow dragging off screen on all sides - only check if completely outside window bounds
const isOutside =
e.clientX < margin ||
e.clientX > window.innerWidth - margin ||
e.clientY < margin ||
e.clientY > window.innerHeight - margin;
e.clientX < 0 ||
e.clientX > window.innerWidth ||
e.clientY < 0 ||
e.clientY > window.innerHeight;
// Only trigger download if clearly outside the window bounds
if (isOutside) {
@@ -478,6 +495,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
async function handleUploadFile(file: File) {
if (!sshSessionId) return;
// Show progress for large files (>10MB)
const isLargeFile = file.size > 10 * 1024 * 1024;
let progressToast: any = null;
if (isLargeFile) {
progressToast = toast.loading(
t("fileManager.uploadingLargeFile", {
name: file.name,
size: formatFileSize(file.size)
}),
{ duration: Infinity }
);
}
try {
// Ensure SSH connection is valid
await ensureSSHConnection();
@@ -493,8 +524,9 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
file.type === "application/json" ||
file.type === "application/javascript" ||
file.type === "application/xml" ||
file.type === "image/svg+xml" ||
file.name.match(
/\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i,
/\.(txt|json|js|ts|jsx|tsx|css|scss|less|html|htm|xml|svg|yaml|yml|md|markdown|mdown|mkdn|mdx|py|java|c|cpp|h|sh|bash|zsh|bat|ps1|toml|ini|conf|config|sql|vue|svelte)$/i,
);
if (isTextFile) {
@@ -532,11 +564,22 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
currentHost?.id,
undefined, // userId - will be handled by backend
);
// Dismiss progress toast if it exists
if (progressToast) {
toast.dismiss(progressToast);
}
toast.success(
t("fileManager.fileUploadedSuccessfully", { name: file.name }),
);
handleRefreshDirectory();
} catch (error: any) {
// Dismiss progress toast if it exists
if (progressToast) {
toast.dismiss(progressToast);
}
if (
error.message?.includes("connection") ||
error.message?.includes("established")

View File

@@ -1365,8 +1365,8 @@ export function FileManagerGrid({
<div
className="fixed pointer-events-none"
style={{
left: Math.min(Math.max(dragState.mousePosition.x + 40, 10), window.innerWidth - 300),
top: Math.max(Math.min(dragState.mousePosition.y - 80, window.innerHeight - 100), 10),
left: Math.min(Math.max(dragState.mousePosition.x + 40, 0), window.innerWidth - 300),
top: Math.max(Math.min(dragState.mousePosition.y - 80, window.innerHeight - 100), 0),
zIndex: 999999,
}}
>

View File

@@ -3,14 +3,16 @@ import { Button } from "@/components/ui/button.tsx";
import { Input } from "@/components/ui/input.tsx";
import { Label } from "@/components/ui/label.tsx";
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx";
import { VersionAlert } from "@/components/ui/version-alert.tsx";
import { useTranslation } from "react-i18next";
import {
getServerConfig,
saveServerConfig,
testServerConnection,
checkElectronUpdate,
type ServerConfig,
} from "@/ui/main-axios.ts";
import { CheckCircle, XCircle, Server, Wifi } from "lucide-react";
import { CheckCircle, XCircle, Server, Wifi, RefreshCw } from "lucide-react";
interface ServerConfigProps {
onServerConfigured: (serverUrl: string) => void;
@@ -31,9 +33,13 @@ export function ServerConfig({
const [connectionStatus, setConnectionStatus] = useState<
"unknown" | "success" | "error"
>("unknown");
const [versionInfo, setVersionInfo] = useState<any>(null);
const [versionChecking, setVersionChecking] = useState(false);
const [versionDismissed, setVersionDismissed] = useState(false);
useEffect(() => {
loadServerConfig();
checkForUpdates();
}, []);
const loadServerConfig = async () => {
@@ -46,6 +52,29 @@ export function ServerConfig({
} catch (error) {}
};
const checkForUpdates = async () => {
setVersionChecking(true);
try {
const updateInfo = await checkElectronUpdate();
setVersionInfo(updateInfo);
} catch (error) {
console.error("Failed to check for updates:", error);
setVersionInfo({ success: false, error: "Check failed" });
} finally {
setVersionChecking(false);
}
};
const handleVersionDismiss = () => {
setVersionDismissed(true);
};
const handleDownloadUpdate = () => {
if (versionInfo?.latest_release?.html_url) {
window.open(versionInfo.latest_release.html_url, "_blank");
}
};
const handleTestConnection = async () => {
if (!serverUrl.trim()) {
setError(t("serverConfig.enterServerUrl"));
@@ -195,6 +224,34 @@ export function ServerConfig({
</Alert>
)}
{/* Version Check Section */}
{versionInfo && !versionDismissed && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{t("versionCheck.checkUpdates")}</h3>
<Button
variant="ghost"
size="sm"
onClick={checkForUpdates}
disabled={versionChecking}
className="h-6 px-2"
>
{versionChecking ? (
<div className="w-3 h-3 border border-current border-t-transparent rounded-full animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
</Button>
</div>
<VersionAlert
updateInfo={versionInfo}
onDismiss={handleVersionDismiss}
onDownload={handleDownloadUpdate}
showDismiss={true}
/>
</div>
)}
<div className="flex space-x-2">
{onCancel && !isFirstTime && (
<Button

View File

@@ -389,6 +389,36 @@ export async function testServerConnection(
}
}
export async function checkElectronUpdate(): Promise<{
success: boolean;
status?: "up_to_date" | "requires_update";
localVersion?: string;
remoteVersion?: string;
latest_release?: {
tag_name: string;
name: string;
published_at: string;
html_url: string;
body: string;
};
cached?: boolean;
cache_age?: number;
error?: string;
}> {
if (!isElectron())
return { success: false, error: "Not in Electron environment" };
try {
const result = await (window as any).electronAPI?.invoke(
"check-electron-update",
);
return result;
} catch (error) {
console.error("Failed to check Electron update:", error);
return { success: false, error: "Update check failed" };
}
}
if (isElectron()) {
getServerConfig().then((config) => {
if (config?.serverUrl) {