Merge branch 'dev-1.10.1' into feat/ports-widget

This commit is contained in:
Luke Gustafson
2026-01-12 02:45:59 -05:00
committed by GitHub
56 changed files with 68494 additions and 145 deletions

View File

@@ -16,17 +16,6 @@
<small style="color: #666;">Achieved on September 1st, 2025</small> <small style="color: #666;">Achieved on September 1st, 2025</small>
</p> </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 /> <br />
<p align="center"> <p align="center">
<a href="https://github.com/Termix-SSH/Termix"> <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 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 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. free and self-hosted alternative to Termius available for all platforms.
# Features # 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 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 - **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 - **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 - **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 - **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 - **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. - **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. - **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 - **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 - **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. - **Modern UI** - Clean desktop/mobile-friendly interface built with React, Tailwind CSS, and Shadcn
- **Languages** - Built-in support ~30 languages (bulk translated via Google Translate, results may vary ofc) - **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. - **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. - **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 History** - Auto-complete and view previously ran SSH commands
- **Command Palette** - Double tap left shift to quickly access SSH connections with your keyboard - **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 # Planned Features
@@ -120,13 +107,19 @@ volumes:
driver: local 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 # 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`. 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 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. channel, however, response times may be longer.
# Screenshots # Show-off
<p align="center"> <p align="center">
<img src="./repo-images/Image 1.png" width="400" alt="Termix Demo 1"/> <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"> <p align="center">
<img src="./repo-images/Image 7.png" width="400" alt="Termix Demo 7"/> <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>
<p align="center"> <p align="center">
@@ -158,7 +145,7 @@ channel, however, response times may be longer.
Your browser does not support the video tag. Your browser does not support the video tag.
</video> </video>
</p> </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 # License

View File

@@ -74,6 +74,9 @@ VOLUME ["/app/data"]
EXPOSE ${PORT} 30001 30002 30003 30004 30005 30006 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 COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh

View File

@@ -1,3 +1,5 @@
worker_processes 1;
master_process off;
pid /app/nginx/nginx.pid; pid /app/nginx/nginx.pid;
error_log /app/nginx/logs/error.log warn; 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; pid /app/nginx/nginx.pid;
error_log /app/nginx/logs/error.log warn; error_log /app/nginx/logs/error.log warn;

View File

