From 4896b71b01a64cd68a9a0197e0e33281cfdfd549 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 12 Jan 2026 00:21:59 -0600 Subject: [PATCH 01/17] fix: remove top tech --- README.md | 31 ++++++------------------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 9ddf04d6..0a1fbe7b 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,6 @@ Achieved on September 1st, 2025

-#### 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)](#) -

@@ -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 @@ -126,7 +113,7 @@ If you need help or want to request a feature with Termix, visit the [Issues](ht 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

Termix Demo 1 @@ -145,12 +132,6 @@ channel, however, response times may be longer.

Termix Demo 7 - Termix Demo 8 -

- -

- Termix Demo 9 - Termix Demo 110

@@ -158,7 +139,7 @@ channel, however, response times may be longer. Your browser does not support the video tag.

-Some videos and images may be out of date or may not perfectly showcase features. +Videos and images may be out of date. # License From 614f2f84ecf3a22c16e9baaeac40ecfe67d66e29 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 12 Jan 2026 00:29:14 -0600 Subject: [PATCH 02/17] fix: update readme --- README.md | 6 +++++ package-lock.json | 60 +++++++++++++++-------------------------------- 2 files changed, 25 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index deb9e49c..7f341ae1 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,12 @@ volumes: driver: local ``` +# Sponsors + +Thank you to [Digital Ocean](https://www.digitalocean.com/) for sponsoring Termix and covering our documentation server costs! + +Powered by DigitalOcean + # 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`. diff --git a/package-lock.json b/package-lock.json index 47d466f0..1c9d6013 100644 --- a/package-lock.json +++ b/package-lock.json @@ -158,7 +158,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -444,7 +443,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz", "integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -493,7 +491,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -520,7 +517,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", @@ -548,7 +544,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", @@ -749,7 +744,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -826,7 +820,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", - "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -848,7 +841,6 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", "license": "MIT", - "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1176,7 +1168,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1563,6 +1554,7 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, + "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1584,6 +1576,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2630,8 +2623,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz", "integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@lezer/cpp": { "version": "1.1.3", @@ -2671,7 +2663,6 @@ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.3.0" } @@ -2703,7 +2694,6 @@ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", @@ -2726,7 +2716,6 @@ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", "license": "MIT", - "peer": true, "dependencies": { "@lezer/common": "^1.0.0" } @@ -5174,7 +5163,6 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -5332,7 +5320,6 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -5455,7 +5442,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5498,7 +5484,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5509,7 +5494,6 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5677,7 +5661,6 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -6085,8 +6068,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/7zip-bin": { "version": "5.2.0", @@ -6121,7 +6103,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6549,7 +6530,6 @@ "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -6692,7 +6672,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7692,7 +7671,6 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -7769,7 +7747,8 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true + "optional": true, + "peer": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -8232,7 +8211,6 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -8330,7 +8308,8 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", - "license": "(MPL-2.0 OR Apache-2.0)" + "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true }, "node_modules/dot-prop": { "version": "5.3.0", @@ -8705,6 +8684,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8725,6 +8705,7 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8740,6 +8721,7 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", + "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -8750,6 +8732,7 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 4.0.0" } @@ -9011,7 +8994,6 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -11023,7 +11005,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -12590,6 +12571,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -14619,6 +14601,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -14636,6 +14619,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -15121,7 +15105,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15131,7 +15114,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -15158,7 +15140,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -15306,7 +15287,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -15515,8 +15495,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -16906,6 +16885,7 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -16946,6 +16926,7 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -16960,6 +16941,7 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -17064,7 +17046,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -17270,7 +17251,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17683,7 +17663,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17775,7 +17754,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, From 7ecfb4d685a68239390bdb6c5d6fa8edb8afe9ab Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:27:58 +0800 Subject: [PATCH 03/17] fix: prevent long container names from overflowing card (#496) Added min-w-0 to CardTitle to allow text truncation in flexbox. Without this, flex items have min-width: auto which prevents the truncate class from working properly. Fixes #411 --- .../desktop/apps/features/docker/components/ContainerCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/desktop/apps/features/docker/components/ContainerCard.tsx b/src/ui/desktop/apps/features/docker/components/ContainerCard.tsx index 30cb0ca3..c3be34b1 100644 --- a/src/ui/desktop/apps/features/docker/components/ContainerCard.tsx +++ b/src/ui/desktop/apps/features/docker/components/ContainerCard.tsx @@ -255,7 +255,7 @@ export function ContainerCard({ >
- + {container.name.startsWith("/") ? container.name.slice(1) : container.name} From 4150faa558d53f92ca995dc74ffc35b697d3d3a7 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:28:17 +0800 Subject: [PATCH 04/17] fix: use SFTP readdir for file listing to support non-Linux systems (#495) The file manager now uses SFTP readdir as the primary method for listing files, with ls -la as a fallback. This enables compatibility with MikroTik RouterOS and other non-Linux systems that don't have standard shell commands. Fixes #317 --- src/backend/ssh/file-manager.ts | 307 ++++++++++++++++++++++++-------- 1 file changed, 229 insertions(+), 78 deletions(-) diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 97f6a88b..bcfa80f6 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -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) => { From 81d506afba22dfa3cb5676c9143df1534983a929 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:28:38 +0800 Subject: [PATCH 05/17] fix: restore SSH connection timeout to 120s for 2FA authentication (#494) The timeout was reduced from 120s to 30s in v1.10, causing 2FA login failures. Users with keyboard-interactive authentication (TOTP/2FA) need sufficient time to enter their verification codes before the SSH connection times out. Fixes #404 --- src/backend/ssh/terminal.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 64223bfe..fffd68ac 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -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; @@ -1115,10 +1115,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", From f5d948aa454e613bebb1392fc60f8c215bdd4303 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:29:02 +0800 Subject: [PATCH 06/17] feat: add Docker container healthcheck (#493) --- docker/Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker/Dockerfile b/docker/Dockerfile index 6885c496..29313736 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 From 4648549e7461cb734df9303086879a48293e7c19 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:29:24 +0800 Subject: [PATCH 07/17] fix: owner should not be marked as shared when host is shared to their role (#492) --- src/backend/database/routes/ssh.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 8e7e9086..e97fca86 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -852,7 +852,7 @@ router.get( socks5ProxyChain: sshData.socks5ProxyChain, ownerId: sshData.userId, - isShared: sql`${hostAccess.id} IS NOT NULL`, + isShared: sql`${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, From 5f080be4eeca917fc620f02162e51e8350453a08 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:29:35 +0800 Subject: [PATCH 08/17] fix: use correct MIME types for image preview (#491) --- .../file-manager/components/FileViewer.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/ui/desktop/apps/features/file-manager/components/FileViewer.tsx b/src/ui/desktop/apps/features/file-manager/components/FileViewer.tsx index e09b5cee..640d59f5 100644 --- a/src/ui/desktop/apps/features/file-manager/components/FileViewer.tsx +++ b/src/ui/desktop/apps/features/file-manager/components/FileViewer.tsx @@ -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 = { + 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; From afb66a1098bc9f49138c52f4e7a6649e1a280046 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:30:01 +0800 Subject: [PATCH 09/17] fix: prevent session reset when updating host properties (#490) --- .../apps/features/terminal/Terminal.tsx | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/ui/desktop/apps/features/terminal/Terminal.tsx b/src/ui/desktop/apps/features/terminal/Terminal.tsx index 8e4a6c62..dcee59c4 100644 --- a/src/ui/desktop/apps/features/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/features/terminal/Terminal.tsx @@ -618,15 +618,15 @@ export const Terminal = forwardRef( ? `${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( 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( 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; From 58945288e04436123d09d2f0d9ad95ad1debaec7 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:30:13 +0800 Subject: [PATCH 10/17] fix: add shell creation timeout and improve error handling (#489) --- src/backend/ssh/terminal.ts | 58 +++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index fffd68ac..80389dbb 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -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); }); From 99b0181c4541afca2127cba16205cb925b2e387d Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:30:27 +0800 Subject: [PATCH 11/17] fix: set default lineHeight to 1.0 for TUI apps compatibility (#488) --- src/constants/terminal-themes.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants/terminal-themes.ts b/src/constants/terminal-themes.ts index 46385c65..d9736ea9 100644 --- a/src/constants/terminal-themes.ts +++ b/src/constants/terminal-themes.ts @@ -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, From e6870f962ac307a1242735c8d9989f82ceabaa0f Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:30:40 +0800 Subject: [PATCH 12/17] fix: delete all related data when removing user (#487) --- src/backend/database/routes/users.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 7d896ca7..29aa6a31 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -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 { 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)); From 2b6361cbb68b8865518f51f87d99eecd39e4d622 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:30:51 +0800 Subject: [PATCH 13/17] fix: nginx permission denied on restricted kernels (#486) --- docker/nginx-https.conf | 2 ++ docker/nginx.conf | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 7788848b..dce94dd2 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -1,3 +1,5 @@ +worker_processes 1; +master_process off; pid /app/nginx/nginx.pid; error_log /app/nginx/logs/error.log warn; diff --git a/docker/nginx.conf b/docker/nginx.conf index ac6b7112..fd95a81f 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -1,3 +1,5 @@ +worker_processes 1; +master_process off; pid /app/nginx/nginx.pid; error_log /app/nginx/logs/error.log warn; From 1eb28dec8b05268054f132340bcf302f87ca1f91 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:31:04 +0800 Subject: [PATCH 14/17] fix: skip existing hosts and credentials during JSON import (#485) Added duplicate detection for SSH hosts (by ip+port+username) and credentials (by name) during import. Existing items are now skipped by default, or updated if replaceExisting option is enabled. This matches the existing behavior of importDismissedAlerts. Fixes #389 --- src/backend/utils/user-data-import.ts | 84 ++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 16 deletions(-) diff --git a/src/backend/utils/user-data-import.ts b/src/backend/utils/user-data-import.ts index da776893..ab161e2c 100644 --- a/src/backend/utils/user-data-import.ts +++ b/src/backend/utils/user-data-import.ts @@ -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( From ceff07c685602e138ab77f0d51ef608827b0c764 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 12 Jan 2026 15:31:21 +0800 Subject: [PATCH 15/17] feat: add firewall status widget for server stats (#484) --- src/backend/ssh/server-stats.ts | 35 +++ src/backend/ssh/widgets/firewall-collector.ts | 254 ++++++++++++++++++ src/locales/en.json | 20 +- src/types/stats-widgets.ts | 28 +- .../features/server-stats/ServerStats.tsx | 6 + .../server-stats/widgets/FirewallWidget.tsx | 213 +++++++++++++++ .../features/server-stats/widgets/index.ts | 1 + .../host-manager/hosts/HostManagerEditor.tsx | 3 + .../hosts/tabs/HostStatisticsTab.tsx | 3 + 9 files changed, 561 insertions(+), 2 deletions(-) create mode 100644 src/backend/ssh/widgets/firewall-collector.ts create mode 100644 src/ui/desktop/apps/features/server-stats/widgets/FirewallWidget.tsx diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index e17f0491..9bea374c 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -19,6 +19,7 @@ import { collectUptimeMetrics } from "./widgets/uptime-collector.js"; import { collectProcessesMetrics } from "./widgets/processes-collector.js"; import { collectSystemMetrics } from "./widgets/system-collector.js"; import { collectLoginStats } from "./widgets/login-stats-collector.js"; +import { collectFirewallMetrics } from "./widgets/firewall-collector.js"; import { createSocks5Connection } from "../utils/socks5-helper.js"; async function resolveJumpHost( @@ -1782,6 +1783,39 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ login_stats = await collectLoginStats(client); } catch (e) {} + 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), + }); + } + const result = { cpu, memory, @@ -1791,6 +1825,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ processes, system, login_stats, + firewall, }; metricsCache.set(host.id, result); diff --git a/src/backend/ssh/widgets/firewall-collector.ts b/src/backend/ssh/widgets/firewall-collector.ts new file mode 100644 index 00000000..1043ee39 --- /dev/null +++ b/src/backend/ssh/widgets/firewall-collector.ts @@ -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 = 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 { + 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: [], + }; + } +} diff --git a/src/locales/en.json b/src/locales/en.json index b80a8466..79be5fd8 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1731,7 +1731,25 @@ "executingQuickAction": "Executing {{name}}...", "quickActionSuccess": "{{name}} completed successfully", "quickActionFailed": "{{name}} failed", - "quickActionError": "Failed to execute {{name}}" + "quickActionError": "Failed to execute {{name}}", + "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": { "tagline": "SSH SERVER MANAGER", diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index f7040ae4..407dc177 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -6,7 +6,33 @@ export type WidgetType = | "uptime" | "processes" | "system" - | "login_stats"; + | "login_stats" + | "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 { enabledWidgets: WidgetType[]; diff --git a/src/ui/desktop/apps/features/server-stats/ServerStats.tsx b/src/ui/desktop/apps/features/server-stats/ServerStats.tsx index a87813b5..e1fdf48c 100644 --- a/src/ui/desktop/apps/features/server-stats/ServerStats.tsx +++ b/src/ui/desktop/apps/features/server-stats/ServerStats.tsx @@ -33,6 +33,7 @@ import { ProcessesWidget, SystemWidget, LoginStatsWidget, + FirewallWidget, } from "./widgets"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; @@ -265,6 +266,11 @@ export function ServerStats({ ); + case "firewall": + return ( + + ); + default: return null; } diff --git a/src/ui/desktop/apps/features/server-stats/widgets/FirewallWidget.tsx b/src/ui/desktop/apps/features/server-stats/widgets/FirewallWidget.tsx new file mode 100644 index 00000000..1aee7fa6 --- /dev/null +++ b/src/ui/desktop/apps/features/server-stats/widgets/FirewallWidget.tsx @@ -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 ( +
+
+ {getTargetLabel(rule.target)} +
+
+ {rule.protocol.toUpperCase()} +
+
+ {rule.dport || "-"} +
+
+ {formatSource()} +
+
+ ); +} + +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 ( +
+ + {isOpen && ( + <> + {chain.rules.length > 0 ? ( +
+
+
{t("serverStats.firewall.action")}
+
{t("serverStats.firewall.protocol")}
+
{t("serverStats.firewall.port")}
+
{t("serverStats.firewall.source")}
+
+
+ {chain.rules.map((rule, idx) => ( + + ))} +
+
+ ) : ( +
+ {t("serverStats.firewall.noRules")} +
+ )} + + )} +
+ ); +} + +export function FirewallWidget({ metrics }: FirewallWidgetProps) { + const { t } = useTranslation(); + + const firewall = ( + metrics as ServerMetrics & { firewall?: FirewallMetrics } + )?.firewall; + + const getStatusIcon = () => { + if (!firewall || firewall.type === "none") { + return ; + } + if (firewall.status === "active") { + return ; + } + return ; + }; + + 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 ( +
+
+ {getStatusIcon()} +

+ {t("serverStats.firewall.title")} +

+ {firewall && firewall.type !== "none" && ( + + {firewall.type} + + )} +
+ +
+ + {getStatusText()} + +
+ + {firewall && firewall.chains.length > 0 ? ( +
+ {firewall.chains.map((chain) => ( + + ))} +
+ ) : ( +
+

+ {t("serverStats.firewall.noData")} +

+
+ )} +
+ ); +} diff --git a/src/ui/desktop/apps/features/server-stats/widgets/index.ts b/src/ui/desktop/apps/features/server-stats/widgets/index.ts index 5f47b6dc..8bb4599d 100644 --- a/src/ui/desktop/apps/features/server-stats/widgets/index.ts +++ b/src/ui/desktop/apps/features/server-stats/widgets/index.ts @@ -6,3 +6,4 @@ export { UptimeWidget } from "./UptimeWidget.tsx"; export { ProcessesWidget } from "./ProcessesWidget.tsx"; export { SystemWidget } from "./SystemWidget.tsx"; export { LoginStatsWidget } from "./LoginStatsWidget.tsx"; +export { FirewallWidget } from "./FirewallWidget.tsx"; diff --git a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx index df3ec54d..06c19f27 100644 --- a/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/HostManagerEditor.tsx @@ -317,6 +317,7 @@ export function HostManagerEditor({ "processes", "system", "login_stats", + "firewall", ]), ) .default([ @@ -327,6 +328,7 @@ export function HostManagerEditor({ "uptime", "system", "login_stats", + "firewall", ]), statusCheckEnabled: z.boolean().default(true), statusCheckInterval: z.number().min(5).max(3600).default(30), @@ -342,6 +344,7 @@ export function HostManagerEditor({ "uptime", "system", "login_stats", + "firewall", ], statusCheckEnabled: true, statusCheckInterval: 30, diff --git a/src/ui/desktop/apps/host-manager/hosts/tabs/HostStatisticsTab.tsx b/src/ui/desktop/apps/host-manager/hosts/tabs/HostStatisticsTab.tsx index cd9b3e52..2f44ff94 100644 --- a/src/ui/desktop/apps/host-manager/hosts/tabs/HostStatisticsTab.tsx +++ b/src/ui/desktop/apps/host-manager/hosts/tabs/HostStatisticsTab.tsx @@ -239,6 +239,7 @@ export function HostStatisticsTab({ "processes", "system", "login_stats", + "firewall", ] as const ).map((widget) => (
@@ -266,6 +267,8 @@ export function HostStatisticsTab({ {widget === "system" && t("serverStats.systemInfo")} {widget === "login_stats" && t("serverStats.loginStats")} + {widget === "firewall" && + t("serverStats.firewall.title")}
))} From 816172d67bb856f3935cf1a64fda1ab5bf3442a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nunzio=20Marf=C3=A8?= Date: Mon, 12 Jan 2026 08:36:03 +0100 Subject: [PATCH 16/17] Feature: PWA (#479) * feat: add PWA support with offline capabilities - Add web app manifest with icons and theme configuration - Add service worker with cache-first strategy for static assets - Add useServiceWorker hook for SW registration - Add PWA meta tags and Apple-specific tags to index.html - Update vite.config.ts for optimal asset caching * Update package-lock.json --- index.html | 7 ++ public/manifest.json | 40 +++++++++++ public/sw.js | 120 ++++++++++++++++++++++++++++++++ src/hooks/use-service-worker.ts | 71 +++++++++++++++++++ src/main.tsx | 5 ++ vite.config.ts | 7 +- 6 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 public/manifest.json create mode 100644 public/sw.js create mode 100644 src/hooks/use-service-worker.ts diff --git a/index.html b/index.html index b376f7cd..83e38e8e 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,13 @@ + + + + + + + Termix