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" }, diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index e17f0491..3ccc3d6c 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 { collectPortsMetrics } from "./widgets/ports-collector.js"; import { createSocks5Connection } from "../utils/socks5-helper.js"; async function resolveJumpHost( @@ -1782,6 +1783,29 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ login_stats = await collectLoginStats(client); } catch (e) {} + let ports: { + source: "ss" | "netstat" | "none"; + ports: Array<{ + protocol: "tcp" | "udp"; + localAddress: string; + localPort: number; + state?: string; + pid?: number; + process?: string; + }>; + } = { + source: "none", + ports: [], + }; + try { + ports = await collectPortsMetrics(client); + } catch (e) { + statsLogger.debug("Failed to collect ports metrics", { + operation: "ports_metrics_failed", + error: e instanceof Error ? e.message : String(e), + }); + } + const result = { cpu, memory, @@ -1791,6 +1815,7 @@ async function collectMetrics(host: SSHHostWithCredentials): Promise<{ processes, system, login_stats, + ports, }; metricsCache.set(host.id, result); diff --git a/src/backend/ssh/widgets/ports-collector.ts b/src/backend/ssh/widgets/ports-collector.ts new file mode 100644 index 00000000..57e8161e --- /dev/null +++ b/src/backend/ssh/widgets/ports-collector.ts @@ -0,0 +1,155 @@ +import type { Client } from "ssh2"; +import { execCommand } from "./common-utils.js"; +import type { PortsMetrics, ListeningPort } from "../../../types/stats-widgets.js"; + +function parseSsOutput(output: string): ListeningPort[] { + const ports: ListeningPort[] = []; + const lines = output.split("\n").slice(1); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const parts = trimmed.split(/\s+/); + if (parts.length < 5) continue; + + const protocol = parts[0]?.toLowerCase(); + if (protocol !== "tcp" && protocol !== "udp") continue; + + const state = parts[1]; + const localAddr = parts[4]; + + if (!localAddr) continue; + + const lastColon = localAddr.lastIndexOf(":"); + if (lastColon === -1) continue; + + const address = localAddr.substring(0, lastColon); + const portStr = localAddr.substring(lastColon + 1); + const port = parseInt(portStr, 10); + + if (isNaN(port)) continue; + + const portEntry: ListeningPort = { + protocol: protocol as "tcp" | "udp", + localAddress: address.replace(/^\[|\]$/g, ""), + localPort: port, + state: protocol === "tcp" ? state : undefined, + }; + + const processInfo = parts[6]; + if (processInfo && processInfo.startsWith("users:")) { + const pidMatch = processInfo.match(/pid=(\d+)/); + const nameMatch = processInfo.match(/\("([^"]+)"/); + if (pidMatch) portEntry.pid = parseInt(pidMatch[1], 10); + if (nameMatch) portEntry.process = nameMatch[1]; + } + + ports.push(portEntry); + } + + return ports; +} + +function parseNetstatOutput(output: string): ListeningPort[] { + const ports: ListeningPort[] = []; + const lines = output.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + const parts = trimmed.split(/\s+/); + if (parts.length < 4) continue; + + const proto = parts[0]?.toLowerCase(); + if (!proto) continue; + + let protocol: "tcp" | "udp"; + if (proto.startsWith("tcp")) { + protocol = "tcp"; + } else if (proto.startsWith("udp")) { + protocol = "udp"; + } else { + continue; + } + + const localAddr = parts[3]; + if (!localAddr) continue; + + const lastColon = localAddr.lastIndexOf(":"); + if (lastColon === -1) continue; + + const address = localAddr.substring(0, lastColon); + const portStr = localAddr.substring(lastColon + 1); + const port = parseInt(portStr, 10); + + if (isNaN(port)) continue; + + const portEntry: ListeningPort = { + protocol, + localAddress: address, + localPort: port, + }; + + if (protocol === "tcp" && parts.length >= 6) { + portEntry.state = parts[5]; + } + + const pidProgram = parts[parts.length - 1]; + if (pidProgram && pidProgram.includes("/")) { + const [pidStr, process] = pidProgram.split("/"); + const pid = parseInt(pidStr, 10); + if (!isNaN(pid)) portEntry.pid = pid; + if (process) portEntry.process = process; + } + + ports.push(portEntry); + } + + return ports; +} + +export async function collectPortsMetrics( + client: Client, +): Promise { + try { + const ssResult = await execCommand( + client, + "ss -tulnp 2>/dev/null", + 15000, + ); + + if (ssResult.stdout && ssResult.stdout.includes("Local")) { + const ports = parseSsOutput(ssResult.stdout); + return { + source: "ss", + ports: ports.sort((a, b) => a.localPort - b.localPort), + }; + } + + const netstatResult = await execCommand( + client, + "netstat -tulnp 2>/dev/null", + 15000, + ); + + if (netstatResult.stdout && netstatResult.stdout.includes("Local")) { + const ports = parseNetstatOutput(netstatResult.stdout); + return { + source: "netstat", + ports: ports.sort((a, b) => a.localPort - b.localPort), + }; + } + + return { + source: "none", + ports: [], + }; + } catch { + return { + source: "none", + ports: [], + }; + } +} diff --git a/src/locales/en.json b/src/locales/en.json index b80a8466..9d39737a 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1731,7 +1731,16 @@ "executingQuickAction": "Executing {{name}}...", "quickActionSuccess": "{{name}} completed successfully", "quickActionFailed": "{{name}} failed", - "quickActionError": "Failed to execute {{name}}" + "quickActionError": "Failed to execute {{name}}", + "ports": { + "title": "Listening Ports", + "protocol": "Protocol", + "port": "Port", + "address": "Address", + "state": "State", + "process": "Process", + "noData": "No listening ports data" + } }, "auth": { "tagline": "SSH SERVER MANAGER", diff --git a/src/types/stats-widgets.ts b/src/types/stats-widgets.ts index f7040ae4..3ef78053 100644 --- a/src/types/stats-widgets.ts +++ b/src/types/stats-widgets.ts @@ -6,7 +6,22 @@ export type WidgetType = | "uptime" | "processes" | "system" - | "login_stats"; + | "login_stats" + | "ports"; + +export interface ListeningPort { + protocol: "tcp" | "udp"; + localAddress: string; + localPort: number; + state?: string; + pid?: number; + process?: string; +} + +export interface PortsMetrics { + source: "ss" | "netstat" | "none"; + ports: ListeningPort[]; +} 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..3c5410ab 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, + PortsWidget, } from "./widgets"; import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; @@ -265,6 +266,11 @@ export function ServerStats({ ); + case "ports": + return ( + + ); + default: return null; } diff --git a/src/ui/desktop/apps/features/server-stats/widgets/PortsWidget.tsx b/src/ui/desktop/apps/features/server-stats/widgets/PortsWidget.tsx new file mode 100644 index 00000000..15fd1db9 --- /dev/null +++ b/src/ui/desktop/apps/features/server-stats/widgets/PortsWidget.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { Network } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import type { ServerMetrics } from "@/ui/main-axios.ts"; +import type { PortsMetrics, ListeningPort } from "@/types/stats-widgets"; + +interface PortsWidgetProps { + metrics: ServerMetrics | null; + metricsHistory: ServerMetrics[]; +} + +function PortRow({ port }: { port: ListeningPort }) { + const formatAddress = (addr: string) => { + if (addr === "0.0.0.0" || addr === "*" || addr === "::") { + return "*"; + } + return addr; + }; + + return ( +
+
+ {port.protocol.toUpperCase()} +
+
+ {port.localPort} +
+
+ {formatAddress(port.localAddress)} +
+
+ {port.state || "-"} +
+
+ {port.process || (port.pid ? `PID:${port.pid}` : "-")} +
+
+ ); +} + +export function PortsWidget({ metrics }: PortsWidgetProps) { + const { t } = useTranslation(); + + const portsData = ( + metrics as ServerMetrics & { ports?: PortsMetrics } + )?.ports; + + const tcpPorts = portsData?.ports.filter(p => p.protocol === "tcp") || []; + const udpPorts = portsData?.ports.filter(p => p.protocol === "udp") || []; + + return ( +
+
+ +

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

+ {portsData && portsData.source !== "none" && ( + + {portsData.source} + + )} +
+ +
+ + TCP: {tcpPorts.length} + + + UDP: {udpPorts.length} + +
+ + {portsData && portsData.ports.length > 0 ? ( +
+
+
{t("serverStats.ports.protocol")}
+
{t("serverStats.ports.port")}
+
{t("serverStats.ports.address")}
+
{t("serverStats.ports.state")}
+
{t("serverStats.ports.process")}
+
+
+ {portsData.ports.map((port, idx) => ( + + ))} +
+
+ ) : ( +
+

+ {t("serverStats.ports.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..54fff0e8 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 { PortsWidget } from "./PortsWidget.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..c151a522 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", + "ports", ]), ) .default([ @@ -327,6 +328,7 @@ export function HostManagerEditor({ "uptime", "system", "login_stats", + "ports", ]), 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", + "ports", ], 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..cb9d7513 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", + "ports", ] as const ).map((widget) => (
@@ -266,6 +267,8 @@ export function HostStatisticsTab({ {widget === "system" && t("serverStats.systemInfo")} {widget === "login_stats" && t("serverStats.loginStats")} + {widget === "ports" && + t("serverStats.ports.title")}
))}