feat: add listening ports widget #483
37
README.md
37
README.md
@@ -16,17 +16,6 @@
|
||||
<small style="color: #666;">Achieved on September 1st, 2025</small>
|
||||
</p>
|
||||
|
||||
#### Top Technologies
|
||||
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
|
||||
<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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
worker_processes 1;
|
||||
master_process off;
|
||||
pid /app/nginx/nginx.pid;
|
||||
error_log /app/nginx/logs/error.log warn;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
worker_processes 1;
|
||||
master_process off;
|
||||
pid /app/nginx/nginx.pid;
|
||||
error_log /app/nginx/logs/error.log warn;
|
||||
|
||||
|
||||
@@ -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
40
public/manifest.json
Normal 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
120
public/sw.js
Normal 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;
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
254
src/backend/ssh/widgets/firewall-collector.ts
Normal file
254
src/backend/ssh/widgets/firewall-collector.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
71
src/hooks/use-service-worker.ts
Normal file
71
src/hooks/use-service-worker.ts
Normal 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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
2402
src/locales/translated/af.json
Normal file
2402
src/locales/translated/af.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/ar.json
Normal file
2402
src/locales/translated/ar.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/ca.json
Normal file
2402
src/locales/translated/ca.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/cs.json
Normal file
2402
src/locales/translated/cs.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/da.json
Normal file
2402
src/locales/translated/da.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/de.json
Normal file
2402
src/locales/translated/de.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/el.json
Normal file
2402
src/locales/translated/el.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/en.json
Normal file
2402
src/locales/translated/en.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/es.json
Normal file
2402
src/locales/translated/es.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/fi.json
Normal file
2402
src/locales/translated/fi.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/fr.json
Normal file
2402
src/locales/translated/fr.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/he.json
Normal file
2402
src/locales/translated/he.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/hu.json
Normal file
2402
src/locales/translated/hu.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/it.json
Normal file
2402
src/locales/translated/it.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/ja.json
Normal file
2402
src/locales/translated/ja.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/ko.json
Normal file
2402
src/locales/translated/ko.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/nl.json
Normal file
2402
src/locales/translated/nl.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/no.json
Normal file
2402
src/locales/translated/no.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/pl.json
Normal file
2402
src/locales/translated/pl.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/pt.json
Normal file
2402
src/locales/translated/pt.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/ro.json
Normal file
2402
src/locales/translated/ro.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/ru.json
Normal file
2402
src/locales/translated/ru.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/sr.json
Normal file
2402
src/locales/translated/sr.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/sv.json
Normal file
2402
src/locales/translated/sv.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/tr.json
Normal file
2402
src/locales/translated/tr.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/uk.json
Normal file
2402
src/locales/translated/uk.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/vi.json
Normal file
2402
src/locales/translated/vi.json
Normal file
File diff suppressed because it is too large
Load Diff
2402
src/locales/translated/zh.json
Normal file
2402
src/locales/translated/zh.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>,
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user