feat: add listening ports widget #483

Merged
ZacharyZcR merged 2 commits from feat/ports-widget into dev-1.10.1 2026-01-12 07:46:06 +00:00
56 changed files with 68494 additions and 145 deletions
Showing only changes of commit f154a2490b - Show all commits

View File

@@ -16,17 +16,6 @@
<small style="color: #666;">Achieved on September 1st, 2025</small>
</p>
#### Top Technologies
[![React Badge](https://img.shields.io/badge/-React-61DBFB?style=flat-square&labelColor=black&logo=react&logoColor=61DBFB)](#)
[![TypeScript Badge](https://img.shields.io/badge/-TypeScript-3178C6?style=flat-square&labelColor=black&logo=typescript&logoColor=3178C6)](#)
[![Node.js Badge](https://img.shields.io/badge/-Node.js-3C873A?style=flat-square&labelColor=black&logo=node.js&logoColor=3C873A)](#)
[![Vite Badge](https://img.shields.io/badge/-Vite-646CFF?style=flat-square&labelColor=black&logo=vite&logoColor=646CFF)](#)
[![Tailwind CSS Badge](https://img.shields.io/badge/-TailwindCSS-38B2AC?style=flat-square&labelColor=black&logo=tailwindcss&logoColor=38B2AC)](#)
[![Docker Badge](https://img.shields.io/badge/-Docker-2496ED?style=flat-square&labelColor=black&logo=docker&logoColor=2496ED)](#)
[![SQLite Badge](https://img.shields.io/badge/-SQLite-003B57?style=flat-square&labelColor=black&logo=sqlite&logoColor=003B57)](#)
[![Radix UI Badge](https://img.shields.io/badge/-Radix%20UI-161618?style=flat-square&labelColor=black&logo=radixui&logoColor=161618)](#)
<br />
<p align="center">
<a href="https://github.com/Termix-SSH/Termix">
@@ -45,7 +34,7 @@ If you would like, you can support the project here!\
Termix is an open-source, forever-free, self-hosted all-in-one server management platform. It provides a multi-platform
solution for managing your servers and infrastructure through a single, intuitive interface. Termix offers SSH terminal
access, SSH tunneling capabilities, remote file management, and many other tools. Termix is the perfect
access, SSH tunneling capabilities, and remote file management, with many more tools to come. Termix is the perfect
free and self-hosted alternative to Termius available for all platforms.
# Features
@@ -53,22 +42,20 @@ free and self-hosted alternative to Termius available for all platforms.
- **SSH Terminal Access** - Full-featured terminal with split-screen support (up to 4 panels) with a browser-like tab system. Includes support for customizing the terminal including common terminal themes, fonts, and other components
- **SSH Tunnel Management** - Create and manage SSH tunnels with automatic reconnection and health monitoring
- **Remote File Manager** - Manage files directly on remote servers with support for viewing and editing code, images, audio, and video. Upload, download, rename, delete, and move files seamlessly
- **Docker Management** - Start, stop, pause, remove containers. View container stats. Control container using docker exec terminal. It was not made to replace Portainer or Dockge but rather to simply manage your containers compared to creating them.
- **SSH Host Manager** - Save, organize, and manage your SSH connections with tags and folders, and easily save reusable login info while being able to automate the deployment of SSH keys
- **Server Stats** - View CPU, memory, and disk usage along with network, uptime, and system information on any SSH server
- **Dashboard** - View server information at a glance on your dashboard
- **RBAC** - Create roles and share hosts across users/roles
- **User Authentication** - Secure user management with admin controls and OIDC and 2FA (TOTP) support. View active user sessions across all platforms and revoke permissions. Link your OIDC/Local accounts together.
- **Database Encryption** - Backend stored as encrypted SQLite database files. View [docs](https://docs.termix.site/security) for more.
- **Data Export/Import** - Export and import SSH hosts, credentials, and file manager data
- **Automatic SSL Setup** - Built-in SSL certificate generation and management with HTTPS redirects
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn. Choose between dark or light mode based UI.
- **Languages** - Built-in support ~30 languages (bulk translated via Google Translate, results may vary ofc)
- **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn
- **Languages** - Built-in support for English, Chinese, German, and Portuguese
- **Platform Support** - Available as a web app, desktop application (Windows, Linux, and macOS), and dedicated mobile/tablet app for iOS and Android.
- **SSH Tools** - Create reusable command snippets that execute with a single click. Run one command simultaneously across multiple open terminals.
- **Command History** - Auto-complete and view previously ran SSH commands
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard
- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, SOCKS5, password autofill, etc.
- **SSH Feature Rich** - Supports jump hosts, warpgate, TOTP based connections, etc.
# Planned Features
@@ -120,13 +107,19 @@ volumes:
driver: local
```
# Sponsors
Thank you to [Digital Ocean](https://www.digitalocean.com/) for sponsoring Termix and covering our documentation server costs!
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_blue.svg" alt="Powered by DigitalOcean" width="300" height="200">
# Support
If you need help or want to request a feature with Termix, visit the [Issues](https://github.com/Termix-SSH/Support/issues) page, log in, and press `New Issue`.
Please be as detailed as possible in your issue, preferably written in English. You can also join the [Discord](https://discord.gg/jVQGdvHDrf) server and visit the support
channel, however, response times may be longer.
# Screenshots
# Show-off
<p align="center">
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/>
@@ -145,12 +138,6 @@ channel, however, response times may be longer.
<p align="center">
<img src="./repo-images/Image 7.png" width="400" alt="Termix Demo 7"/>
<img src="./repo-images/Image 8.png" width="400" alt="Termix Demo 8"/>
</p>
<p align="center">
<img src="./repo-images/Image 9.png" width="400" alt="Termix Demo 9"/>
<img src="./repo-images/Image 10.png" width="400" alt="Termix Demo 110"/>
</p>
<p align="center">
@@ -158,7 +145,7 @@ channel, however, response times may be longer.
Your browser does not support the video tag.
</video>
</p>
Some videos and images may be out of date or may not perfectly showcase features.
Videos and images may be out of date.
# License

View File

@@ -74,6 +74,9 @@ VOLUME ["/app/data"]
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD node -e "require('http').get('http://localhost:30001/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

View File

@@ -1,3 +1,5 @@
worker_processes 1;
master_process off;
pid /app/nginx/nginx.pid;
error_log /app/nginx/logs/error.log warn;

View File

@@ -1,3 +1,5 @@
worker_processes 1;
master_process off;
pid /app/nginx/nginx.pid;
error_log /app/nginx/logs/error.log warn;

View File

@@ -4,6 +4,13 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Termix" />
<link rel="apple-touch-icon" href="/icons/512x512.png" />
<link rel="manifest" href="/manifest.json" />
<title>Termix</title>
<style>
.hide-scrollbar {

40
public/manifest.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "Termix",
"short_name": "Termix",
"description": "A web-based server management platform with SSH terminal, tunneling, and file editing capabilities",
"theme_color": "#09090b",
"background_color": "#09090b",
"display": "standalone",
"orientation": "any",
"scope": "/",
"start_url": "/",
"icons": [
{
"src": "/icons/48x48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "/icons/64x64.png",
"sizes": "64x64",
"type": "image/png"
},
{
"src": "/icons/128x128.png",
"sizes": "128x128",
"type": "image/png"
},
{
"src": "/icons/256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "/icons/512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
],
"categories": ["utilities", "developer", "productivity"]
}

120
public/sw.js Normal file
View File

@@ -0,0 +1,120 @@
/**
* Termix Service Worker
* Handles caching for offline PWA support
*/
const CACHE_NAME = "termix-v1";
const STATIC_ASSETS = [
"/",
"/index.html",
"/manifest.json",
"/favicon.ico",
"/icons/48x48.png",
"/icons/128x128.png",
"/icons/256x256.png",
"/icons/512x512.png",
];
// Install event - cache static assets
self.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => {
console.log("[SW] Caching static assets");
return cache.addAll(STATIC_ASSETS);
})
.then(() => {
// Activate immediately without waiting
return self.skipWaiting();
}),
);
});
// Activate event - clean up old caches
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames
.filter((name) => name !== CACHE_NAME)
.map((name) => {
console.log("[SW] Deleting old cache:", name);
return caches.delete(name);
}),
);
})
.then(() => {
// Take control of all pages immediately
return self.clients.claim();
}),
);
});
// Fetch event - serve from cache, fall back to network
self.addEventListener("fetch", (event) => {
const { request } = event;
const url = new URL(request.url);
// Skip non-GET requests
if (request.method !== "GET") {
return;
}
// Skip API requests - these must be online
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/ws")) {
return;
}
// Skip cross-origin requests
if (url.origin !== self.location.origin) {
return;
}
// For navigation requests (HTML), use network-first
if (request.mode === "navigate") {
event.respondWith(
fetch(request)
.then((response) => {
// Clone and cache the response
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
return response;
})
.catch(() => {
// Offline: return cached index.html
return caches.match("/index.html");
}),
);
return;
}
// For all other assets, use cache-first
event.respondWith(
caches.match(request).then((cachedResponse) => {
if (cachedResponse) {
return cachedResponse;
}
// Not in cache, fetch from network
return fetch(request).then((response) => {
// Don't cache non-successful responses
if (!response || response.status !== 200 || response.type !== "basic") {
return response;
}
// Clone and cache the response
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
return response;
});
}),
);
});

View File

@@ -852,7 +852,7 @@ router.get(
socks5ProxyChain: sshData.socks5ProxyChain,
ownerId: sshData.userId,
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL`,
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL AND ${sshData.userId} != ${userId}`,
permissionLevel: hostAccess.permissionLevel,
expiresAt: hostAccess.expiresAt,
})
@@ -1700,8 +1700,9 @@ async function resolveHostCredentials(
if (requestingUserId && requestingUserId !== ownerId) {
try {
const { SharedCredentialManager } =
await import("../../utils/shared-credential-manager.js");
const { SharedCredentialManager } = await import(
"../../utils/shared-credential-manager.js"
);
const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser(
host.id as number,

View File

@@ -20,6 +20,10 @@ import {
commandHistory,
roles,
userRoles,
hostAccess,
sharedCredentials,
auditLogs,
sessionRecordings,
} from "../db/schema.js";
import { eq, and } from "drizzle-orm";
import bcrypt from "bcryptjs";
@@ -141,6 +145,29 @@ const requireAdmin = authManager.createAdminMiddleware();
async function deleteUserAndRelatedData(userId: string): Promise<void> {
try {
// Delete shared credentials first (depends on hostAccess)
await db
.delete(sharedCredentials)
.where(eq(sharedCredentials.targetUserId, userId));
// Delete session recordings (depends on hostAccess)
await db
.delete(sessionRecordings)
.where(eq(sessionRecordings.userId, userId));
// Delete host access records (both granted by and granted to this user)
await db.delete(hostAccess).where(eq(hostAccess.userId, userId));
await db.delete(hostAccess).where(eq(hostAccess.grantedBy, userId));
// Delete sessions
await db.delete(sessions).where(eq(sessions.userId, userId));
// Delete user roles
await db.delete(userRoles).where(eq(userRoles.userId, userId));
// Delete audit logs
await db.delete(auditLogs).where(eq(auditLogs.userId, userId));
await db
.delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, userId));

View File

@@ -44,6 +44,58 @@ function isExecutableFile(permissions: string, fileName: string): boolean {
);
}
function modeToPermissions(mode: number): string {
const S_IFDIR = 0o040000;
const S_IFLNK = 0o120000;
const S_IFMT = 0o170000;
const type = mode & S_IFMT;
const prefix = type === S_IFDIR ? "d" : type === S_IFLNK ? "l" : "-";
const perms = [
mode & 0o400 ? "r" : "-",
mode & 0o200 ? "w" : "-",
mode & 0o100 ? "x" : "-",
mode & 0o040 ? "r" : "-",
mode & 0o020 ? "w" : "-",
mode & 0o010 ? "x" : "-",
mode & 0o004 ? "r" : "-",
mode & 0o002 ? "w" : "-",
mode & 0o001 ? "x" : "-",
].join("");
return prefix + perms;
}
function formatMtime(mtime: number): string {
const date = new Date(mtime * 1000);
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const month = months[date.getMonth()];
const day = date.getDate().toString().padStart(2, " ");
const now = new Date();
const sixMonthsAgo = new Date(now.getTime() - 180 * 24 * 60 * 60 * 1000);
if (date > sixMonthsAgo) {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${month} ${day} ${hours}:${minutes}`;
}
return `${month} ${day} ${date.getFullYear()}`;
}
const app = express();
app.use(
@@ -1152,88 +1204,187 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
sshConn.lastActive = Date.now();
sshConn.activeOperations++;
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
if (err) {
sshConn.activeOperations--;
fileLogger.error("SSH listFiles error:", err);
return res.status(500).json({ error: err.message });
}
let data = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on("close", (code) => {
sshConn.activeOperations--;
if (code !== 0) {
fileLogger.error(
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
return res.status(500).json({ error: `Command failed: ${errorData}` });
}
const lines = data.split("\n").filter((line) => line.trim());
const files = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const owner = parts[2];
const group = parts[3];
const size = parseInt(parts[4], 10);
let dateStr = "";
const nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
}
const name = parts.slice(nameStartIndex).join(" ");
const isDirectory = permissions.startsWith("d");
const isLink = permissions.startsWith("l");
if (name === "." || name === "..") continue;
let actualName = name;
let linkTarget = undefined;
if (isLink && name.includes(" -> ")) {
const linkParts = name.split(" -> ");
actualName = linkParts[0];
linkTarget = linkParts[1];
}
files.push({
name: actualName,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size,
modified: dateStr,
permissions,
owner,
group,
linkTarget,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, actualName)
: false,
});
const trySFTP = () => {
try {
sshConn.client.sftp((err, sftp) => {
if (err) {
fileLogger.warn(
`SFTP failed for listFiles, trying fallback: ${err.message}`,
);
tryFallbackMethod();
return;
}
sftp.readdir(sshPath, (readdirErr, list) => {
if (readdirErr) {
fileLogger.warn(
`SFTP readdir failed, trying fallback: ${readdirErr.message}`,
);
tryFallbackMethod();
return;
}
const symlinks: Array<{ index: number; path: string }> = [];
const files: Array<{
name: string;
type: string;
size: number | undefined;
modified: string;
permissions: string;
owner: string;
group: string;
linkTarget: string | undefined;
path: string;
executable: boolean;
}> = [];
for (const entry of list) {
if (entry.filename === "." || entry.filename === "..") continue;
const attrs = entry.attrs;
const permissions = modeToPermissions(attrs.mode);
const isDirectory = attrs.isDirectory();
const isLink = attrs.isSymbolicLink();
const fileEntry = {
name: entry.filename,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : attrs.size,
modified: formatMtime(attrs.mtime),
permissions,
owner: String(attrs.uid),
group: String(attrs.gid),
linkTarget: undefined as string | undefined,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${entry.filename}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, entry.filename)
: false,
};
if (isLink) {
symlinks.push({ index: files.length, path: fileEntry.path });
}
files.push(fileEntry);
}
if (symlinks.length === 0) {
sshConn.activeOperations--;
return res.json({ files, path: sshPath });
}
let resolved = 0;
for (const link of symlinks) {
sftp.readlink(link.path, (linkErr, target) => {
resolved++;
if (!linkErr && target) {
files[link.index].linkTarget = target;
}
if (resolved === symlinks.length) {
sshConn.activeOperations--;
res.json({ files, path: sshPath });
}
});
}
});
});
} catch (sftpErr: unknown) {
const errMsg =
sftpErr instanceof Error ? sftpErr.message : "Unknown error";
fileLogger.warn(`SFTP connection error, trying fallback: ${errMsg}`);
tryFallbackMethod();
}
};
const tryFallbackMethod = () => {
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
if (err) {
sshConn.activeOperations--;
fileLogger.error("SSH listFiles error:", err);
return res.status(500).json({ error: err.message });
}
res.json({ files, path: sshPath });
let data = "";
let errorData = "";
stream.on("data", (chunk: Buffer) => {
data += chunk.toString();
});
stream.stderr.on("data", (chunk: Buffer) => {
errorData += chunk.toString();
});
stream.on("close", (code) => {
sshConn.activeOperations--;
if (code !== 0) {
fileLogger.error(
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
);
return res
.status(500)
.json({ error: `Command failed: ${errorData}` });
}
const lines = data.split("\n").filter((line) => line.trim());
const files = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const parts = line.split(/\s+/);
if (parts.length >= 9) {
const permissions = parts[0];
const owner = parts[2];
const group = parts[3];
const size = parseInt(parts[4], 10);
let dateStr = "";
const nameStartIndex = 8;
if (parts[5] && parts[6] && parts[7]) {
dateStr = `${parts[5]} ${parts[6]} ${parts[7]}`;
}
const name = parts.slice(nameStartIndex).join(" ");
const isDirectory = permissions.startsWith("d");
const isLink = permissions.startsWith("l");
if (name === "." || name === "..") continue;
let actualName = name;
let linkTarget = undefined;
if (isLink && name.includes(" -> ")) {
const linkParts = name.split(" -> ");
actualName = linkParts[0];
linkTarget = linkParts[1];
}
files.push({
name: actualName,
type: isDirectory ? "directory" : isLink ? "link" : "file",
size: isDirectory ? undefined : size,
modified: dateStr,
permissions,
owner,
group,
linkTarget,
path: `${sshPath.endsWith("/") ? sshPath : sshPath + "/"}${actualName}`,
executable:
!isDirectory && !isLink
? isExecutableFile(permissions, actualName)
: false,
});
}
}
res.json({ files, path: sshPath });
});
});
});
};
trySFTP();
});
app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => {

View File

@@ -20,6 +20,7 @@ import { collectProcessesMetrics } from "./widgets/processes-collector.js";
import { collectSystemMetrics } from "./widgets/system-collector.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js";
import { collectPortsMetrics } from "./widgets/ports-collector.js";
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
import { createSocks5Connection } from "../utils/socks5-helper.js";
async function resolveJumpHost(
@@ -1802,6 +1803,35 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
} catch (e) {
statsLogger.debug("Failed to collect ports metrics", {
operation: "ports_metrics_failed",
let firewall: {
type: "iptables" | "nftables" | "none";
status: "active" | "inactive" | "unknown";
chains: Array<{
name: string;
policy: string;
rules: Array<{
chain: string;
target: string;
protocol: string;
source: string;
destination: string;
dport?: string;
sport?: string;
state?: string;
interface?: string;
extra?: string;
}>;
}>;
} = {
type: "none",
status: "unknown",
chains: [],
};
try {
firewall = await collectFirewallMetrics(client);
} catch (e) {
statsLogger.debug("Failed to collect firewall metrics", {
operation: "firewall_metrics_failed",
error: e instanceof Error ? e.message : String(e),
});
}
@@ -1816,6 +1846,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
system,
login_stats,
ports,
firewall,
};
metricsCache.set(host.id, result);

View File

@@ -648,7 +648,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
);
cleanupSSH(connectionTimeout);
}
}, 30000);
}, 120000);
let resolvedCredentials = { password, key, keyPassword, keyType, authType };
let authMethodNotAvailable = false;
@@ -761,6 +761,36 @@ wss.on("connection", async (ws: WebSocket, req) => {
return;
}
sshLogger.info("Creating shell", {
operation: "ssh_shell_start",
hostId: id,
ip,
port,
username,
});
let shellCallbackReceived = false;
const shellTimeout = setTimeout(() => {
if (!shellCallbackReceived && isShellInitializing) {
sshLogger.error("Shell creation timeout - no response from server", {
operation: "ssh_shell_timeout",
hostId: id,
ip,
port,
username,
});
isShellInitializing = false;
ws.send(
JSON.stringify({
type: "error",
message:
"Shell creation timeout. The server may not support interactive shells or the connection was interrupted.",
}),
);
cleanupSSH(connectionTimeout);
}
}, 15000);
conn.shell(
{
rows: data.rows,
@@ -768,6 +798,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
term: "xterm-256color",
} as PseudoTtyOptions,
(err, stream) => {
shellCallbackReceived = true;
clearTimeout(shellTimeout);
isShellInitializing = false;
if (err) {
@@ -784,6 +816,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
message: "Shell error: " + err.message,
}),
);
cleanupSSH(connectionTimeout);
return;
}
@@ -969,6 +1002,31 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.on("close", () => {
clearTimeout(connectionTimeout);
if (isShellInitializing || (isConnected && !sshStream)) {
sshLogger.warn("SSH connection closed during shell initialization", {
operation: "ssh_close_during_init",
hostId: id,
ip,
port,
username,
isShellInitializing,
hasStream: !!sshStream,
});
ws.send(
JSON.stringify({
type: "error",
message:
"Connection closed during shell initialization. The server may have rejected the shell request.",
}),
);
} else if (!sshStream) {
ws.send(
JSON.stringify({
type: "disconnected",
message: "Connection closed",
}),
);
}
cleanupSSH(connectionTimeout);
});
@@ -1115,10 +1173,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
tryKeyboard: true,
keepaliveInterval: 30000,
keepaliveCountMax: 3,
readyTimeout: 30000,
readyTimeout: 120000,
tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000,
timeout: 30000,
timeout: 120000,
env: {
TERM: "xterm-256color",
LANG: "en_US.UTF-8",

View File

@@ -0,0 +1,254 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "../../../types/stats-widgets.js";
function parseIptablesRule(line: string): FirewallRule | null {
if (!line.startsWith("-A ")) return null;
const rule: FirewallRule = {
chain: "",
target: "",
protocol: "all",
source: "0.0.0.0/0",
destination: "0.0.0.0/0",
};
const chainMatch = line.match(/^-A\s+(\S+)/);
if (chainMatch) {
rule.chain = chainMatch[1];
}
const targetMatch = line.match(/-j\s+(\S+)/);
if (targetMatch) {
rule.target = targetMatch[1];
}
const protocolMatch = line.match(/-p\s+(\S+)/);
if (protocolMatch) {
rule.protocol = protocolMatch[1];
}
const sourceMatch = line.match(/-s\s+(\S+)/);
if (sourceMatch) {
rule.source = sourceMatch[1];
}
const destMatch = line.match(/-d\s+(\S+)/);
if (destMatch) {
rule.destination = destMatch[1];
}
const dportMatch = line.match(/--dport\s+(\S+)/);
if (dportMatch) {
rule.dport = dportMatch[1];
}
const sportMatch = line.match(/--sport\s+(\S+)/);
if (sportMatch) {
rule.sport = sportMatch[1];
}
const stateMatch = line.match(/--state\s+(\S+)/);
if (stateMatch) {
rule.state = stateMatch[1];
}
const interfaceMatch = line.match(/-i\s+(\S+)/);
if (interfaceMatch) {
rule.interface = interfaceMatch[1];
}
return rule;
}
function parseIptablesOutput(output: string): FirewallChain[] {
const chains: Map<string, FirewallChain> = new Map();
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
const policyMatch = trimmed.match(/^:(\S+)\s+(\S+)/);
if (policyMatch) {
const [, chainName, policy] = policyMatch;
chains.set(chainName, {
name: chainName,
policy: policy,
rules: [],
});
continue;
}
const rule = parseIptablesRule(trimmed);
if (rule) {
let chain = chains.get(rule.chain);
if (!chain) {
chain = {
name: rule.chain,
policy: "ACCEPT",
rules: [],
};
chains.set(rule.chain, chain);
}
chain.rules.push(rule);
}
}
return Array.from(chains.values());
}
function parseNftablesOutput(output: string): FirewallChain[] {
const chains: FirewallChain[] = [];
let currentChain: FirewallChain | null = null;
const lines = output.split("\n");
for (const line of lines) {
const trimmed = line.trim();
const chainMatch = trimmed.match(
/chain\s+(\S+)\s*\{?\s*(?:type\s+\S+\s+hook\s+(\S+))?/,
);
if (chainMatch) {
if (currentChain) {
chains.push(currentChain);
}
currentChain = {
name: chainMatch[1].toUpperCase(),
policy: "ACCEPT",
rules: [],
};
continue;
}
if (currentChain && trimmed.startsWith("policy ")) {
const policyMatch = trimmed.match(/policy\s+(\S+)/);
if (policyMatch) {
currentChain.policy = policyMatch[1].toUpperCase();
}
continue;
}
if (currentChain && trimmed && !trimmed.startsWith("}")) {
const rule: FirewallRule = {
chain: currentChain.name,
target: "",
protocol: "all",
source: "0.0.0.0/0",
destination: "0.0.0.0/0",
};
if (trimmed.includes("accept")) rule.target = "ACCEPT";
else if (trimmed.includes("drop")) rule.target = "DROP";
else if (trimmed.includes("reject")) rule.target = "REJECT";
const tcpMatch = trimmed.match(/tcp\s+dport\s+(\S+)/);
if (tcpMatch) {
rule.protocol = "tcp";
rule.dport = tcpMatch[1];
}
const udpMatch = trimmed.match(/udp\s+dport\s+(\S+)/);
if (udpMatch) {
rule.protocol = "udp";
rule.dport = udpMatch[1];
}
const saddrMatch = trimmed.match(/saddr\s+(\S+)/);
if (saddrMatch) {
rule.source = saddrMatch[1];
}
const daddrMatch = trimmed.match(/daddr\s+(\S+)/);
if (daddrMatch) {
rule.destination = daddrMatch[1];
}
const iifMatch = trimmed.match(/iif\s+"?(\S+)"?/);
if (iifMatch) {
rule.interface = iifMatch[1].replace(/"/g, "");
}
const ctStateMatch = trimmed.match(/ct\s+state\s+(\S+)/);
if (ctStateMatch) {
rule.state = ctStateMatch[1].toUpperCase();
}
if (rule.target) {
currentChain.rules.push(rule);
}
}
if (trimmed === "}") {
if (currentChain) {
chains.push(currentChain);
currentChain = null;
}
}
}
if (currentChain) {
chains.push(currentChain);
}
return chains;
}
export async function collectFirewallMetrics(
client: Client,
): Promise<FirewallMetrics> {
try {
const iptablesResult = await execCommand(
client,
"iptables-save 2>/dev/null",
15000,
);
if (iptablesResult.stdout && iptablesResult.stdout.includes("*filter")) {
const chains = parseIptablesOutput(iptablesResult.stdout);
const hasRules = chains.some((c) => c.rules.length > 0);
return {
type: "iptables",
status: hasRules ? "active" : "inactive",
chains: chains.filter(
(c) =>
c.name === "INPUT" || c.name === "OUTPUT" || c.name === "FORWARD",
),
};
}
const nftResult = await execCommand(
client,
"nft list ruleset 2>/dev/null",
15000,
);
if (nftResult.stdout && nftResult.stdout.trim()) {
const chains = parseNftablesOutput(nftResult.stdout);
const hasRules = chains.some((c) => c.rules.length > 0);
return {
type: "nftables",
status: hasRules ? "active" : "inactive",
chains,
};
}
return {
type: "none",
status: "unknown",
chains: [],
};
} catch {
return {
type: "none",
status: "unknown",
chains: [],
};
}
}

View File

@@ -177,15 +177,33 @@ class UserDataImport {
continue;
}
const tempId = `import-ssh-${targetUserId}-${Date.now()}-${imported}`;
const existing = await getDb()
.select()
.from(sshData)
.where(
and(
eq(sshData.userId, targetUserId),
eq(sshData.ip, host.ip as string),
eq(sshData.port, host.port as number),
eq(sshData.username, host.username as string),
),
);
if (existing.length > 0 && !options.replaceExisting) {
skipped++;
continue;
}
const newHostData = {
...host,
id: tempId,
userId: targetUserId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
if (existing.length === 0) {
newHostData.createdAt = new Date().toISOString();
}
let processedHostData = newHostData;
if (options.userDataKey) {
processedHostData = DataCrypto.encryptRecord(
@@ -198,9 +216,18 @@ class UserDataImport {
delete processedHostData.id;
await getDb()
.insert(sshData)
.values(processedHostData as unknown as typeof sshData.$inferInsert);
if (existing.length > 0 && options.replaceExisting) {
await getDb()
.update(sshData)
.set(processedHostData as unknown as typeof sshData.$inferInsert)
.where(eq(sshData.id, existing[0].id));
} else {
await getDb()
.insert(sshData)
.values(
processedHostData as unknown as typeof sshData.$inferInsert,
);
}
imported++;
} catch (error) {
errors.push(
@@ -233,17 +260,33 @@ class UserDataImport {
continue;
}
const tempCredId = `import-cred-${targetUserId}-${Date.now()}-${imported}`;
const existing = await getDb()
.select()
.from(sshCredentials)
.where(
and(
eq(sshCredentials.userId, targetUserId),
eq(sshCredentials.name, credential.name as string),
),
);
if (existing.length > 0 && !options.replaceExisting) {
skipped++;
continue;
}
const newCredentialData = {
...credential,
id: tempCredId,
userId: targetUserId,
usageCount: 0,
lastUsed: null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
if (existing.length === 0) {
newCredentialData.usageCount = 0;
newCredentialData.lastUsed = null;
newCredentialData.createdAt = new Date().toISOString();
}
let processedCredentialData = newCredentialData;
if (options.userDataKey) {
processedCredentialData = DataCrypto.encryptRecord(
@@ -256,11 +299,20 @@ class UserDataImport {
delete processedCredentialData.id;
await getDb()
.insert(sshCredentials)
.values(
processedCredentialData as unknown as typeof sshCredentials.$inferInsert,
);
if (existing.length > 0 && options.replaceExisting) {
await getDb()
.update(sshCredentials)
.set(
processedCredentialData as unknown as typeof sshCredentials.$inferInsert,
)
.where(eq(sshCredentials.id, existing[0].id));
} else {
await getDb()
.insert(sshCredentials)
.values(
processedCredentialData as unknown as typeof sshCredentials.$inferInsert,
);
}
imported++;
} catch (error) {
errors.push(

View File

@@ -745,7 +745,7 @@ export const DEFAULT_TERMINAL_CONFIG = {
fontSize: 14,
fontFamily: "Caskaydia Cove Nerd Font Mono",
letterSpacing: 0,
lineHeight: 1.2,
lineHeight: 1.0,
theme: "termix",
scrollback: 10000,

View File

@@ -0,0 +1,71 @@
import { useEffect, useState, useCallback } from "react";
import { isElectron } from "@/ui/main-axios";
interface ServiceWorkerState {
isSupported: boolean;
isRegistered: boolean;
updateAvailable: boolean;
}
/**
* Hook to manage PWA Service Worker registration.
* Only registers in production web environment (not in Electron).
*/
export function useServiceWorker(): ServiceWorkerState {
const [state, setState] = useState<ServiceWorkerState>({
isSupported: false,
isRegistered: false,
updateAvailable: false,
});
const handleUpdateFound = useCallback(
(registration: ServiceWorkerRegistration) => {
const newWorker = registration.installing;
if (!newWorker) return;
newWorker.addEventListener("statechange", () => {
if (
newWorker.state === "installed" &&
navigator.serviceWorker.controller
) {
setState((prev) => ({ ...prev, updateAvailable: true }));
console.log("[SW] Update available");
}
});
},
[],
);
useEffect(() => {
const isSupported =
"serviceWorker" in navigator && !isElectron() && import.meta.env.PROD;
setState((prev) => ({ ...prev, isSupported }));
if (!isSupported) return;
const registerSW = async () => {
try {
const registration = await navigator.serviceWorker.register("/sw.js");
console.log("[SW] Registered:", registration.scope);
setState((prev) => ({ ...prev, isRegistered: true }));
registration.addEventListener("updatefound", () =>
handleUpdateFound(registration),
);
} catch (error) {
console.error("[SW] Registration failed:", error);
}
};
if (document.readyState === "complete") {
registerSW();
} else {
window.addEventListener("load", registerSW);
return () => window.removeEventListener("load", registerSW);
}
}, [handleUpdateFound]);
return state;
}

View File

@@ -1740,6 +1740,23 @@
"state": "State",
"process": "Process",
"noData": "No listening ports data"
"firewall": {
"title": "Firewall",
"active": "Active",
"inactive": "Inactive",
"notDetected": "Not Detected",
"policy": "Policy",
"rules": "rules",
"noRules": "No rules",
"noData": "No firewall data available",
"action": "Action",
"protocol": "Proto",
"port": "Port",
"source": "Source",
"accept": "ACCEPT",
"drop": "DROP",
"reject": "REJECT",
"anywhere": "Anywhere"
}
},
"auth": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ import { ThemeProvider } from "@/components/theme-provider";
import { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
import "./i18n/i18n";
import { isElectron } from "./ui/main-axios.ts";
import { useServiceWorker } from "@/hooks/use-service-worker";
function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth);
@@ -58,6 +59,9 @@ function RootApp() {
const isMobile = width < 768;
const [showVersionCheck, setShowVersionCheck] = useState(true);
// PWA Service Worker registration (production web only)
useServiceWorker();
const userAgent =
navigator.userAgent || navigator.vendor || (window as any).opera || "";
const isTermixMobile = /Termix-Mobile/.test(userAgent);
@@ -114,3 +118,4 @@ createRoot(document.getElementById("root")!).render(
</ThemeProvider>
</StrictMode>,
);

View File

@@ -21,6 +21,31 @@ export interface ListeningPort {
export interface PortsMetrics {
source: "ss" | "netstat" | "none";
ports: ListeningPort[];
| "firewall";
export interface FirewallRule {
chain: string;
target: string;
protocol: string;
source: string;
destination: string;
dport?: string;
sport?: string;
state?: string;
interface?: string;
extra?: string;
}
export interface FirewallChain {
name: string;
policy: string;
rules: FirewallRule[];
}
export interface FirewallMetrics {
type: "iptables" | "nftables" | "none";
status: "active" | "inactive" | "unknown";
chains: FirewallChain[];
}
export interface StatsConfig {

View File

@@ -255,7 +255,7 @@ export function ContainerCard({
>
<CardHeader className="pb-2 px-4">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base font-semibold truncate flex-1">
<CardTitle className="text-base font-semibold truncate flex-1 min-w-0">
{container.name.startsWith("/")
? container.name.slice(1)
: container.name}

View File

@@ -332,11 +332,21 @@ export function FileViewer({
const getImageDataUrl = (content: string, fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (ext === "svg") {
return `data:image/svg+xml;base64,${content}`;
}
const mimeTypes: Record<string, string> = {
svg: "image/svg+xml",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
bmp: "image/bmp",
ico: "image/x-icon",
tiff: "image/tiff",
tif: "image/tiff",
};
return `data:image/*;base64,${content}`;
const mimeType = mimeTypes[ext] || "image/png";
return `data:${mimeType};base64,${content}`;
};
const WARNING_SIZE = 50 * 1024 * 1024;

View File

@@ -34,6 +34,7 @@ import {
SystemWidget,
LoginStatsWidget,
PortsWidget,
FirewallWidget,
} from "./widgets";
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
@@ -269,6 +270,10 @@ export function ServerStats({
case "ports":
return (
<PortsWidget metrics={metrics} metricsHistory={metricsHistory} />
case "firewall":
return (
<FirewallWidget metrics={metrics} metricsHistory={metricsHistory} />
);
default:

View File

@@ -0,0 +1,213 @@
import React from "react";
import { Shield, ShieldOff, ShieldCheck, ChevronDown } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { ServerMetrics } from "@/ui/main-axios.ts";
import type {
FirewallMetrics,
FirewallChain,
FirewallRule,
} from "@/types/stats-widgets";
interface FirewallWidgetProps {
metrics: ServerMetrics | null;
metricsHistory: ServerMetrics[];
}
function RuleRow({ rule }: { rule: FirewallRule }) {
const { t } = useTranslation();
const getTargetStyle = (target: string) => {
switch (target.toUpperCase()) {
case "ACCEPT":
return "text-green-400";
case "DROP":
return "text-red-400";
case "REJECT":
return "text-orange-400";
default:
return "text-muted-foreground";
}
};
const getTargetLabel = (target: string) => {
switch (target.toUpperCase()) {
case "ACCEPT":
return t("serverStats.firewall.accept");
case "DROP":
return t("serverStats.firewall.drop");
case "REJECT":
return t("serverStats.firewall.reject");
default:
return target;
}
};
const formatSource = () => {
if (rule.interface) {
return rule.interface;
}
if (rule.state) {
return rule.state;
}
if (rule.source === "0.0.0.0/0") {
return t("serverStats.firewall.anywhere");
}
return rule.source;
};
return (
<div className="grid grid-cols-4 gap-2 text-xs py-1.5 border-b border-edge/30 last:border-0">
<div className={`font-medium ${getTargetStyle(rule.target)}`}>
{getTargetLabel(rule.target)}
</div>
<div className="text-foreground-subtle font-mono">
{rule.protocol.toUpperCase()}
</div>
<div className="text-foreground-subtle font-mono">
{rule.dport || "-"}
</div>
<div className="text-foreground-subtle truncate" title={formatSource()}>
{formatSource()}
</div>
</div>
);
}
function ChainSection({ chain }: { chain: FirewallChain }) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = React.useState(true);
const getPolicyStyle = (policy: string) => {
switch (policy.toUpperCase()) {
case "ACCEPT":
return "text-green-400";
case "DROP":
return "text-red-400";
case "REJECT":
return "text-orange-400";
default:
return "text-muted-foreground";
}
};
return (
<div>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 w-full py-1.5 hover:bg-canvas/30 rounded px-1 -mx-1 text-left"
>
<ChevronDown
className={`h-3 w-3 text-muted-foreground transition-transform ${
isOpen ? "" : "-rotate-90"
}`}
/>
<span className="text-sm font-medium text-foreground">
{chain.name}
</span>
<span className="text-xs text-muted-foreground">
({t("serverStats.firewall.policy")}:{" "}
<span className={getPolicyStyle(chain.policy)}>{chain.policy}</span>)
</span>
<span className="text-xs text-muted-foreground ml-auto">
{chain.rules.length} {t("serverStats.firewall.rules")}
</span>
</button>
{isOpen && (
<>
{chain.rules.length > 0 ? (
<div className="mt-2 ml-5">
<div className="grid grid-cols-4 gap-2 text-xs text-muted-foreground border-b border-edge/50 pb-1 mb-1">
<div>{t("serverStats.firewall.action")}</div>
<div>{t("serverStats.firewall.protocol")}</div>
<div>{t("serverStats.firewall.port")}</div>
<div>{t("serverStats.firewall.source")}</div>
</div>
<div className="max-h-32 overflow-y-auto thin-scrollbar">
{chain.rules.map((rule, idx) => (
<RuleRow key={idx} rule={rule} />
))}
</div>
</div>
) : (
<div className="text-xs text-muted-foreground ml-5 mt-1">
{t("serverStats.firewall.noRules")}
</div>
)}
</>
)}
</div>
);
}
export function FirewallWidget({ metrics }: FirewallWidgetProps) {
const { t } = useTranslation();
const firewall = (
metrics as ServerMetrics & { firewall?: FirewallMetrics }
)?.firewall;
const getStatusIcon = () => {
if (!firewall || firewall.type === "none") {
return <ShieldOff className="h-5 w-5 text-muted-foreground" />;
}
if (firewall.status === "active") {
return <ShieldCheck className="h-5 w-5 text-green-400" />;
}
return <Shield className="h-5 w-5 text-orange-400" />;
};
const getStatusText = () => {
if (!firewall || firewall.type === "none") {
return t("serverStats.firewall.notDetected");
}
if (firewall.status === "active") {
return t("serverStats.firewall.active");
}
return t("serverStats.firewall.inactive");
};
return (
<div className="h-full w-full p-4 rounded-lg bg-canvas/50 border border-edge/50 hover:bg-canvas/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
{getStatusIcon()}
<h3 className="font-semibold text-lg text-foreground">
{t("serverStats.firewall.title")}
</h3>
{firewall && firewall.type !== "none" && (
<span className="text-xs text-muted-foreground ml-auto bg-canvas/50 px-2 py-0.5 rounded">
{firewall.type}
</span>
)}
</div>
<div className="flex items-center gap-2 mb-3 flex-shrink-0">
<span
className={`text-sm font-medium ${
firewall?.status === "active"
? "text-green-400"
: firewall?.status === "inactive"
? "text-orange-400"
: "text-muted-foreground"
}`}
>
{getStatusText()}
</span>
</div>
{firewall && firewall.chains.length > 0 ? (
<div className="flex-1 overflow-y-auto thin-scrollbar space-y-2">
{firewall.chains.map((chain) => (
<ChainSection key={chain.name} chain={chain} />
))}
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<p className="text-sm text-muted-foreground">
{t("serverStats.firewall.noData")}
</p>
</div>
)}
</div>
);
}

View File

@@ -7,3 +7,4 @@ export { ProcessesWidget } from "./ProcessesWidget.tsx";
export { SystemWidget } from "./SystemWidget.tsx";
export { LoginStatsWidget } from "./LoginStatsWidget.tsx";
export { PortsWidget } from "./PortsWidget.tsx";
export { FirewallWidget } from "./FirewallWidget.tsx";

View File

@@ -618,15 +618,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002`
: isElectron()
? (() => {
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?:\/\//, "");
return `${wsProtocol}${wsHost}/ssh/websocket/`;
})()
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?:\/\//, "");
return `${wsProtocol}${wsHost}/ssh/websocket/`;
})()
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
if (
@@ -1387,7 +1387,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const selectedCommand =
autocompleteSuggestionsRef.current[
autocompleteSelectedIndexRef.current
autocompleteSelectedIndexRef.current
];
const currentCmd = currentAutocompleteCommand.current;
const completion = selectedCommand.substring(currentCmd.length);
@@ -1548,7 +1548,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
scheduleNotify(terminal.cols, terminal.rows);
connectToHost(terminal.cols, terminal.rows);
}
}, [terminal, hostConfig, isVisible, isConnected, isConnecting]);
// Note: Using hostConfig.id instead of hostConfig object to prevent
// unnecessary reconnections when host properties are updated.
// Only reconnect when switching to a different host.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [terminal, hostConfig.id, isVisible, isConnected, isConnecting]);
useEffect(() => {
if (!terminal || !fitAddonRef.current || !isVisible) return;

View File

@@ -318,6 +318,7 @@ export function HostManagerEditor({
"system",
"login_stats",
"ports",
"firewall",
]),
)
.default([
@@ -329,6 +330,7 @@ export function HostManagerEditor({
"system",
"login_stats",
"ports",
"firewall",
]),
statusCheckEnabled: z.boolean().default(true),
statusCheckInterval: z.number().min(5).max(3600).default(30),
@@ -345,6 +347,7 @@ export function HostManagerEditor({
"system",
"login_stats",
"ports",
"firewall",
],
statusCheckEnabled: true,
statusCheckInterval: 30,

View File

@@ -240,6 +240,7 @@ export function HostStatisticsTab({
"system",
"login_stats",
"ports",
"firewall",
] as const
).map((widget) => (
<div key={widget} className="flex items-center space-x-2">
@@ -269,6 +270,8 @@ export function HostStatisticsTab({
t("serverStats.loginStats")}
{widget === "ports" &&
t("serverStats.ports.title")}
{widget === "firewall" &&
t("serverStats.firewall.title")}
</label>
</div>
))}

View File

@@ -20,6 +20,7 @@ export default defineConfig({
base: "./",
build: {
sourcemap: false,
assetsInlineLimit: 0,
rollupOptions: {
output: {
manualChunks: {
@@ -35,9 +36,9 @@ export default defineConfig({
server: {
https: useHTTPS
? {
cert: fs.readFileSync(sslCertPath),
key: fs.readFileSync(sslKeyPath),
}
cert: fs.readFileSync(sslCertPath),
key: fs.readFileSync(sslKeyPath),
}
: false,
port: 5173,
host: "localhost",