@@ -4,6 +4,13 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> <title>Termix</title>
<style> <style>
.hide-scrollbar { .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, socks5ProxyChain: sshData.socks5ProxyChain,
ownerId: sshData.userId, 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, permissionLevel: hostAccess.permissionLevel,
expiresAt: hostAccess.expiresAt, expiresAt: hostAccess.expiresAt,
}) })
@@ -1700,8 +1700,9 @@ async function resolveHostCredentials(
if (requestingUserId && requestingUserId !== ownerId) { if (requestingUserId && requestingUserId !== ownerId) {
try { try {
const { SharedCredentialManager } = const { SharedCredentialManager } = await import(
await import("../../utils/shared-credential-manager.js"); "../../utils/shared-credential-manager.js"
);
const sharedCredManager = SharedCredentialManager.getInstance(); const sharedCredManager = SharedCredentialManager.getInstance();
const sharedCred = await sharedCredManager.getSharedCredentialForUser( const sharedCred = await sharedCredManager.getSharedCredentialForUser(
host.id as number, host.id as number,

View File

@@ -20,6 +20,10 @@ import {
commandHistory, commandHistory,
roles, roles,
userRoles, userRoles,
hostAccess,
sharedCredentials,
auditLogs,
sessionRecordings,
} from "../db/schema.js"; } from "../db/schema.js";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import bcrypt from "bcryptjs"; import bcrypt from "bcryptjs";
@@ -141,6 +145,29 @@ const requireAdmin = authManager.createAdminMiddleware();
async function deleteUserAndRelatedData(userId: string): Promise<void> { async function deleteUserAndRelatedData(userId: string): Promise<void> {
try { 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 await db
.delete(sshCredentialUsage) .delete(sshCredentialUsage)
.where(eq(sshCredentialUsage.userId, userId)); .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(); const app = express();
app.use( app.use(
@@ -1152,6 +1204,100 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
sshConn.lastActive = Date.now(); sshConn.lastActive = Date.now();
sshConn.activeOperations++; sshConn.activeOperations++;
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, "'\"'\"'"); const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => { sshConn.client.exec(`command ls -la '${escapedPath}'`, (err, stream) => {
if (err) { if (err) {
@@ -1177,7 +1323,9 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
fileLogger.error( fileLogger.error(
`SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`, `SSH listFiles command failed with code ${code}: ${errorData.replace(/\n/g, " ").trim()}`,
); );
return res.status(500).json({ error: `Command failed: ${errorData}` }); return res
.status(500)
.json({ error: `Command failed: ${errorData}` });
} }
const lines = data.split("\n").filter((line) => line.trim()); const lines = data.split("\n").filter((line) => line.trim());
@@ -1234,6 +1382,9 @@ app.get("/ssh/file_manager/ssh/listFiles", (req, res) => {
res.json({ files, path: sshPath }); res.json({ files, path: sshPath });
}); });
}); });
};
trySFTP();
}); });
app.get("/ssh/file_manager/ssh/identifySymlink", (req, res) => { 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 { collectSystemMetrics } from "./widgets/system-collector.js";
import { collectLoginStats } from "./widgets/login-stats-collector.js"; import { collectLoginStats } from "./widgets/login-stats-collector.js";
import { collectPortsMetrics } from "./widgets/ports-collector.js"; import { collectPortsMetrics } from "./widgets/ports-collector.js";
import { collectFirewallMetrics } from "./widgets/firewall-collector.js";
import { createSocks5Connection } from "../utils/socks5-helper.js"; import { createSocks5Connection } from "../utils/socks5-helper.js";
async function resolveJumpHost( async function resolveJumpHost(
@@ -1802,6 +1803,35 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
} catch (e) { } catch (e) {
statsLogger.debug("Failed to collect ports metrics", { statsLogger.debug("Failed to collect ports metrics", {
operation: "ports_metrics_failed", 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), error: e instanceof Error ? e.message : String(e),
}); });
} }
@@ -1816,6 +1846,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{
system, system,
login_stats, login_stats,
ports, ports,
firewall,
}; };
metricsCache.set(host.id, result); metricsCache.set(host.id, result);

View File

@@ -648,7 +648,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
); );
cleanupSSH(connectionTimeout); cleanupSSH(connectionTimeout);
} }
}, 30000); }, 120000);
let resolvedCredentials = { password, key, keyPassword, keyType, authType }; let resolvedCredentials = { password, key, keyPassword, keyType, authType };
let authMethodNotAvailable = false; let authMethodNotAvailable = false;
@@ -761,6 +761,36 @@ wss.on("connection", async (ws: WebSocket, req) => {
return; 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( conn.shell(
{ {
rows: data.rows, rows: data.rows,
@@ -768,6 +798,8 @@ wss.on("connection", async (ws: WebSocket, req) => {
term: "xterm-256color", term: "xterm-256color",
} as PseudoTtyOptions, } as PseudoTtyOptions,
(err, stream) => { (err, stream) => {
shellCallbackReceived = true;
clearTimeout(shellTimeout);
isShellInitializing = false; isShellInitializing = false;
if (err) { if (err) {
@@ -784,6 +816,7 @@ wss.on("connection", async (ws: WebSocket, req) => {
message: "Shell error: " + err.message, message: "Shell error: " + err.message,
}), }),
); );
cleanupSSH(connectionTimeout);
return; return;
} }
@@ -969,6 +1002,31 @@ wss.on("connection", async (ws: WebSocket, req) => {
sshConn.on("close", () => { sshConn.on("close", () => {
clearTimeout(connectionTimeout); 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); cleanupSSH(connectionTimeout);
}); });
@@ -1115,10 +1173,10 @@ wss.on("connection", async (ws: WebSocket, req) => {
tryKeyboard: true, tryKeyboard: true,
keepaliveInterval: 30000, keepaliveInterval: 30000,
keepaliveCountMax: 3, keepaliveCountMax: 3,
readyTimeout: 30000, readyTimeout: 120000,
tcpKeepAlive: true, tcpKeepAlive: true,
tcpKeepAliveInitialDelay: 30000, tcpKeepAliveInitialDelay: 30000,
timeout: 30000, timeout: 120000,
env: { env: {
TERM: "xterm-256color", TERM: "xterm-256color",
LANG: "en_US.UTF-8", 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; 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 = { const newHostData = {
...host, ...host,
id: tempId,
userId: targetUserId, userId: targetUserId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
if (existing.length === 0) {
newHostData.createdAt = new Date().toISOString();
}
let processedHostData = newHostData; let processedHostData = newHostData;
if (options.userDataKey) { if (options.userDataKey) {
processedHostData = DataCrypto.encryptRecord( processedHostData = DataCrypto.encryptRecord(
@@ -198,9 +216,18 @@ class UserDataImport {
delete processedHostData.id; delete processedHostData.id;
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() await getDb()
.insert(sshData) .insert(sshData)
.values(processedHostData as unknown as typeof sshData.$inferInsert); .values(
processedHostData as unknown as typeof sshData.$inferInsert,
);
}
imported++; imported++;
} catch (error) { } catch (error) {
errors.push( errors.push(
@@ -233,17 +260,33 @@ class UserDataImport {
continue; 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 = { const newCredentialData = {
...credential, ...credential,
id: tempCredId,
userId: targetUserId, userId: targetUserId,
usageCount: 0,
lastUsed: null,
createdAt: new Date().toISOString(),
updatedAt: 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; let processedCredentialData = newCredentialData;
if (options.userDataKey) { if (options.userDataKey) {
processedCredentialData = DataCrypto.encryptRecord( processedCredentialData = DataCrypto.encryptRecord(
@@ -256,11 +299,20 @@ class UserDataImport {
delete processedCredentialData.id; delete processedCredentialData.id;
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() await getDb()
.insert(sshCredentials) .insert(sshCredentials)
.values( .values(
processedCredentialData as unknown as typeof sshCredentials.$inferInsert, processedCredentialData as unknown as typeof sshCredentials.$inferInsert,
); );
}
imported++; imported++;
} catch (error) { } catch (error) {
errors.push( errors.push(

View File

@@ -745,7 +745,7 @@ export const DEFAULT_TERMINAL_CONFIG = {
fontSize: 14, fontSize: 14,
fontFamily: "Caskaydia Cove Nerd Font Mono", fontFamily: "Caskaydia Cove Nerd Font Mono",
letterSpacing: 0, letterSpacing: 0,
lineHeight: 1.2, lineHeight: 1.0,
theme: "termix", theme: "termix",
scrollback: 10000, 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", "state": "State",
"process": "Process", "process": "Process",
"noData": "No listening ports data" "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": { "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 { ElectronVersionCheck } from "@/ui/desktop/user/ElectronVersionCheck.tsx";
import "./i18n/i18n"; import "./i18n/i18n";
import { isElectron } from "./ui/main-axios.ts"; import { isElectron } from "./ui/main-axios.ts";
import { useServiceWorker } from "@/hooks/use-service-worker";
function useWindowWidth() { function useWindowWidth() {
const [width, setWidth] = useState(window.innerWidth); const [width, setWidth] = useState(window.innerWidth);
@@ -58,6 +59,9 @@ function RootApp() {
const isMobile = width < 768; const isMobile = width < 768;
const [showVersionCheck, setShowVersionCheck] = useState(true); const [showVersionCheck, setShowVersionCheck] = useState(true);
// PWA Service Worker registration (production web only)
useServiceWorker();
const userAgent = const userAgent =
navigator.userAgent || navigator.vendor || (window as any).opera || ""; navigator.userAgent || navigator.vendor || (window as any).opera || "";
const isTermixMobile = /Termix-Mobile/.test(userAgent); const isTermixMobile = /Termix-Mobile/.test(userAgent);
@@ -114,3 +118,4 @@ createRoot(document.getElementById("root")!).render(
</ThemeProvider> </ThemeProvider>
</StrictMode>, </StrictMode>,
); );

View File

@@ -21,6 +21,31 @@ export interface ListeningPort {
export interface PortsMetrics { export interface PortsMetrics {
source: "ss" | "netstat" | "none"; source: "ss" | "netstat" | "none";
ports: ListeningPort[]; 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 { export interface StatsConfig {

View File

@@ -255,7 +255,7 @@ export function ContainerCard({
> >
<CardHeader className="pb-2 px-4"> <CardHeader className="pb-2 px-4">
<div className="flex items-start justify-between gap-2"> <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.startsWith("/")
? container.name.slice(1) ? container.name.slice(1)
: container.name} : container.name}

View File

@@ -332,11 +332,21 @@ export function FileViewer({
const getImageDataUrl = (content: string, fileName: string): string => { const getImageDataUrl = (content: string, fileName: string): string => {
const ext = fileName.split(".").pop()?.toLowerCase() || ""; const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (ext === "svg") { const mimeTypes: Record<string, string> = {
return `data:image/svg+xml;base64,${content}`; 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; const WARNING_SIZE = 50 * 1024 * 1024;

View File

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

View File

@@ -1548,7 +1548,11 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
scheduleNotify(terminal.cols, terminal.rows); scheduleNotify(terminal.cols, terminal.rows);
connectToHost(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(() => { useEffect(() => {
if (!terminal || !fitAddonRef.current || !isVisible) return; if (!terminal || !fitAddonRef.current || !isVisible) return;

View File

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

View File

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

View File

@@ -20,6 +20,7 @@ export default defineConfig({
base: "./", base: "./",
build: { build: {
sourcemap: false, sourcemap: false,
assetsInlineLimit: 0,
rollupOptions: { rollupOptions: {
output: { output: {
manualChunks: { manualChunks: {