diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index fb72157e..0f675a6f 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -312,6 +312,42 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location ~ ^/docker(/.*)?$ { + proxy_pass http://127.0.0.1:30007; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + location /docker/console/ { + proxy_pass http://127.0.0.1:30008/; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_connect_timeout 10s; + + proxy_buffering off; + proxy_request_buffering off; + + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; + } + error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; diff --git a/docker/nginx.conf b/docker/nginx.conf index eea71293..4b72f21c 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -309,6 +309,42 @@ http { proxy_set_header X-Forwarded-Proto $scheme; } + location ~ ^/docker(/.*)?$ { + proxy_pass http://127.0.0.1:30007; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_connect_timeout 60s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + location /docker/console/ { + proxy_pass http://127.0.0.1:30008/; + proxy_http_version 1.1; + + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_connect_timeout 10s; + + proxy_buffering off; + proxy_request_buffering off; + + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; + } + error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; diff --git a/package-lock.json b/package-lock.json index 14f680ed..2973ba46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "termix", - "version": "1.8.1", + "version": "1.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "termix", - "version": "1.8.1", + "version": "1.9.0", "dependencies": { "@codemirror/autocomplete": "^6.18.7", "@codemirror/commands": "^6.3.3", @@ -16,6 +16,7 @@ "@hookform/resolvers": "^5.1.1", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", @@ -26,7 +27,7 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.8", @@ -154,6 +155,7 @@ "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", @@ -439,6 +441,7 @@ "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", @@ -487,6 +490,7 @@ "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", @@ -513,6 +517,7 @@ "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", @@ -540,6 +545,7 @@ "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", @@ -740,6 +746,7 @@ "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", @@ -816,6 +823,7 @@ "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" } @@ -837,6 +845,7 @@ "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", @@ -1164,6 +1173,7 @@ "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", @@ -1550,7 +1560,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1572,7 +1581,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2529,7 +2537,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz", "integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@lezer/cpp": { "version": "1.1.3", @@ -2569,6 +2578,7 @@ "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" } @@ -2600,6 +2610,7 @@ "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", @@ -2622,6 +2633,7 @@ "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" } @@ -3144,6 +3156,52 @@ } } }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", @@ -3253,6 +3311,24 @@ } } }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", @@ -3319,6 +3395,24 @@ } } }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-direction": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", @@ -3511,6 +3605,24 @@ } } }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popover": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", @@ -3548,6 +3660,24 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -3651,6 +3781,24 @@ } } }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-progress": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", @@ -3780,6 +3928,24 @@ } } }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", @@ -3837,9 +4003,9 @@ } }, "node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -3947,6 +4113,24 @@ } } }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -4845,6 +5029,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -5002,6 +5187,7 @@ "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", @@ -5124,6 +5310,7 @@ "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" } @@ -5166,6 +5353,7 @@ "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" } @@ -5176,6 +5364,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5343,6 +5532,7 @@ "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", @@ -5719,7 +5909,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/7zip-bin": { "version": "5.2.0", @@ -5754,6 +5945,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6176,6 +6368,7 @@ "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -6310,6 +6503,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7313,6 +7507,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -7389,8 +7584,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -7849,6 +8043,7 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -7946,8 +8141,7 @@ "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)", - "peer": true + "license": "(MPL-2.0 OR Apache-2.0)" }, "node_modules/dot-prop": { "version": "5.3.0", @@ -8299,7 +8493,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8320,7 +8513,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8336,7 +8528,6 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -8347,7 +8538,6 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -8610,6 +8800,7 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10208,6 +10399,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -11749,7 +11941,6 @@ "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" }, @@ -13777,7 +13968,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13795,7 +13985,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -14247,6 +14436,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14256,6 +14446,7 @@ "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" }, @@ -14282,6 +14473,7 @@ "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" }, @@ -14429,6 +14621,7 @@ "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" @@ -14637,7 +14830,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -15958,7 +16152,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -15999,7 +16192,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -16014,7 +16206,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -16119,6 +16310,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16324,6 +16516,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16736,6 +16929,7 @@ "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", @@ -16827,6 +17021,7 @@ "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/package.json b/package.json index a26dd5f8..d31836ff 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@hookform/resolvers": "^5.1.1", "@monaco-editor/react": "^4.7.0", "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", @@ -45,7 +46,7 @@ "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.6", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.8", diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 5ab7755e..7eaab14c 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -201,12 +201,14 @@ async function initializeCompleteDatabase(): Promise { enable_tunnel INTEGER NOT NULL DEFAULT 1, tunnel_connections TEXT, enable_file_manager INTEGER NOT NULL DEFAULT 1, + enable_docker INTEGER NOT NULL DEFAULT 0, default_path TEXT, autostart_password TEXT, autostart_key TEXT, autostart_key_password TEXT, force_keyboard_interactive TEXT, stats_config TEXT, + docker_config TEXT, terminal_config TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, @@ -561,6 +563,12 @@ const migrateSchema = () => { addColumnIfNotExists("ssh_data", "stats_config", "TEXT"); addColumnIfNotExists("ssh_data", "terminal_config", "TEXT"); addColumnIfNotExists("ssh_data", "quick_actions", "TEXT"); + addColumnIfNotExists( + "ssh_data", + "enable_docker", + "INTEGER NOT NULL DEFAULT 0", + ); + addColumnIfNotExists("ssh_data", "docker_config", "TEXT"); addColumnIfNotExists("ssh_credentials", "private_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 4fe67aba..87a03881 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -86,6 +86,9 @@ export const sshData = sqliteTable("ssh_data", { enableFileManager: integer("enable_file_manager", { mode: "boolean" }) .notNull() .default(true), + enableDocker: integer("enable_docker", { mode: "boolean" }) + .notNull() + .default(false), defaultPath: text("default_path"), statsConfig: text("stats_config"), terminalConfig: text("terminal_config"), diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 8c397a6d..a090b267 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -237,6 +237,7 @@ router.post( enableTerminal, enableTunnel, enableFileManager, + enableDocker, defaultPath, tunnelConnections, jumpHosts, @@ -282,6 +283,7 @@ router.post( ? JSON.stringify(quickActions) : null, enableFileManager: enableFileManager ? 1 : 0, + enableDocker: enableDocker ? 1 : 0, defaultPath: defaultPath || null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, @@ -343,6 +345,7 @@ router.post( ? JSON.parse(createdHost.jumpHosts as string) : [], enableFileManager: !!createdHost.enableFileManager, + enableDocker: !!createdHost.enableDocker, statsConfig: createdHost.statsConfig ? JSON.parse(createdHost.statsConfig as string) : undefined, @@ -459,6 +462,7 @@ router.put( enableTerminal, enableTunnel, enableFileManager, + enableDocker, defaultPath, tunnelConnections, jumpHosts, @@ -505,6 +509,7 @@ router.put( ? JSON.stringify(quickActions) : null, enableFileManager: enableFileManager ? 1 : 0, + enableDocker: enableDocker ? 1 : 0, defaultPath: defaultPath || null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, @@ -584,9 +589,13 @@ router.put( ? JSON.parse(updatedHost.jumpHosts as string) : [], enableFileManager: !!updatedHost.enableFileManager, + enableDocker: !!updatedHost.enableDocker, statsConfig: updatedHost.statsConfig ? JSON.parse(updatedHost.statsConfig as string) : undefined, + dockerConfig: updatedHost.dockerConfig + ? JSON.parse(updatedHost.dockerConfig as string) + : undefined, }; const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; @@ -775,6 +784,7 @@ router.get( ? JSON.parse(row.quickActions as string) : [], enableFileManager: !!row.enableFileManager, + enableDocker: !!row.enableDocker, statsConfig: row.statsConfig ? JSON.parse(row.statsConfig as string) : undefined, diff --git a/src/backend/ssh/docker-console.ts b/src/backend/ssh/docker-console.ts new file mode 100644 index 00000000..3cc28394 --- /dev/null +++ b/src/backend/ssh/docker-console.ts @@ -0,0 +1,687 @@ +import { Client as SSHClient } from "ssh2"; +import { WebSocketServer, WebSocket } from "ws"; +import { parse as parseUrl } from "url"; +import { AuthManager } from "../utils/auth-manager.js"; +import { sshData, sshCredentials } from "../database/db/schema.js"; +import { and, eq } from "drizzle-orm"; +import { getDb } from "../database/db/index.js"; +import { SimpleDBOps } from "../utils/simple-db-ops.js"; +import { systemLogger } from "../utils/logger.js"; +import type { SSHHost } from "../../types/index.js"; + +const dockerConsoleLogger = systemLogger; + +interface SSHSession { + client: SSHClient; + stream: any; + isConnected: boolean; + containerId?: string; + shell?: string; +} + +const activeSessions = new Map(); + +// WebSocket server on port 30008 +const wss = new WebSocketServer({ + port: 30008, + verifyClient: async (info, callback) => { + try { + const url = parseUrl(info.req.url || "", true); + const token = url.query.token as string; + + if (!token) { + dockerConsoleLogger.warn("WebSocket connection rejected: No token", { + operation: "ws_verify", + }); + return callback(false, 401, "Authentication required"); + } + + const authManager = AuthManager.getInstance(); + const decoded = await authManager.verifyJWTToken(token); + + if (!decoded || !decoded.userId) { + dockerConsoleLogger.warn( + "WebSocket connection rejected: Invalid token", + { + operation: "ws_verify", + }, + ); + return callback(false, 401, "Invalid token"); + } + + // Store userId in the request for later use + (info.req as any).userId = decoded.userId; + + dockerConsoleLogger.info("WebSocket connection verified", { + operation: "ws_verify", + userId: decoded.userId, + }); + + callback(true); + } catch (error) { + dockerConsoleLogger.error("WebSocket verification error", error, { + operation: "ws_verify", + }); + callback(false, 500, "Authentication failed"); + } + }, +}); + +// Helper function to detect available shell in container +async function detectShell( + session: SSHSession, + containerId: string, +): Promise { + const shells = ["bash", "sh", "ash"]; + + for (const shell of shells) { + try { + await new Promise((resolve, reject) => { + session.client.exec( + `docker exec ${containerId} which ${shell}`, + (err, stream) => { + if (err) return reject(err); + + let output = ""; + stream.on("data", (data: Buffer) => { + output += data.toString(); + }); + + stream.on("close", (code: number) => { + if (code === 0 && output.trim()) { + resolve(); + } else { + reject(new Error(`Shell ${shell} not found`)); + } + }); + + stream.stderr.on("data", () => { + // Ignore stderr + }); + }, + ); + }); + + // If we get here, the shell was found + return shell; + } catch { + // Try next shell + continue; + } + } + + // Default to sh if nothing else works + return "sh"; +} + +// Helper function to create jump host chain +async function createJumpHostChain( + jumpHosts: any[], + userId: string, +): Promise { + if (!jumpHosts || jumpHosts.length === 0) { + return null; + } + + let currentClient: SSHClient | null = null; + + for (let i = 0; i < jumpHosts.length; i++) { + const jumpHostId = jumpHosts[i].hostId; + + // Fetch jump host from database + const jumpHostData = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where(and(eq(sshData.id, jumpHostId), eq(sshData.userId, userId))), + "ssh_data", + userId, + ); + + if (jumpHostData.length === 0) { + throw new Error(`Jump host ${jumpHostId} not found`); + } + + const jumpHost = jumpHostData[0] as unknown as SSHHost; + if (typeof jumpHost.jumpHosts === "string" && jumpHost.jumpHosts) { + try { + jumpHost.jumpHosts = JSON.parse(jumpHost.jumpHosts); + } catch (e) { + dockerConsoleLogger.error("Failed to parse jump hosts", e, { + hostId: jumpHost.id, + }); + jumpHost.jumpHosts = []; + } + } + + // Resolve credentials for jump host + let resolvedCredentials: any = { + password: jumpHost.password, + sshKey: jumpHost.key, + keyPassword: jumpHost.keyPassword, + authType: jumpHost.authType, + }; + + if (jumpHost.credentialId) { + const credentials = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, jumpHost.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedCredentials = { + password: credential.password, + sshKey: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + authType: credential.auth_type || credential.authType, + }; + } + } + + const client = new SSHClient(); + + const config: any = { + host: jumpHost.ip, + port: jumpHost.port || 22, + username: jumpHost.username, + tryKeyboard: true, + readyTimeout: 60000, + keepaliveInterval: 30000, + keepaliveCountMax: 120, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + }; + + // Set authentication + if ( + resolvedCredentials.authType === "password" && + resolvedCredentials.password + ) { + config.password = resolvedCredentials.password; + } else if ( + resolvedCredentials.authType === "key" && + resolvedCredentials.sshKey + ) { + const cleanKey = resolvedCredentials.sshKey + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + config.privateKey = Buffer.from(cleanKey, "utf8"); + if (resolvedCredentials.keyPassword) { + config.passphrase = resolvedCredentials.keyPassword; + } + } + + // If we have a previous client, use it as the sock + if (currentClient) { + await new Promise((resolve, reject) => { + currentClient!.forwardOut( + "127.0.0.1", + 0, + jumpHost.ip, + jumpHost.port || 22, + (err, stream) => { + if (err) return reject(err); + config.sock = stream; + resolve(); + }, + ); + }); + } + + await new Promise((resolve, reject) => { + client.on("ready", () => resolve()); + client.on("error", reject); + client.connect(config); + }); + + currentClient = client; + } + + return currentClient; +} + +// Handle WebSocket connections +wss.on("connection", async (ws: WebSocket, req) => { + const userId = (req as any).userId; + const sessionId = `docker-console-${Date.now()}-${Math.random()}`; + + dockerConsoleLogger.info("Docker console WebSocket connected", { + operation: "ws_connect", + sessionId, + userId, + }); + + let sshSession: SSHSession | null = null; + + ws.on("message", async (data) => { + try { + const message = JSON.parse(data.toString()); + + switch (message.type) { + case "connect": { + const { hostConfig, containerId, shell, cols, rows } = + message.data as { + hostConfig: SSHHost; + containerId: string; + shell?: string; + cols?: number; + rows?: number; + }; + + if ( + typeof hostConfig.jumpHosts === "string" && + hostConfig.jumpHosts + ) { + try { + hostConfig.jumpHosts = JSON.parse(hostConfig.jumpHosts); + } catch (e) { + dockerConsoleLogger.error("Failed to parse jump hosts", e, { + hostId: hostConfig.id, + }); + hostConfig.jumpHosts = []; + } + } + + if (!hostConfig || !containerId) { + ws.send( + JSON.stringify({ + type: "error", + message: "Host configuration and container ID are required", + }), + ); + return; + } + + // Check if Docker is enabled for this host + if (!hostConfig.enableDocker) { + ws.send( + JSON.stringify({ + type: "error", + message: + "Docker is not enabled for this host. Enable it in Host Settings.", + }), + ); + return; + } + + try { + // Resolve credentials + let resolvedCredentials: any = { + password: hostConfig.password, + sshKey: hostConfig.key, + keyPassword: hostConfig.keyPassword, + authType: hostConfig.authType, + }; + + if (hostConfig.credentialId) { + const credentials = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, hostConfig.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedCredentials = { + password: credential.password, + sshKey: + credential.private_key || + credential.privateKey || + credential.key, + keyPassword: + credential.key_password || credential.keyPassword, + authType: credential.auth_type || credential.authType, + }; + } + } + + // Create SSH client + const client = new SSHClient(); + + const config: any = { + host: hostConfig.ip, + port: hostConfig.port || 22, + username: hostConfig.username, + tryKeyboard: true, + readyTimeout: 60000, + keepaliveInterval: 30000, + keepaliveCountMax: 120, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + }; + + // Set authentication + if ( + resolvedCredentials.authType === "password" && + resolvedCredentials.password + ) { + config.password = resolvedCredentials.password; + } else if ( + resolvedCredentials.authType === "key" && + resolvedCredentials.sshKey + ) { + const cleanKey = resolvedCredentials.sshKey + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + config.privateKey = Buffer.from(cleanKey, "utf8"); + if (resolvedCredentials.keyPassword) { + config.passphrase = resolvedCredentials.keyPassword; + } + } + + // Handle jump hosts if configured + if (hostConfig.jumpHosts && hostConfig.jumpHosts.length > 0) { + const jumpClient = await createJumpHostChain( + hostConfig.jumpHosts, + userId, + ); + if (jumpClient) { + const stream = await new Promise((resolve, reject) => { + jumpClient.forwardOut( + "127.0.0.1", + 0, + hostConfig.ip, + hostConfig.port || 22, + (err, stream) => { + if (err) return reject(err); + resolve(stream); + }, + ); + }); + config.sock = stream; + } + } + + // Connect to SSH + await new Promise((resolve, reject) => { + client.on("ready", () => resolve()); + client.on("error", reject); + client.connect(config); + }); + + sshSession = { + client, + stream: null, + isConnected: true, + containerId, + }; + + activeSessions.set(sessionId, sshSession); + + // Detect or use provided shell + const detectedShell = + shell || (await detectShell(sshSession, containerId)); + sshSession.shell = detectedShell; + + // Create docker exec PTY + const execCommand = `docker exec -it ${containerId} /bin/${detectedShell}`; + + client.exec( + execCommand, + { + pty: { + term: "xterm-256color", + cols: cols || 80, + rows: rows || 24, + }, + }, + (err, stream) => { + if (err) { + dockerConsoleLogger.error( + "Failed to create docker exec", + err, + { + operation: "docker_exec", + sessionId, + containerId, + }, + ); + + ws.send( + JSON.stringify({ + type: "error", + message: `Failed to start console: ${err.message}`, + }), + ); + return; + } + + sshSession!.stream = stream; + + // Forward stream output to WebSocket + stream.on("data", (data: Buffer) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "output", + data: data.toString("utf8"), + }), + ); + } + }); + + stream.stderr.on("data", (data: Buffer) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "output", + data: data.toString("utf8"), + }), + ); + } + }); + + stream.on("close", () => { + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "disconnected", + message: "Console session ended", + }), + ); + } + + // Cleanup + if (sshSession) { + sshSession.client.end(); + activeSessions.delete(sessionId); + } + }); + + ws.send( + JSON.stringify({ + type: "connected", + data: { shell: detectedShell }, + }), + ); + + dockerConsoleLogger.info("Docker console session started", { + operation: "console_start", + sessionId, + containerId, + shell: detectedShell, + }); + }, + ); + } catch (error) { + dockerConsoleLogger.error("Failed to connect to container", error, { + operation: "console_connect", + sessionId, + containerId: message.data.containerId, + }); + + ws.send( + JSON.stringify({ + type: "error", + message: + error instanceof Error + ? error.message + : "Failed to connect to container", + }), + ); + } + break; + } + + case "input": { + if (sshSession && sshSession.stream) { + sshSession.stream.write(message.data); + } + break; + } + + case "resize": { + if (sshSession && sshSession.stream) { + const { cols, rows } = message.data; + sshSession.stream.setWindow(rows, cols); + + dockerConsoleLogger.debug("Console resized", { + operation: "console_resize", + sessionId, + cols, + rows, + }); + } + break; + } + + case "disconnect": { + if (sshSession) { + if (sshSession.stream) { + sshSession.stream.end(); + } + sshSession.client.end(); + activeSessions.delete(sessionId); + + dockerConsoleLogger.info("Docker console disconnected", { + operation: "console_disconnect", + sessionId, + }); + + ws.send( + JSON.stringify({ + type: "disconnected", + message: "Disconnected from container", + }), + ); + } + break; + } + + case "ping": { + // Respond with pong to acknowledge keepalive + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "pong" })); + } + break; + } + + default: + dockerConsoleLogger.warn("Unknown message type", { + operation: "ws_message", + type: message.type, + }); + } + } catch (error) { + dockerConsoleLogger.error("WebSocket message error", error, { + operation: "ws_message", + sessionId, + }); + + ws.send( + JSON.stringify({ + type: "error", + message: error instanceof Error ? error.message : "An error occurred", + }), + ); + } + }); + + ws.on("close", () => { + dockerConsoleLogger.info("WebSocket connection closed", { + operation: "ws_close", + sessionId, + }); + + // Cleanup SSH session if still active + if (sshSession) { + if (sshSession.stream) { + sshSession.stream.end(); + } + sshSession.client.end(); + activeSessions.delete(sessionId); + } + }); + + ws.on("error", (error) => { + dockerConsoleLogger.error("WebSocket error", error, { + operation: "ws_error", + sessionId, + }); + + // Cleanup + if (sshSession) { + if (sshSession.stream) { + sshSession.stream.end(); + } + sshSession.client.end(); + activeSessions.delete(sessionId); + } + }); +}); + +dockerConsoleLogger.info( + "Docker console WebSocket server started on port 30008", + { + operation: "startup", + }, +); + +// Graceful shutdown +process.on("SIGTERM", () => { + dockerConsoleLogger.info("Shutting down Docker console server...", { + operation: "shutdown", + }); + + // Close all active sessions + activeSessions.forEach((session, sessionId) => { + if (session.stream) { + session.stream.end(); + } + session.client.end(); + dockerConsoleLogger.info("Closed session during shutdown", { + operation: "shutdown", + sessionId, + }); + }); + + activeSessions.clear(); + + wss.close(() => { + dockerConsoleLogger.info("Docker console server closed", { + operation: "shutdown", + }); + process.exit(0); + }); +}); diff --git a/src/backend/ssh/docker.ts b/src/backend/ssh/docker.ts new file mode 100644 index 00000000..dff81f9c --- /dev/null +++ b/src/backend/ssh/docker.ts @@ -0,0 +1,1464 @@ +import express from "express"; +import cors from "cors"; +import cookieParser from "cookie-parser"; +import { Client as SSHClient } from "ssh2"; +import type { ClientChannel } from "ssh2"; +import { getDb } from "../database/db/index.js"; +import { sshData, sshCredentials } from "../database/db/schema.js"; +import { eq, and } from "drizzle-orm"; +import { logger } from "../utils/logger.js"; +import { SimpleDBOps } from "../utils/simple-db-ops.js"; +import { AuthManager } from "../utils/auth-manager.js"; +import type { AuthenticatedRequest, SSHHost } from "../../types/index.js"; + +// Create dedicated logger for Docker operations +const dockerLogger = logger; + +// SSH Session Management +interface SSHSession { + client: SSHClient; + isConnected: boolean; + lastActive: number; + timeout?: NodeJS.Timeout; + activeOperations: number; + hostId?: number; +} + +const sshSessions: Record = {}; + +// Session cleanup with 60-minute idle timeout +const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000; + +function cleanupSession(sessionId: string) { + const session = sshSessions[sessionId]; + if (session) { + if (session.activeOperations > 0) { + dockerLogger.warn( + `Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`, + { + operation: "cleanup_deferred", + sessionId, + activeOperations: session.activeOperations, + }, + ); + scheduleSessionCleanup(sessionId); + return; + } + + try { + session.client.end(); + } catch (error) { + dockerLogger.debug("Error ending SSH client during cleanup", { error }); + } + clearTimeout(session.timeout); + delete sshSessions[sessionId]; + dockerLogger.info("Docker SSH session cleaned up", { + operation: "session_cleanup", + sessionId, + }); + } +} + +function scheduleSessionCleanup(sessionId: string) { + const session = sshSessions[sessionId]; + if (session) { + if (session.timeout) clearTimeout(session.timeout); + + session.timeout = setTimeout(() => { + cleanupSession(sessionId); + }, SESSION_IDLE_TIMEOUT); + } +} + +// Helper function to resolve jump host +async function resolveJumpHost( + hostId: number, + userId: string, +): Promise { + try { + const hosts = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))), + "ssh_data", + userId, + ); + + if (hosts.length === 0) { + return null; + } + + const host = hosts[0]; + + if (host.credentialId) { + const credentials = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + return { + ...host, + password: credential.password, + key: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + keyType: credential.key_type || credential.keyType, + authType: credential.auth_type || credential.authType, + }; + } + } + + return host; + } catch (error) { + dockerLogger.error("Failed to resolve jump host", error, { + operation: "resolve_jump_host", + hostId, + userId, + }); + return null; + } +} + +// Helper function to create jump host chain +async function createJumpHostChain( + jumpHosts: Array<{ hostId: number }>, + userId: string, +): Promise { + if (!jumpHosts || jumpHosts.length === 0) { + return null; + } + + let currentClient: SSHClient | null = null; + const clients: SSHClient[] = []; + + try { + for (let i = 0; i < jumpHosts.length; i++) { + const jumpHostConfig = await resolveJumpHost(jumpHosts[i].hostId, userId); + + if (!jumpHostConfig) { + dockerLogger.error(`Jump host ${i + 1} not found`, undefined, { + operation: "jump_host_chain", + hostId: jumpHosts[i].hostId, + }); + clients.forEach((c) => c.end()); + return null; + } + + const jumpClient = new SSHClient(); + clients.push(jumpClient); + + const connected = await new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false); + }, 30000); + + jumpClient.on("ready", () => { + clearTimeout(timeout); + resolve(true); + }); + + jumpClient.on("error", (err) => { + clearTimeout(timeout); + dockerLogger.error(`Jump host ${i + 1} connection failed`, err, { + operation: "jump_host_connect", + hostId: jumpHostConfig.id, + ip: jumpHostConfig.ip, + }); + resolve(false); + }); + + const connectConfig: any = { + host: jumpHostConfig.ip, + port: jumpHostConfig.port || 22, + username: jumpHostConfig.username, + tryKeyboard: true, + readyTimeout: 30000, + }; + + if (jumpHostConfig.authType === "password" && jumpHostConfig.password) { + connectConfig.password = jumpHostConfig.password; + } else if (jumpHostConfig.authType === "key" && jumpHostConfig.key) { + const cleanKey = jumpHostConfig.key + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + connectConfig.privateKey = Buffer.from(cleanKey, "utf8"); + if (jumpHostConfig.keyPassword) { + connectConfig.passphrase = jumpHostConfig.keyPassword; + } + } + + if (currentClient) { + currentClient.forwardOut( + "127.0.0.1", + 0, + jumpHostConfig.ip, + jumpHostConfig.port || 22, + (err, stream) => { + if (err) { + clearTimeout(timeout); + resolve(false); + return; + } + connectConfig.sock = stream; + jumpClient.connect(connectConfig); + }, + ); + } else { + jumpClient.connect(connectConfig); + } + }); + + if (!connected) { + clients.forEach((c) => c.end()); + return null; + } + + currentClient = jumpClient; + } + + return currentClient; + } catch (error) { + dockerLogger.error("Failed to create jump host chain", error, { + operation: "jump_host_chain", + }); + clients.forEach((c) => c.end()); + return null; + } +} + +// Helper function to execute Docker CLI commands +async function executeDockerCommand( + session: SSHSession, + command: string, +): Promise { + return new Promise((resolve, reject) => { + session.client.exec(command, (err, stream) => { + if (err) { + dockerLogger.error("Docker command execution error", err, { + operation: "execute_docker_command", + command, + }); + return reject(err); + } + + let stdout = ""; + let stderr = ""; + + stream.on("close", (code: number) => { + if (code !== 0) { + dockerLogger.error("Docker command failed", undefined, { + operation: "execute_docker_command", + command, + exitCode: code, + stderr, + }); + reject(new Error(stderr || `Command exited with code ${code}`)); + } else { + resolve(stdout); + } + }); + + stream.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + + stream.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + + stream.on("error", (streamErr: Error) => { + dockerLogger.error("Docker command stream error", streamErr, { + operation: "execute_docker_command", + command, + }); + reject(streamErr); + }); + }); + }); +} + +// Express app setup +const app = express(); + +app.use( + cors({ + origin: (origin, callback) => { + if (!origin) return callback(null, true); + + const allowedOrigins = [ + "http://localhost:5173", + "http://localhost:3000", + "http://127.0.0.1:5173", + "http://127.0.0.1:3000", + ]; + + if (origin.startsWith("https://")) { + return callback(null, true); + } + + if (origin.startsWith("http://")) { + return callback(null, true); + } + + if (allowedOrigins.includes(origin)) { + return callback(null, true); + } + + callback(new Error("Not allowed by CORS")); + }, + credentials: true, + methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allowedHeaders: [ + "Content-Type", + "Authorization", + "User-Agent", + "X-Electron-App", + ], + }), +); + +app.use(cookieParser()); +app.use(express.json({ limit: "100mb" })); +app.use(express.urlencoded({ limit: "100mb", extended: true })); + +// Initialize AuthManager and apply middleware +const authManager = AuthManager.getInstance(); +app.use(authManager.createAuthMiddleware()); + +// Session management endpoints + +// POST /docker/ssh/connect - Establish SSH session +app.post("/docker/ssh/connect", async (req, res) => { + const { sessionId, hostId } = req.body; + const userId = (req as any).userId; + + if (!userId) { + dockerLogger.error( + "Docker SSH connection rejected: no authenticated user", + { + operation: "docker_connect_auth", + sessionId, + }, + ); + return res.status(401).json({ error: "Authentication required" }); + } + + if (!SimpleDBOps.isUserDataUnlocked(userId)) { + return res.status(401).json({ + error: "Session expired - please log in again", + code: "SESSION_EXPIRED", + }); + } + + if (!sessionId || !hostId) { + dockerLogger.warn("Missing Docker SSH connection parameters", { + operation: "docker_connect", + sessionId, + hasHostId: !!hostId, + }); + return res.status(400).json({ error: "Missing sessionId or hostId" }); + } + + try { + // Get host configuration + const hosts = await SimpleDBOps.select( + getDb() + .select() + .from(sshData) + .where(and(eq(sshData.id, hostId), eq(sshData.userId, userId))), + "ssh_data", + userId, + ); + + if (hosts.length === 0) { + return res.status(404).json({ error: "Host not found" }); + } + + const host = hosts[0] as unknown as SSHHost; + if (typeof host.jumpHosts === "string" && host.jumpHosts) { + try { + host.jumpHosts = JSON.parse(host.jumpHosts); + } catch (e) { + dockerLogger.error("Failed to parse jump hosts", e, { + hostId: host.id, + }); + host.jumpHosts = []; + } + } + + // Check if Docker is enabled for this host + if (!host.enableDocker) { + dockerLogger.warn("Docker not enabled for host", { + operation: "docker_connect", + hostId, + userId, + }); + return res.status(403).json({ + error: + "Docker is not enabled for this host. Enable it in Host Settings.", + code: "DOCKER_DISABLED", + }); + } + + // Clean up existing session if any + if (sshSessions[sessionId]) { + cleanupSession(sessionId); + } + + // Resolve credentials + let resolvedCredentials: any = { + password: host.password, + sshKey: host.key, + keyPassword: host.keyPassword, + authType: host.authType, + }; + + if (host.credentialId) { + const credentials = await SimpleDBOps.select( + getDb() + .select() + .from(sshCredentials) + .where( + and( + eq(sshCredentials.id, host.credentialId as number), + eq(sshCredentials.userId, userId), + ), + ), + "ssh_credentials", + userId, + ); + + if (credentials.length > 0) { + const credential = credentials[0]; + resolvedCredentials = { + password: credential.password, + sshKey: + credential.private_key || credential.privateKey || credential.key, + keyPassword: credential.key_password || credential.keyPassword, + authType: credential.auth_type || credential.authType, + }; + } + } + + // Create SSH client + const client = new SSHClient(); + + const config: any = { + host: host.ip, + port: host.port || 22, + username: host.username, + tryKeyboard: true, + keepaliveInterval: 30000, + keepaliveCountMax: 3, + readyTimeout: 60000, + tcpKeepAlive: true, + tcpKeepAliveInitialDelay: 30000, + }; + + // Set authentication + if ( + resolvedCredentials.authType === "password" && + resolvedCredentials.password + ) { + config.password = resolvedCredentials.password; + } else if ( + resolvedCredentials.authType === "key" && + resolvedCredentials.sshKey + ) { + const cleanKey = resolvedCredentials.sshKey + .trim() + .replace(/\r\n/g, "\n") + .replace(/\r/g, "\n"); + config.privateKey = Buffer.from(cleanKey, "utf8"); + if (resolvedCredentials.keyPassword) { + config.passphrase = resolvedCredentials.keyPassword; + } + } + + let responseSent = false; + + client.on("ready", () => { + if (responseSent) return; + responseSent = true; + + sshSessions[sessionId] = { + client, + isConnected: true, + lastActive: Date.now(), + activeOperations: 0, + hostId, + }; + + scheduleSessionCleanup(sessionId); + + dockerLogger.info("Docker SSH session established", { + operation: "docker_connect", + sessionId, + hostId, + userId, + }); + + res.json({ success: true, message: "SSH connection established" }); + }); + + client.on("error", (err) => { + if (responseSent) return; + responseSent = true; + + dockerLogger.error("Docker SSH connection failed", err, { + operation: "docker_connect", + sessionId, + hostId, + userId, + }); + + res.status(500).json({ + success: false, + message: err.message || "SSH connection failed", + }); + }); + + client.on("close", () => { + if (sshSessions[sessionId]) { + sshSessions[sessionId].isConnected = false; + cleanupSession(sessionId); + } + }); + + // Handle jump hosts if configured + if (host.jumpHosts && host.jumpHosts.length > 0) { + const jumpClient = await createJumpHostChain( + host.jumpHosts as Array<{ hostId: number }>, + userId, + ); + + if (!jumpClient) { + return res.status(500).json({ + error: "Failed to establish jump host chain", + }); + } + + jumpClient.forwardOut( + "127.0.0.1", + 0, + host.ip, + host.port || 22, + (err, stream) => { + if (err) { + dockerLogger.error("Failed to forward through jump host", err, { + operation: "docker_jump_forward", + sessionId, + hostId, + }); + jumpClient.end(); + if (!responseSent) { + responseSent = true; + return res.status(500).json({ + error: "Failed to forward through jump host: " + err.message, + }); + } + return; + } + + config.sock = stream; + client.connect(config); + }, + ); + } else { + client.connect(config); + } + } catch (error) { + dockerLogger.error("Docker SSH connection error", error, { + operation: "docker_connect", + sessionId, + hostId, + userId, + }); + res.status(500).json({ + success: false, + message: error instanceof Error ? error.message : "Unknown error", + }); + } +}); + +// POST /docker/ssh/disconnect - Close SSH session +app.post("/docker/ssh/disconnect", async (req, res) => { + const { sessionId } = req.body; + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + cleanupSession(sessionId); + + dockerLogger.info("Docker SSH session disconnected", { + operation: "docker_disconnect", + sessionId, + }); + + res.json({ success: true, message: "SSH session disconnected" }); +}); + +// POST /docker/ssh/keepalive - Keep session alive +app.post("/docker/ssh/keepalive", async (req, res) => { + const { sessionId } = req.body; + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + connected: false, + }); + } + + session.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + res.json({ + success: true, + connected: true, + message: "Session keepalive successful", + lastActive: session.lastActive, + }); +}); + +// GET /docker/ssh/status - Check session status +app.get("/docker/ssh/status", async (req, res) => { + const sessionId = req.query.sessionId as string; + + if (!sessionId) { + return res.status(400).json({ error: "Session ID is required" }); + } + + const isConnected = !!sshSessions[sessionId]?.isConnected; + + res.json({ success: true, connected: isConnected }); +}); + +// GET /docker/validate/:sessionId - Validate Docker availability +app.get("/docker/validate/:sessionId", async (req, res) => { + const { sessionId } = req.params; + const userId = (req as any).userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + }); + } + + session.lastActive = Date.now(); + session.activeOperations++; + + try { + // Check if Docker is installed + try { + const versionOutput = await executeDockerCommand( + session, + "docker --version", + ); + const versionMatch = versionOutput.match(/Docker version ([^\s,]+)/); + const version = versionMatch ? versionMatch[1] : "unknown"; + + // Check if Docker daemon is running + try { + await executeDockerCommand(session, "docker ps >/dev/null 2>&1"); + + session.activeOperations--; + return res.json({ + available: true, + version, + }); + } catch (daemonError) { + session.activeOperations--; + const errorMsg = + daemonError instanceof Error ? daemonError.message : ""; + + if (errorMsg.includes("Cannot connect to the Docker daemon")) { + return res.json({ + available: false, + error: + "Docker daemon is not running. Start it with: sudo systemctl start docker", + code: "DAEMON_NOT_RUNNING", + }); + } + + if (errorMsg.includes("permission denied")) { + return res.json({ + available: false, + error: + "Permission denied. Add your user to the docker group: sudo usermod -aG docker $USER", + code: "PERMISSION_DENIED", + }); + } + + return res.json({ + available: false, + error: errorMsg, + code: "DOCKER_ERROR", + }); + } + } catch (installError) { + session.activeOperations--; + return res.json({ + available: false, + error: + "Docker is not installed on this host. Please install Docker to use this feature.", + code: "NOT_INSTALLED", + }); + } + } catch (error) { + session.activeOperations--; + dockerLogger.error("Docker validation error", error, { + operation: "docker_validate", + sessionId, + userId, + }); + + res.status(500).json({ + available: false, + error: error instanceof Error ? error.message : "Validation failed", + }); + } +}); + +// GET /docker/containers/:sessionId - List all containers +app.get("/docker/containers/:sessionId", async (req, res) => { + const { sessionId } = req.params; + const all = req.query.all !== "false"; // Default to true + const userId = (req as any).userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + }); + } + + session.lastActive = Date.now(); + session.activeOperations++; + + try { + const allFlag = all ? "-a " : ""; + const command = `docker ps ${allFlag}--format '{"id":"{{.ID}}","name":"{{.Names}}","image":"{{.Image}}","status":"{{.Status}}","state":"{{.State}}","ports":"{{.Ports}}","created":"{{.CreatedAt}}"}'`; + + const output = await executeDockerCommand(session, command); + + const containers = output + .split("\n") + .filter((line) => line.trim()) + .map((line) => { + try { + return JSON.parse(line); + } catch (e) { + dockerLogger.warn("Failed to parse container line", { + operation: "parse_container", + line, + }); + return null; + } + }) + .filter((c) => c !== null); + + session.activeOperations--; + + res.json(containers); + } catch (error) { + session.activeOperations--; + dockerLogger.error("Failed to list Docker containers", error, { + operation: "list_containers", + sessionId, + userId, + }); + + res.status(500).json({ + error: + error instanceof Error ? error.message : "Failed to list containers", + }); + } +}); + +// GET /docker/containers/:sessionId/:containerId - Get container details +app.get("/docker/containers/:sessionId/:containerId", async (req, res) => { + const { sessionId, containerId } = req.params; + const userId = (req as any).userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + }); + } + + session.lastActive = Date.now(); + session.activeOperations++; + + try { + const command = `docker inspect ${containerId}`; + const output = await executeDockerCommand(session, command); + const details = JSON.parse(output); + + session.activeOperations--; + + if (details && details.length > 0) { + res.json(details[0]); + } else { + res.status(404).json({ + error: "Container not found", + code: "CONTAINER_NOT_FOUND", + }); + } + } catch (error) { + session.activeOperations--; + + const errorMsg = error instanceof Error ? error.message : ""; + if (errorMsg.includes("No such container")) { + return res.status(404).json({ + error: "Container not found", + code: "CONTAINER_NOT_FOUND", + }); + } + + dockerLogger.error("Failed to get container details", error, { + operation: "get_container_details", + sessionId, + containerId, + userId, + }); + + res.status(500).json({ + error: errorMsg || "Failed to get container details", + }); + } +}); + +// POST /docker/containers/:sessionId/:containerId/start - Start container +app.post( + "/docker/containers/:sessionId/:containerId/start", + async (req, res) => { + const { sessionId, containerId } = req.params; + const userId = (req as any).userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + }); + } + + session.lastActive = Date.now(); + session.activeOperations++; + + try { + await executeDockerCommand(session, `docker start ${containerId}`); + + session.activeOperations--; + + dockerLogger.info("Container started", { + operation: "start_container", + sessionId, + containerId, + userId, + }); + + res.json({ + success: true, + message: "Container started successfully", + }); + } catch (error) { + session.activeOperations--; + + const errorMsg = error instanceof Error ? error.message : ""; + if (errorMsg.includes("No such container")) { + return res.status(404).json({ + success: false, + error: "Container not found", + code: "CONTAINER_NOT_FOUND", + }); + } + + dockerLogger.error("Failed to start container", error, { + operation: "start_container", + sessionId, + containerId, + userId, + }); + + res.status(500).json({ + success: false, + error: errorMsg || "Failed to start container", + }); + } + }, +); + +// POST /docker/containers/:sessionId/:containerId/stop - Stop container +app.post( + "/docker/containers/:sessionId/:containerId/stop", + async (req, res) => { + const { sessionId, containerId } = req.params; + const userId = (req as any).userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + }); + } + + session.lastActive = Date.now(); + session.activeOperations++; + + try { + await executeDockerCommand(session, `docker stop ${containerId}`); + + session.activeOperations--; + + dockerLogger.info("Container stopped", { + operation: "stop_container", + sessionId, + containerId, + userId, + }); + + res.json({ + success: true, + message: "Container stopped successfully", + }); + } catch (error) { + session.activeOperations--; + + const errorMsg = error instanceof Error ? error.message : ""; + if (errorMsg.includes("No such container")) { + return res.status(404).json({ + success: false, + error: "Container not found", + code: "CONTAINER_NOT_FOUND", + }); + } + + dockerLogger.error("Failed to stop container", error, { + operation: "stop_container", + sessionId, + containerId, + userId, + }); + + res.status(500).json({ + success: false, + error: errorMsg || "Failed to stop container", + }); + } + }, +); + +// POST /docker/containers/:sessionId/:containerId/restart - Restart container +app.post( + "/docker/containers/:sessionId/:containerId/restart", + async (req, res) => { + const { sessionId, containerId } = req.params; + const userId = (req as any).userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + }); + } + + session.lastActive = Date.now(); + session.activeOperations++; + + try { + await executeDockerCommand(session, `docker restart ${containerId}`); + + session.activeOperations--; + + dockerLogger.info("Container restarted", { + operation: "restart_container", + sessionId, + containerId, + userId, + }); + + res.json({ + success: true, + message: "Container restarted successfully", + }); + } catch (error) { + session.activeOperations--; + + const errorMsg = error instanceof Error ? error.message : ""; + if (errorMsg.includes("No such container")) { + return res.status(404).json({ + success: false, + error: "Container not found", + code: "CONTAINER_NOT_FOUND", + }); + } + + dockerLogger.error("Failed to restart container", error, { + operation: "restart_container", + sessionId, + containerId, + userId, + }); + + res.status(500).json({ + success: false, + error: errorMsg || "Failed to restart container", + }); + } + }, +); + +// POST /docker/containers/:sessionId/:containerId/pause - Pause container +app.post( + "/docker/containers/:sessionId/:containerId/pause", + async (req, res) => { + const { sessionId, containerId } = req.params; + const userId = (req as any).userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + }); + } + + session.lastActive = Date.now(); + session.activeOperations++; + + try { + await executeDockerCommand(session, `docker pause ${containerId}`); + + session.activeOperations--; + + dockerLogger.info("Container paused", { + operation: "pause_container", + sessionId, + containerId, + userId, + }); + + res.json({ + success: true, + message: "Container paused successfully", + }); + } catch (error) { + session.activeOperations--; + + const errorMsg = error instanceof Error ? error.message : ""; + if (errorMsg.includes("No such container")) { + return res.status(404).json({ + success: false, + error: "Container not found", + code: "CONTAINER_NOT_FOUND", + }); + } + + dockerLogger.error("Failed to pause container", error, { + operation: "pause_container", + sessionId, + containerId, + userId, + }); + + res.status(500).json({ + success: false, + error: errorMsg || "Failed to pause container", + }); + } + }, +); + +// POST /docker/containers/:sessionId/:containerId/unpause - Unpause container +app.post( + "/docker/containers/:sessionId/:containerId/unpause", + async (req, res) => { + const { sessionId, containerId } = req.params; + const userId = (req as any).userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + }); + } + + session.lastActive = Date.now(); + session.activeOperations++; + + try { + await executeDockerCommand(session, `docker unpause ${containerId}`); + + session.activeOperations--; + + dockerLogger.info("Container unpaused", { + operation: "unpause_container", + sessionId, + containerId, + userId, + }); + + res.json({ + success: true, + message: "Container unpaused successfully", + }); + } catch (error) { + session.activeOperations--; + + const errorMsg = error instanceof Error ? error.message : ""; + if (errorMsg.includes("No such container")) { + return res.status(404).json({ + success: false, + error: "Container not found", + code: "CONTAINER_NOT_FOUND", + }); + } + + dockerLogger.error("Failed to unpause container", error, { + operation: "unpause_container", + sessionId, + containerId, + userId, + }); + + res.status(500).json({ + success: false, + error: errorMsg || "Failed to unpause container", + }); + } + }, +); + +// DELETE /docker/containers/:sessionId/:containerId/remove - Remove container +app.delete( + "/docker/containers/:sessionId/:containerId/remove", + async (req, res) => { + const { sessionId, containerId } = req.params; + const force = req.query.force === "true"; + const userId = (req as any).userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + }); + } + + session.lastActive = Date.now(); + session.activeOperations++; + + try { + const forceFlag = force ? "-f " : ""; + await executeDockerCommand( + session, + `docker rm ${forceFlag}${containerId}`, + ); + + session.activeOperations--; + + dockerLogger.info("Container removed", { + operation: "remove_container", + sessionId, + containerId, + force, + userId, + }); + + res.json({ + success: true, + message: "Container removed successfully", + }); + } catch (error) { + session.activeOperations--; + + const errorMsg = error instanceof Error ? error.message : ""; + if (errorMsg.includes("No such container")) { + return res.status(404).json({ + success: false, + error: "Container not found", + code: "CONTAINER_NOT_FOUND", + }); + } + + if (errorMsg.includes("cannot remove a running container")) { + return res.status(400).json({ + success: false, + error: + "Cannot remove a running container. Stop it first or use force.", + code: "CONTAINER_RUNNING", + }); + } + + dockerLogger.error("Failed to remove container", error, { + operation: "remove_container", + sessionId, + containerId, + userId, + }); + + res.status(500).json({ + success: false, + error: errorMsg || "Failed to remove container", + }); + } + }, +); + +// GET /docker/containers/:sessionId/:containerId/logs - Get container logs +app.get("/docker/containers/:sessionId/:containerId/logs", async (req, res) => { + const { sessionId, containerId } = req.params; + const tail = req.query.tail ? parseInt(req.query.tail as string) : 100; + const timestamps = req.query.timestamps === "true"; + const since = req.query.since as string; + const until = req.query.until as string; + const userId = (req as any).userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + }); + } + + session.lastActive = Date.now(); + session.activeOperations++; + + try { + let command = `docker logs ${containerId}`; + + if (tail && tail > 0) { + command += ` --tail ${tail}`; + } + + if (timestamps) { + command += " --timestamps"; + } + + if (since) { + command += ` --since ${since}`; + } + + if (until) { + command += ` --until ${until}`; + } + + const logs = await executeDockerCommand(session, command); + + session.activeOperations--; + + res.json({ + success: true, + logs, + }); + } catch (error) { + session.activeOperations--; + + const errorMsg = error instanceof Error ? error.message : ""; + if (errorMsg.includes("No such container")) { + return res.status(404).json({ + success: false, + error: "Container not found", + code: "CONTAINER_NOT_FOUND", + }); + } + + dockerLogger.error("Failed to get container logs", error, { + operation: "get_logs", + sessionId, + containerId, + userId, + }); + + res.status(500).json({ + success: false, + error: errorMsg || "Failed to get container logs", + }); + } +}); + +// GET /docker/containers/:sessionId/:containerId/stats - Get container stats +app.get( + "/docker/containers/:sessionId/:containerId/stats", + async (req, res) => { + const { sessionId, containerId } = req.params; + const userId = (req as any).userId; + + if (!userId) { + return res.status(401).json({ error: "Authentication required" }); + } + + const session = sshSessions[sessionId]; + + if (!session || !session.isConnected) { + return res.status(400).json({ + error: "SSH session not found or not connected", + }); + } + + session.lastActive = Date.now(); + session.activeOperations++; + + try { + const command = `docker stats ${containerId} --no-stream --format '{"cpu":"{{.CPUPerc}}","memory":"{{.MemUsage}}","memoryPercent":"{{.MemPerc}}","netIO":"{{.NetIO}}","blockIO":"{{.BlockIO}}","pids":"{{.PIDs}}"}'`; + + const output = await executeDockerCommand(session, command); + const rawStats = JSON.parse(output.trim()); + + // Parse memory usage (e.g., "1.5GiB / 8GiB" -> { used: "1.5GiB", limit: "8GiB" }) + const memoryParts = rawStats.memory.split(" / "); + const memoryUsed = memoryParts[0]?.trim() || "0B"; + const memoryLimit = memoryParts[1]?.trim() || "0B"; + + // Parse network I/O (e.g., "1.5MB / 2.3MB" -> { input: "1.5MB", output: "2.3MB" }) + const netIOParts = rawStats.netIO.split(" / "); + const netInput = netIOParts[0]?.trim() || "0B"; + const netOutput = netIOParts[1]?.trim() || "0B"; + + // Parse block I/O (e.g., "10MB / 5MB" -> { read: "10MB", write: "5MB" }) + const blockIOParts = rawStats.blockIO.split(" / "); + const blockRead = blockIOParts[0]?.trim() || "0B"; + const blockWrite = blockIOParts[1]?.trim() || "0B"; + + const stats = { + cpu: rawStats.cpu, + memoryUsed, + memoryLimit, + memoryPercent: rawStats.memoryPercent, + netInput, + netOutput, + blockRead, + blockWrite, + pids: rawStats.pids, + }; + + session.activeOperations--; + + res.json(stats); + } catch (error) { + session.activeOperations--; + + const errorMsg = error instanceof Error ? error.message : ""; + if (errorMsg.includes("No such container")) { + return res.status(404).json({ + success: false, + error: "Container not found", + code: "CONTAINER_NOT_FOUND", + }); + } + + dockerLogger.error("Failed to get container stats", error, { + operation: "get_stats", + sessionId, + containerId, + userId, + }); + + res.status(500).json({ + success: false, + error: errorMsg || "Failed to get container stats", + }); + } + }, +); + +// Start server +const PORT = 30007; + +app.listen(PORT, async () => { + try { + await authManager.initialize(); + dockerLogger.info(`Docker backend server started on port ${PORT}`); + } catch (err) { + dockerLogger.error("Failed to initialize Docker backend", err, { + operation: "startup", + }); + } +}); + +// Graceful shutdown +process.on("SIGINT", () => { + dockerLogger.info("Shutting down Docker backend"); + Object.keys(sshSessions).forEach((sessionId) => { + cleanupSession(sessionId); + }); + process.exit(0); +}); + +process.on("SIGTERM", () => { + dockerLogger.info("Shutting down Docker backend"); + Object.keys(sshSessions).forEach((sessionId) => { + cleanupSession(sessionId); + }); + process.exit(0); +}); diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index 78c181e7..9b863824 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -316,7 +316,6 @@ wss.on("connection", async (ws: WebSocket, req) => { let sshConn: Client | null = null; let sshStream: ClientChannel | null = null; - let pingInterval: NodeJS.Timeout | null = null; let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null; let totpPromptSent = false; let isKeyboardInteractive = false; @@ -802,8 +801,6 @@ wss.on("connection", async (ws: WebSocket, req) => { ); }); - setupPingInterval(); - if (initialPath && initialPath.trim() !== "") { const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`; stream.write(cdCommand); @@ -1279,11 +1276,6 @@ wss.on("connection", async (ws: WebSocket, req) => { clearTimeout(timeoutId); } - if (pingInterval) { - clearInterval(pingInterval); - pingInterval = null; - } - if (sshStream) { try { sshStream.end(); @@ -1320,24 +1312,12 @@ wss.on("connection", async (ws: WebSocket, req) => { }, 100); } - function setupPingInterval() { - pingInterval = setInterval(() => { - if (sshConn && sshStream) { - try { - sshStream.write("\x00"); - } catch (e: unknown) { - sshLogger.error( - "SSH keepalive failed: " + - (e instanceof Error ? e.message : "Unknown error"), - ); - cleanupSSH(); - } - } else if (!sshConn || !sshStream) { - if (pingInterval) { - clearInterval(pingInterval); - pingInterval = null; - } - } - }, 30000); - } + // Note: PTY-level keepalive (writing \x00 to the stream) was removed. + // It was causing ^@ characters to appear in terminals with echoctl enabled. + // SSH-level keepalive is configured via connectConfig (keepaliveInterval, + // keepaliveCountMax, tcpKeepAlive), which handles connection health monitoring + // without producing visible output on the terminal. + // + // See: https://github.com/Termix-SSH/Support/issues/232 + // See: https://github.com/Termix-SSH/Support/issues/309 }); diff --git a/src/backend/starter.ts b/src/backend/starter.ts index b74c9b11..10bb8802 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -102,6 +102,8 @@ import { systemLogger, versionLogger } from "./utils/logger.js"; await import("./ssh/tunnel.js"); await import("./ssh/file-manager.js"); await import("./ssh/server-stats.js"); + await import("./ssh/docker.js"); + await import("./ssh/docker-console.js"); await import("./dashboard.js"); process.on("SIGINT", () => { diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index 41f44982..cb5ff611 100644 --- a/src/backend/utils/logger.ts +++ b/src/backend/utils/logger.ts @@ -36,7 +36,7 @@ const SENSITIVE_FIELDS = [ const TRUNCATE_FIELDS = ["data", "content", "body", "response", "request"]; -class Logger { +export class Logger { private serviceName: string; private serviceIcon: string; private serviceColor: string; diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..5284517e --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,155 @@ +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +}; diff --git a/src/types/index.ts b/src/types/index.ts index 3f5844b5..1f022e21 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -40,6 +40,7 @@ export interface SSHHost { enableTerminal: boolean; enableTunnel: boolean; enableFileManager: boolean; + enableDocker: boolean; defaultPath: string; tunnelConnections: TunnelConnection[]; jumpHosts?: JumpHost[]; @@ -82,6 +83,7 @@ export interface SSHHostData { enableTerminal?: boolean; enableTunnel?: boolean; enableFileManager?: boolean; + enableDocker?: boolean; defaultPath?: string; forceKeyboardInteractive?: boolean; tunnelConnections?: TunnelConnection[]; @@ -344,13 +346,14 @@ export interface TerminalConfig { export interface TabContextTab { id: number; type: - | "home" - | "terminal" - | "ssh_manager" - | "server" - | "admin" - | "file_manager" - | "user_profile"; + | "home" + | "terminal" + | "ssh_manager" + | "server" + | "admin" + | "file_manager" + | "user_profile" + | "docker"; title: string; hostConfig?: SSHHost; terminalRef?: any; @@ -676,3 +679,55 @@ export interface RestoreRequestBody { backupPath: string; targetPath?: string; } + +// ============================================================================ +// DOCKER TYPES +// ============================================================================ + +export interface DockerContainer { + id: string; + name: string; + image: string; + status: string; + state: + | "created" + | "running" + | "paused" + | "restarting" + | "removing" + | "exited" + | "dead"; + ports: string; + created: string; + command?: string; + labels?: Record; + networks?: string[]; + mounts?: string[]; +} + +export interface DockerStats { + cpu: string; + memoryUsed: string; + memoryLimit: string; + memoryPercent: string; + netInput: string; + netOutput: string; + blockRead: string; + blockWrite: string; + pids?: string; +} + +export interface DockerLogOptions { + tail?: number; + timestamps?: boolean; + since?: string; + until?: string; + follow?: boolean; +} + +export interface DockerValidation { + available: boolean; + version?: string; + error?: string; + code?: string; +} diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index fb015997..99cfa6d9 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -155,7 +155,9 @@ function AppContent() { const showTerminalView = currentTabData?.type === "terminal" || currentTabData?.type === "server" || - currentTabData?.type === "file_manager"; + currentTabData?.type === "file_manager" || + currentTabData?.type === "tunnel" || + currentTabData?.type === "docker"; const showHome = currentTabData?.type === "home"; const showSshManager = currentTabData?.type === "ssh_manager"; const showAdmin = currentTabData?.type === "admin"; diff --git a/src/ui/desktop/apps/docker/DockerManager.tsx b/src/ui/desktop/apps/docker/DockerManager.tsx new file mode 100644 index 00000000..fbb56266 --- /dev/null +++ b/src/ui/desktop/apps/docker/DockerManager.tsx @@ -0,0 +1,390 @@ +import React from "react"; +import { useSidebar } from "@/components/ui/sidebar.tsx"; +import { Separator } from "@/components/ui/separator.tsx"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs.tsx"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import type { + SSHHost, + DockerContainer, + DockerValidation, +} from "@/types/index.js"; +import { + connectDockerSession, + disconnectDockerSession, + listDockerContainers, + validateDockerAvailability, + keepaliveDockerSession, +} from "@/ui/main-axios.ts"; +import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; +import { AlertCircle } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert.tsx"; +import { ContainerList } from "./components/ContainerList.tsx"; +import { LogViewer } from "./components/LogViewer.tsx"; +import { ContainerStats } from "./components/ContainerStats.tsx"; +import { ConsoleTerminal } from "./components/ConsoleTerminal.tsx"; +import { ContainerDetail } from "./components/ContainerDetail.tsx"; + +interface DockerManagerProps { + hostConfig?: SSHHost; + title?: string; + isVisible?: boolean; + isTopbarOpen?: boolean; + embedded?: boolean; +} + +export function DockerManager({ + hostConfig, + title, + isVisible = true, + isTopbarOpen = true, + embedded = false, +}: DockerManagerProps): React.ReactElement { + const { t } = useTranslation(); + const { state: sidebarState } = useSidebar(); + const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); + const [sessionId, setSessionId] = React.useState(null); + const [containers, setContainers] = React.useState([]); + const [selectedContainer, setSelectedContainer] = React.useState< + string | null + >(null); + const [isConnecting, setIsConnecting] = React.useState(false); + const [activeTab, setActiveTab] = React.useState("containers"); + const [dockerValidation, setDockerValidation] = + React.useState(null); + const [isValidating, setIsValidating] = React.useState(false); + const [viewMode, setViewMode] = React.useState<"list" | "detail">("list"); + + React.useEffect(() => { + if (hostConfig?.id !== currentHostConfig?.id) { + setCurrentHostConfig(hostConfig); + setContainers([]); + setSelectedContainer(null); + setSessionId(null); + setDockerValidation(null); + setViewMode("list"); + } + }, [hostConfig?.id]); + + React.useEffect(() => { + const fetchLatestHostConfig = async () => { + if (hostConfig?.id) { + try { + const { getSSHHosts } = await import("@/ui/main-axios.ts"); + const hosts = await getSSHHosts(); + const updatedHost = hosts.find((h) => h.id === hostConfig.id); + if (updatedHost) { + setCurrentHostConfig(updatedHost); + } + } catch { + // Silently handle error + } + } + }; + + fetchLatestHostConfig(); + + const handleHostsChanged = async () => { + if (hostConfig?.id) { + try { + const { getSSHHosts } = await import("@/ui/main-axios.ts"); + const hosts = await getSSHHosts(); + const updatedHost = hosts.find((h) => h.id === hostConfig.id); + if (updatedHost) { + setCurrentHostConfig(updatedHost); + } + } catch { + // Silently handle error + } + } + }; + + window.addEventListener("ssh-hosts:changed", handleHostsChanged); + return () => + window.removeEventListener("ssh-hosts:changed", handleHostsChanged); + }, [hostConfig?.id]); + + // SSH session lifecycle + React.useEffect(() => { + const initSession = async () => { + if (!currentHostConfig?.id || !currentHostConfig.enableDocker) { + return; + } + + setIsConnecting(true); + const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + + try { + await connectDockerSession(sid, currentHostConfig.id); + setSessionId(sid); + + // Validate Docker availability + setIsValidating(true); + const validation = await validateDockerAvailability(sid); + setDockerValidation(validation); + setIsValidating(false); + + if (!validation.available) { + toast.error( + validation.error || "Docker is not available on this host", + ); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to connect to host", + ); + setIsConnecting(false); + setIsValidating(false); + } finally { + setIsConnecting(false); + } + }; + + initSession(); + + return () => { + if (sessionId) { + disconnectDockerSession(sessionId).catch(() => { + // Silently handle disconnect errors + }); + } + }; + }, [currentHostConfig?.id, currentHostConfig?.enableDocker]); + + // Keepalive interval + React.useEffect(() => { + if (!sessionId || !isVisible) return; + + const keepalive = setInterval( + () => { + keepaliveDockerSession(sessionId).catch(() => { + // Silently handle keepalive errors + }); + }, + 10 * 60 * 1000, + ); // Every 10 minutes + + return () => clearInterval(keepalive); + }, [sessionId, isVisible]); + + // Refresh containers function + const refreshContainers = React.useCallback(async () => { + if (!sessionId) return; + try { + const data = await listDockerContainers(sessionId, true); + setContainers(data); + } catch (error) { + // Silently handle polling errors + } + }, [sessionId]); + + // Poll containers + React.useEffect(() => { + if (!sessionId || !isVisible || !dockerValidation?.available) return; + + let cancelled = false; + + const pollContainers = async () => { + try { + const data = await listDockerContainers(sessionId, true); + if (!cancelled) { + setContainers(data); + } + } catch (error) { + // Silently handle polling errors + } + }; + + pollContainers(); // Initial fetch + const interval = setInterval(pollContainers, 5000); // Poll every 5 seconds + + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [sessionId, isVisible, dockerValidation?.available]); + + const handleBack = React.useCallback(() => { + setViewMode("list"); + setSelectedContainer(null); + }, []); + + const topMarginPx = isTopbarOpen ? 74 : 16; + const leftMarginPx = sidebarState === "collapsed" ? 16 : 8; + const bottomMarginPx = 8; + + const wrapperStyle: React.CSSProperties = embedded + ? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" } + : { + opacity: isVisible ? 1 : 0, + marginLeft: leftMarginPx, + marginRight: 17, + marginTop: topMarginPx, + marginBottom: bottomMarginPx, + height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, + }; + + const containerClass = embedded + ? "h-full w-full text-white overflow-hidden bg-transparent" + : "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden"; + + // Check if Docker is enabled + if (!currentHostConfig?.enableDocker) { + return ( +
+
+
+
+
+

+ {currentHostConfig?.folder} / {title} +

+
+
+
+ + +
+ + + + Docker is not enabled for this host. Enable it in Host Settings + to use Docker features. + + +
+
+
+ ); + } + + // Loading state + if (isConnecting || isValidating) { + return ( +
+
+
+
+
+

+ {currentHostConfig?.folder} / {title} +

+
+
+
+ + +
+
+ +

+ {isValidating + ? "Validating Docker..." + : "Connecting to host..."} +

+
+
+
+
+ ); + } + + // Docker not available + if (dockerValidation && !dockerValidation.available) { + return ( +
+
+
+
+
+

+ {currentHostConfig?.folder} / {title} +

+
+
+
+ + +
+ + + +
Docker Error
+
{dockerValidation.error}
+ {dockerValidation.code && ( +
+ Error code: {dockerValidation.code} +
+ )} +
+
+
+
+
+ ); + } + + return ( +
+
+
+
+
+

+ {currentHostConfig?.folder} / {title} +

+ {dockerValidation?.version && ( +

+ Docker v{dockerValidation.version} +

+ )} +
+
+
+ + +
+ {viewMode === "list" ? ( +
+ {sessionId ? ( + { + setSelectedContainer(id); + setViewMode("detail"); + }} + selectedContainerId={selectedContainer} + onRefresh={refreshContainers} + /> + ) : ( +
+

No session available

+
+ )} +
+ ) : sessionId && selectedContainer && currentHostConfig ? ( + + ) : ( +
+

+ Select a container to view details +

+
+ )} +
+
+
+ ); +} diff --git a/src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx b/src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx new file mode 100644 index 00000000..1f9ca3c0 --- /dev/null +++ b/src/ui/desktop/apps/docker/components/ConsoleTerminal.tsx @@ -0,0 +1,448 @@ +import React from "react"; +import { useXTerm } from "react-xtermjs"; +import { FitAddon } from "@xterm/addon-fit"; +import { ClipboardAddon } from "@xterm/addon-clipboard"; +import { WebLinksAddon } from "@xterm/addon-web-links"; +import { Button } from "@/components/ui/button.tsx"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select.tsx"; +import { Card, CardContent } from "@/components/ui/card.tsx"; +import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react"; +import { toast } from "sonner"; +import type { SSHHost } from "@/types/index.js"; +import { getCookie, isElectron } from "@/ui/main-axios.ts"; +import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; + +interface ConsoleTerminalProps { + sessionId: string; + containerId: string; + containerName: string; + containerState: string; + hostConfig: SSHHost; +} + +export function ConsoleTerminal({ + sessionId, + containerId, + containerName, + containerState, + hostConfig, +}: ConsoleTerminalProps): React.ReactElement { + const { instance: terminal, ref: xtermRef } = useXTerm(); + const [isConnected, setIsConnected] = React.useState(false); + const [isConnecting, setIsConnecting] = React.useState(false); + const [selectedShell, setSelectedShell] = React.useState("bash"); + const wsRef = React.useRef(null); + const fitAddonRef = React.useRef(null); + const pingIntervalRef = React.useRef(null); + + const getWebSocketBaseUrl = React.useCallback(() => { + const isElectronApp = isElectron(); + + // Development mode check (similar to Terminal.tsx) + const isDev = + !isElectronApp && + process.env.NODE_ENV === "development" && + (window.location.port === "3000" || + window.location.port === "5173" || + window.location.port === ""); + + if (isDev) { + // Development: connect directly to port 30008 + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//localhost:30008`; + } + + if (isElectronApp) { + // Electron: construct URL from configured server + 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?:\/\//, ""); + // Use nginx path routing, not direct port + return `${wsProtocol}${wsHost}/docker/console/`; + } + + // Production web: use nginx proxy path (same as Terminal uses /ssh/websocket/) + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}/docker/console/`; + }, []); + + // Initialize terminal + React.useEffect(() => { + if (!terminal) return; + + const fitAddon = new FitAddon(); + const clipboardAddon = new ClipboardAddon(); + const webLinksAddon = new WebLinksAddon(); + + fitAddonRef.current = fitAddon; + + terminal.loadAddon(fitAddon); + terminal.loadAddon(clipboardAddon); + terminal.loadAddon(webLinksAddon); + + terminal.options.cursorBlink = true; + terminal.options.fontSize = 14; + terminal.options.fontFamily = "monospace"; + terminal.options.theme = { + background: "#18181b", + foreground: "#c9d1d9", + }; + + setTimeout(() => { + fitAddon.fit(); + }, 100); + + const resizeHandler = () => { + if (fitAddonRef.current) { + fitAddonRef.current.fit(); + + if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { + const { rows, cols } = terminal; + wsRef.current.send( + JSON.stringify({ + type: "resize", + data: { rows, cols }, + }), + ); + } + } + }; + + window.addEventListener("resize", resizeHandler); + + return () => { + window.removeEventListener("resize", resizeHandler); + + // Clean up WebSocket before disposing terminal + if (wsRef.current) { + try { + wsRef.current.send(JSON.stringify({ type: "disconnect" })); + } catch (error) { + // Ignore errors during cleanup + } + wsRef.current.close(); + wsRef.current = null; + } + + terminal.dispose(); + }; + }, [terminal]); + + const disconnect = React.useCallback(() => { + if (wsRef.current) { + try { + wsRef.current.send(JSON.stringify({ type: "disconnect" })); + } catch (error) { + // WebSocket might already be closed + } + wsRef.current.close(); + wsRef.current = null; + } + setIsConnected(false); + if (terminal) { + try { + terminal.clear(); + terminal.write("Disconnected from container console.\r\n"); + } catch (error) { + // Terminal might be disposed + } + } + }, [terminal]); + + const connect = React.useCallback(() => { + if (!terminal || containerState !== "running") { + toast.error("Container must be running to connect to console"); + return; + } + + setIsConnecting(true); + + try { + const token = isElectron() + ? localStorage.getItem("jwt") + : getCookie("jwt"); + if (!token) { + toast.error("Authentication required"); + setIsConnecting(false); + return; + } + + // Ensure terminal is fitted before connecting + if (fitAddonRef.current) { + fitAddonRef.current.fit(); + } + + const wsUrl = `${getWebSocketBaseUrl()}?token=${encodeURIComponent(token)}`; + const ws = new WebSocket(wsUrl); + + ws.onopen = () => { + // Double-check terminal dimensions + const cols = terminal.cols || 80; + const rows = terminal.rows || 24; + + ws.send( + JSON.stringify({ + type: "connect", + data: { + hostConfig, + containerId, + shell: selectedShell, + cols, + rows, + }, + }), + ); + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + + switch (msg.type) { + case "output": + terminal.write(msg.data); + break; + + case "connected": + setIsConnected(true); + setIsConnecting(false); + toast.success(`Connected to ${containerName}`); + + // Fit terminal and send resize to ensure correct dimensions + setTimeout(() => { + if (fitAddonRef.current) { + fitAddonRef.current.fit(); + } + + // Send resize message with correct dimensions + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "resize", + data: { rows: terminal.rows, cols: terminal.cols }, + }), + ); + } + }, 100); + break; + + case "disconnected": + setIsConnected(false); + setIsConnecting(false); + terminal.write( + `\r\n\x1b[1;33m${msg.message || "Disconnected"}\x1b[0m\r\n`, + ); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + break; + + case "error": + setIsConnecting(false); + toast.error(msg.message || "Console error"); + terminal.write(`\r\n\x1b[1;31mError: ${msg.message}\x1b[0m\r\n`); + break; + } + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }; + + ws.onerror = (error) => { + console.error("WebSocket error:", error); + setIsConnecting(false); + setIsConnected(false); + toast.error("Failed to connect to console"); + }; + + // Set up periodic ping to keep connection alive + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + } + pingIntervalRef.current = setInterval(() => { + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "ping" })); + } + }, 30000); // Ping every 30 seconds + + ws.onclose = () => { + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + setIsConnected(false); + setIsConnecting(false); + if (wsRef.current === ws) { + wsRef.current = null; + } + }; + + wsRef.current = ws; + + // Handle terminal input + terminal.onData((data) => { + if (ws.readyState === WebSocket.OPEN) { + ws.send( + JSON.stringify({ + type: "input", + data, + }), + ); + } + }); + } catch (error) { + setIsConnecting(false); + toast.error( + `Failed to connect: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } + }, [ + terminal, + containerState, + getWebSocketBaseUrl, + hostConfig, + containerId, + selectedShell, + containerName, + ]); + + // Cleanup WebSocket on unmount (terminal cleanup is handled in the terminal effect) + React.useEffect(() => { + return () => { + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + if (wsRef.current) { + try { + wsRef.current.send(JSON.stringify({ type: "disconnect" })); + } catch (error) { + // Ignore errors during cleanup + } + wsRef.current.close(); + wsRef.current = null; + } + setIsConnected(false); + }; + }, []); + + if (containerState !== "running") { + return ( +
+
+ +

Container is not running

+

+ Start the container to access the console +

+
+
+ ); + } + + return ( +
+ {/* Controls */} + + +
+
+ + Console +
+ +
+ {!isConnected ? ( + + ) : ( + + )} +
+
+
+
+ + {/* Terminal */} + + + {/* Terminal container - always rendered */} +
+ + {/* Not connected message */} + {!isConnected && !isConnecting && ( +
+
+ +

Not connected

+

+ Click Connect to start an interactive shell +

+
+
+ )} + + {/* Connecting message */} + {isConnecting && ( +
+
+ +

+ Connecting to {containerName}... +

+
+
+ )} + + +
+ ); +} diff --git a/src/ui/desktop/apps/docker/components/ContainerCard.tsx b/src/ui/desktop/apps/docker/components/ContainerCard.tsx new file mode 100644 index 00000000..48a6a8c3 --- /dev/null +++ b/src/ui/desktop/apps/docker/components/ContainerCard.tsx @@ -0,0 +1,446 @@ +import React from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { Button } from "@/components/ui/button.tsx"; +import { Badge } from "@/components/ui/badge.tsx"; +import { + Play, + Square, + RotateCw, + Pause, + Trash2, + PlayCircle, +} from "lucide-react"; +import { toast } from "sonner"; +import type { DockerContainer } from "@/types/index.js"; +import { + startDockerContainer, + stopDockerContainer, + restartDockerContainer, + pauseDockerContainer, + unpauseDockerContainer, + removeDockerContainer, +} from "@/ui/main-axios.ts"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip.tsx"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog.tsx"; + +interface ContainerCardProps { + container: DockerContainer; + sessionId: string; + onSelect?: () => void; + isSelected?: boolean; + onRefresh?: () => void; +} + +export function ContainerCard({ + container, + sessionId, + onSelect, + isSelected = false, + onRefresh, +}: ContainerCardProps): React.ReactElement { + const [isStarting, setIsStarting] = React.useState(false); + const [isStopping, setIsStopping] = React.useState(false); + const [isRestarting, setIsRestarting] = React.useState(false); + const [isPausing, setIsPausing] = React.useState(false); + const [isRemoving, setIsRemoving] = React.useState(false); + const [showRemoveDialog, setShowRemoveDialog] = React.useState(false); + + const statusColors = { + running: { + bg: "bg-green-500/10", + border: "border-green-500/20", + text: "text-green-400", + badge: "bg-green-500/20 text-green-300 border-green-500/30", + }, + exited: { + bg: "bg-red-500/10", + border: "border-red-500/20", + text: "text-red-400", + badge: "bg-red-500/20 text-red-300 border-red-500/30", + }, + paused: { + bg: "bg-yellow-500/10", + border: "border-yellow-500/20", + text: "text-yellow-400", + badge: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30", + }, + created: { + bg: "bg-blue-500/10", + border: "border-blue-500/20", + text: "text-blue-400", + badge: "bg-blue-500/20 text-blue-300 border-blue-500/30", + }, + restarting: { + bg: "bg-orange-500/10", + border: "border-orange-500/20", + text: "text-orange-400", + badge: "bg-orange-500/20 text-orange-300 border-orange-500/30", + }, + removing: { + bg: "bg-purple-500/10", + border: "border-purple-500/20", + text: "text-purple-400", + badge: "bg-purple-500/20 text-purple-300 border-purple-500/30", + }, + dead: { + bg: "bg-gray-500/10", + border: "border-gray-500/20", + text: "text-gray-400", + badge: "bg-gray-500/20 text-gray-300 border-gray-500/30", + }, + }; + + const colors = statusColors[container.state] || statusColors.created; + + const handleStart = async (e: React.MouseEvent) => { + e.stopPropagation(); + setIsStarting(true); + try { + await startDockerContainer(sessionId, container.id); + toast.success(`Container ${container.name} started`); + onRefresh?.(); + } catch (error) { + toast.error( + `Failed to start container: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setIsStarting(false); + } + }; + + const handleStop = async (e: React.MouseEvent) => { + e.stopPropagation(); + setIsStopping(true); + try { + await stopDockerContainer(sessionId, container.id); + toast.success(`Container ${container.name} stopped`); + onRefresh?.(); + } catch (error) { + toast.error( + `Failed to stop container: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setIsStopping(false); + } + }; + + const handleRestart = async (e: React.MouseEvent) => { + e.stopPropagation(); + setIsRestarting(true); + try { + await restartDockerContainer(sessionId, container.id); + toast.success(`Container ${container.name} restarted`); + onRefresh?.(); + } catch (error) { + toast.error( + `Failed to restart container: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setIsRestarting(false); + } + }; + + const handlePause = async (e: React.MouseEvent) => { + e.stopPropagation(); + setIsPausing(true); + try { + if (container.state === "paused") { + await unpauseDockerContainer(sessionId, container.id); + toast.success(`Container ${container.name} unpaused`); + } else { + await pauseDockerContainer(sessionId, container.id); + toast.success(`Container ${container.name} paused`); + } + onRefresh?.(); + } catch (error) { + toast.error( + `Failed to ${container.state === "paused" ? "unpause" : "pause"} container: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setIsPausing(false); + } + }; + + const handleRemove = async () => { + setIsRemoving(true); + try { + const force = container.state === "running"; + await removeDockerContainer(sessionId, container.id, force); + toast.success(`Container ${container.name} removed`); + setShowRemoveDialog(false); + onRefresh?.(); + } catch (error) { + toast.error( + `Failed to remove container: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setIsRemoving(false); + } + }; + + const isLoading = + isStarting || isStopping || isRestarting || isPausing || isRemoving; + + // Format the created date to be more readable + const formatCreatedDate = (dateStr: string): string => { + try { + // Remove the timezone suffix like "+0000 UTC" + const cleanDate = dateStr.replace(/\s*\+\d{4}\s*UTC\s*$/, "").trim(); + return cleanDate; + } catch { + return dateStr; + } + }; + + // Parse ports into array of port mappings + const parsePorts = (portsStr: string | undefined): string[] => { + if (!portsStr || portsStr.trim() === "") return []; + + // Split by comma and clean up + return portsStr + .split(",") + .map((p) => p.trim()) + .filter((p) => p.length > 0); + }; + + const portsList = parsePorts(container.ports); + + return ( + <> + + +
+ + {container.name.startsWith("/") + ? container.name.slice(1) + : container.name} + + + {container.state} + +
+
+ +
+
+ Image: + + {container.image} + +
+
+ ID: + + {container.id.substring(0, 12)} + +
+
+ + Ports: + +
+ {portsList.length > 0 ? ( + portsList.map((port, idx) => ( + + {port} + + )) + ) : ( + + None + + )} +
+
+
+ + Created: + + + {formatCreatedDate(container.created)} + +
+
+ +
+ + {container.state !== "running" && ( + + + + + Start + + )} + + {container.state === "running" && ( + + + + + Stop + + )} + + {(container.state === "running" || + container.state === "paused") && ( + + + + + + {container.state === "paused" ? "Unpause" : "Pause"} + + + )} + + + + + + Restart + + + + + + + Remove + + +
+
+
+ + + + + Remove Container + + Are you sure you want to remove container{" "} + + {container.name.startsWith("/") + ? container.name.slice(1) + : container.name} + + ? + {container.state === "running" && ( +
+ Warning: This container is currently running and will be + force-removed. +
+ )} +
+
+ + Cancel + { + e.preventDefault(); + handleRemove(); + }} + disabled={isRemoving} + className="bg-red-600 hover:bg-red-700" + > + {isRemoving ? "Removing..." : "Remove"} + + +
+
+ + ); +} diff --git a/src/ui/desktop/apps/docker/components/ContainerDetail.tsx b/src/ui/desktop/apps/docker/components/ContainerDetail.tsx new file mode 100644 index 00000000..ded1c3d8 --- /dev/null +++ b/src/ui/desktop/apps/docker/components/ContainerDetail.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { Button } from "@/components/ui/button.tsx"; +import { Separator } from "@/components/ui/separator.tsx"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs.tsx"; +import { ArrowLeft } from "lucide-react"; +import type { DockerContainer, SSHHost } from "@/types/index.js"; +import { LogViewer } from "./LogViewer.tsx"; +import { ContainerStats } from "./ContainerStats.tsx"; +import { ConsoleTerminal } from "./ConsoleTerminal.tsx"; + +interface ContainerDetailProps { + sessionId: string; + containerId: string; + containers: DockerContainer[]; + hostConfig: SSHHost; + onBack: () => void; +} + +export function ContainerDetail({ + sessionId, + containerId, + containers, + hostConfig, + onBack, +}: ContainerDetailProps): React.ReactElement { + const [activeTab, setActiveTab] = React.useState("logs"); + + const container = containers.find((c) => c.id === containerId); + + if (!container) { + return ( +
+
+

Container not found

+ +
+
+ ); + } + + return ( +
+ {/* Header with back button */} +
+ +
+

{container.name}

+

{container.image}

+
+
+ + + {/* Tabs for Logs, Stats, Console */} +
+ +
+ + Logs + Stats + Console + +
+ + + + + + + + + + + + +
+
+
+ ); +} diff --git a/src/ui/desktop/apps/docker/components/ContainerList.tsx b/src/ui/desktop/apps/docker/components/ContainerList.tsx new file mode 100644 index 00000000..8c383383 --- /dev/null +++ b/src/ui/desktop/apps/docker/components/ContainerList.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { Input } from "@/components/ui/input.tsx"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select.tsx"; +import { Search, Filter } from "lucide-react"; +import type { DockerContainer } from "@/types/index.js"; +import { ContainerCard } from "./ContainerCard.tsx"; + +interface ContainerListProps { + containers: DockerContainer[]; + sessionId: string; + onSelectContainer: (containerId: string) => void; + selectedContainerId?: string | null; + onRefresh?: () => void; +} + +export function ContainerList({ + containers, + sessionId, + onSelectContainer, + selectedContainerId = null, + onRefresh, +}: ContainerListProps): React.ReactElement { + const [searchQuery, setSearchQuery] = React.useState(""); + const [statusFilter, setStatusFilter] = React.useState("all"); + + const filteredContainers = React.useMemo(() => { + return containers.filter((container) => { + const matchesSearch = + container.name.toLowerCase().includes(searchQuery.toLowerCase()) || + container.image.toLowerCase().includes(searchQuery.toLowerCase()) || + container.id.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesStatus = + statusFilter === "all" || container.state === statusFilter; + + return matchesSearch && matchesStatus; + }); + }, [containers, searchQuery, statusFilter]); + + const statusCounts = React.useMemo(() => { + const counts: Record = {}; + containers.forEach((c) => { + counts[c.state] = (counts[c.state] || 0) + 1; + }); + return counts; + }, [containers]); + + if (containers.length === 0) { + return ( +
+
+

No containers found

+

+ Start by creating containers on your server +

+
+
+ ); + } + + return ( +
+ {/* Search and Filter Bar */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-10" + /> +
+
+ + +
+
+ + {/* Container Grid */} + {filteredContainers.length === 0 ? ( +
+
+

No containers match your filters

+

+ Try adjusting your search or filter +

+
+
+ ) : ( +
+ {filteredContainers.map((container) => ( + onSelectContainer(container.id)} + isSelected={selectedContainerId === container.id} + onRefresh={onRefresh} + /> + ))} +
+ )} +
+ ); +} diff --git a/src/ui/desktop/apps/docker/components/ContainerStats.tsx b/src/ui/desktop/apps/docker/components/ContainerStats.tsx new file mode 100644 index 00000000..a78a83c1 --- /dev/null +++ b/src/ui/desktop/apps/docker/components/ContainerStats.tsx @@ -0,0 +1,242 @@ +import React from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card.tsx"; +import { Progress } from "@/components/ui/progress.tsx"; +import { Cpu, MemoryStick, Network, HardDrive, Activity } from "lucide-react"; +import type { DockerStats } from "@/types/index.js"; +import { getContainerStats } from "@/ui/main-axios.ts"; +import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; + +interface ContainerStatsProps { + sessionId: string; + containerId: string; + containerName: string; + containerState: string; +} + +export function ContainerStats({ + sessionId, + containerId, + containerName, + containerState, +}: ContainerStatsProps): React.ReactElement { + const [stats, setStats] = React.useState(null); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const fetchStats = React.useCallback(async () => { + if (containerState !== "running") { + setError("Container must be running to view stats"); + return; + } + + setIsLoading(true); + setError(null); + try { + const data = await getContainerStats(sessionId, containerId); + setStats(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch stats"); + } finally { + setIsLoading(false); + } + }, [sessionId, containerId, containerState]); + + React.useEffect(() => { + fetchStats(); + + // Poll stats every 2 seconds + const interval = setInterval(fetchStats, 2000); + + return () => clearInterval(interval); + }, [fetchStats]); + + if (containerState !== "running") { + return ( +
+
+ +

Container is not running

+

+ Start the container to view statistics +

+
+
+ ); + } + + if (isLoading && !stats) { + return ( +
+
+ +

Loading stats...

+
+
+ ); + } + + if (error) { + return ( +
+
+

Error loading stats

+

{error}

+
+
+ ); + } + + if (!stats) { + return ( +
+

No stats available

+
+ ); + } + + const cpuPercent = parseFloat(stats.cpu) || 0; + const memPercent = parseFloat(stats.memoryPercent) || 0; + + return ( +
+ {/* CPU Usage */} + + + + + CPU Usage + + + +
+
+ Current + + {stats.cpu} + +
+ +
+
+
+ + {/* Memory Usage */} + + + + + Memory Usage + + + +
+
+ Used / Limit + + {stats.memoryUsed} / {stats.memoryLimit} + +
+
+ Percentage + + {stats.memoryPercent} + +
+ +
+
+
+ + {/* Network I/O */} + + + + + Network I/O + + + +
+
+ Input + {stats.netInput} +
+
+ Output + + {stats.netOutput} + +
+
+
+
+ + {/* Block I/O */} + + + + + Block I/O + + + +
+
+ Read + + {stats.blockRead} + +
+
+ Write + + {stats.blockWrite} + +
+ {stats.pids && ( +
+ PIDs + {stats.pids} +
+ )} +
+
+
+ + {/* Container Info */} + + + + + Container Information + + + +
+
+ Name: + {containerName} +
+
+ ID: + + {containerId.substring(0, 12)} + +
+
+ State: + + {containerState} + +
+
+
+
+
+ ); +} diff --git a/src/ui/desktop/apps/docker/components/LogViewer.tsx b/src/ui/desktop/apps/docker/components/LogViewer.tsx new file mode 100644 index 00000000..d8ecc4bd --- /dev/null +++ b/src/ui/desktop/apps/docker/components/LogViewer.tsx @@ -0,0 +1,246 @@ +import React from "react"; +import { Button } from "@/components/ui/button.tsx"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select.tsx"; +import { Card, CardContent } from "@/components/ui/card.tsx"; +import { Switch } from "@/components/ui/switch.tsx"; +import { Label } from "@/components/ui/label.tsx"; +import { Download, RefreshCw, Filter } from "lucide-react"; +import { toast } from "sonner"; +import type { DockerLogOptions } from "@/types/index.js"; +import { getContainerLogs, downloadContainerLogs } from "@/ui/main-axios.ts"; +import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx"; + +interface LogViewerProps { + sessionId: string; + containerId: string; + containerName: string; +} + +export function LogViewer({ + sessionId, + containerId, + containerName, +}: LogViewerProps): React.ReactElement { + const [logs, setLogs] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + const [isDownloading, setIsDownloading] = React.useState(false); + const [tailLines, setTailLines] = React.useState("100"); + const [showTimestamps, setShowTimestamps] = React.useState(false); + const [autoRefresh, setAutoRefresh] = React.useState(false); + const [searchFilter, setSearchFilter] = React.useState(""); + const logsEndRef = React.useRef(null); + + const fetchLogs = React.useCallback(async () => { + setIsLoading(true); + try { + const options: DockerLogOptions = { + tail: tailLines === "all" ? undefined : parseInt(tailLines, 10), + timestamps: showTimestamps, + }; + + const data = await getContainerLogs(sessionId, containerId, options); + setLogs(data.logs); + } catch (error) { + toast.error( + `Failed to fetch logs: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setIsLoading(false); + } + }, [sessionId, containerId, tailLines, showTimestamps]); + + React.useEffect(() => { + fetchLogs(); + }, [fetchLogs]); + + // Auto-refresh + React.useEffect(() => { + if (!autoRefresh) return; + + const interval = setInterval(() => { + fetchLogs(); + }, 3000); // Refresh every 3 seconds + + return () => clearInterval(interval); + }, [autoRefresh, fetchLogs]); + + // Auto-scroll to bottom when new logs arrive + React.useEffect(() => { + if (autoRefresh && logsEndRef.current) { + logsEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [logs, autoRefresh]); + + const handleDownload = async () => { + setIsDownloading(true); + try { + const options: DockerLogOptions = { + timestamps: showTimestamps, + }; + + const blob = await downloadContainerLogs(sessionId, containerId, options); + const url = window.URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${containerName.replace(/[^a-z0-9]/gi, "_")}_logs.txt`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + + toast.success("Logs downloaded successfully"); + } catch (error) { + toast.error( + `Failed to download logs: ${error instanceof Error ? error.message : "Unknown error"}`, + ); + } finally { + setIsDownloading(false); + } + }; + + const filteredLogs = React.useMemo(() => { + if (!searchFilter.trim()) return logs; + + return logs + .split("\n") + .filter((line) => line.toLowerCase().includes(searchFilter.toLowerCase())) + .join("\n"); + }, [logs, searchFilter]); + + return ( +
+ {/* Controls */} + + +
+ {/* Tail Lines */} +
+ + +
+ + {/* Timestamps */} +
+ +
+ + + {showTimestamps ? "Enabled" : "Disabled"} + +
+
+ + {/* Auto Refresh */} +
+ +
+ + + {autoRefresh ? "On" : "Off"} + +
+
+ + {/* Actions */} +
+ +
+ + +
+
+
+ + {/* Search Filter */} +
+
+ + setSearchFilter(e.target.value)} + className="w-full pl-10 pr-4 py-2 bg-dark-bg border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary" + /> +
+
+
+
+ + {/* Logs Display */} + + + {isLoading && !logs ? ( +
+ +
+ ) : ( +
+
+                {filteredLogs || (
+                  No logs available
+                )}
+                
+
+
+ )} +
+
+
+ ); +} diff --git a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx index 68d3b209..4ad0e5f5 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerEditor.tsx @@ -567,6 +567,7 @@ export function HostManagerEditor({ }), ) .default([]), + enableDocker: z.boolean().default(false), }) .superRefine((data, ctx) => { if (data.authType === "none") { @@ -659,6 +660,7 @@ export function HostManagerEditor({ statsConfig: DEFAULT_STATS_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: false, + enableDocker: false, }, }); @@ -754,6 +756,7 @@ export function HostManagerEditor({ : [], }, forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive), + enableDocker: Boolean(cleanedHost.enableDocker), }; if (defaultAuthType === "password") { @@ -805,6 +808,7 @@ export function HostManagerEditor({ statsConfig: DEFAULT_STATS_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG, forceKeyboardInteractive: false, + enableDocker: false, }; form.reset(defaultFormData); @@ -862,6 +866,7 @@ export function HostManagerEditor({ authType: data.authType, overrideCredentialUsername: Boolean(data.overrideCredentialUsername), enableTerminal: Boolean(data.enableTerminal), + enableDocker: Boolean(data.enableDocker), enableTunnel: Boolean(data.enableTunnel), enableFileManager: Boolean(data.enableFileManager), defaultPath: data.defaultPath || "/", @@ -949,9 +954,8 @@ export function HostManagerEditor({ window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); if (savedHost?.id) { - const { notifyHostCreatedOrUpdated } = await import( - "@/ui/main-axios.ts" - ); + const { notifyHostCreatedOrUpdated } = + await import("@/ui/main-axios.ts"); notifyHostCreatedOrUpdated(savedHost.id); } } catch (error) { @@ -984,6 +988,8 @@ export function HostManagerEditor({ setActiveTab("general"); } else if (errors.enableTerminal || errors.terminalConfig) { setActiveTab("terminal"); + } else if (errors.enableDocker) { + setActiveTab("docker"); } else if (errors.enableTunnel || errors.tunnelConnections) { setActiveTab("tunnel"); } else if (errors.enableFileManager || errors.defaultPath) { @@ -1176,6 +1182,7 @@ export function HostManagerEditor({ {t("hosts.terminal")} + Docker {t("hosts.tunnel")} {t("hosts.fileManager")} @@ -2551,6 +2558,26 @@ export function HostManagerEditor({ + + ( + + Enable Docker + + + + + Enable Docker integration for this host + + + )} + /> + 0) ? ( -
+
{currentHostConfig?.quickActions && currentHostConfig.quickActions.length > 0 && (
@@ -600,20 +599,6 @@ export function Server({ )}
) : null} - - {currentHostConfig?.tunnelConnections && - currentHostConfig.tunnelConnections.length > 0 && ( -
- -
- )}
diff --git a/src/ui/desktop/apps/server/widgets/CpuWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/CpuWidget.tsx similarity index 94% rename from src/ui/desktop/apps/server/widgets/CpuWidget.tsx rename to src/ui/desktop/apps/server-stats/widgets/CpuWidget.tsx index e05470f2..d241926c 100644 --- a/src/ui/desktop/apps/server/widgets/CpuWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/CpuWidget.tsx @@ -30,7 +30,7 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) { }, [metricsHistory]); return ( -
+

diff --git a/src/ui/desktop/apps/server/widgets/DiskWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/DiskWidget.tsx similarity index 94% rename from src/ui/desktop/apps/server/widgets/DiskWidget.tsx rename to src/ui/desktop/apps/server-stats/widgets/DiskWidget.tsx index 083b391d..25b469d9 100644 --- a/src/ui/desktop/apps/server/widgets/DiskWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/DiskWidget.tsx @@ -27,7 +27,7 @@ export function DiskWidget({ metrics }: DiskWidgetProps) { }, [metrics]); return ( -
+

diff --git a/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/LoginStatsWidget.tsx similarity index 96% rename from src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx rename to src/ui/desktop/apps/server-stats/widgets/LoginStatsWidget.tsx index f70e8727..432e724f 100644 --- a/src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/LoginStatsWidget.tsx @@ -35,7 +35,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) { const uniqueIPs = loginStats?.uniqueIPs || 0; return ( -
+

diff --git a/src/ui/desktop/apps/server/widgets/MemoryWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/MemoryWidget.tsx similarity index 95% rename from src/ui/desktop/apps/server/widgets/MemoryWidget.tsx rename to src/ui/desktop/apps/server-stats/widgets/MemoryWidget.tsx index ba505a9d..3487fe88 100644 --- a/src/ui/desktop/apps/server/widgets/MemoryWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/MemoryWidget.tsx @@ -30,7 +30,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) { }, [metricsHistory]); return ( -
+

diff --git a/src/ui/desktop/apps/server/widgets/NetworkWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/NetworkWidget.tsx similarity index 93% rename from src/ui/desktop/apps/server/widgets/NetworkWidget.tsx rename to src/ui/desktop/apps/server-stats/widgets/NetworkWidget.tsx index 4a3e7379..597acd08 100644 --- a/src/ui/desktop/apps/server/widgets/NetworkWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/NetworkWidget.tsx @@ -24,7 +24,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) { const interfaces = network?.interfaces || []; return ( -
+

diff --git a/src/ui/desktop/apps/server/widgets/ProcessesWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/ProcessesWidget.tsx similarity index 94% rename from src/ui/desktop/apps/server/widgets/ProcessesWidget.tsx rename to src/ui/desktop/apps/server-stats/widgets/ProcessesWidget.tsx index 2e51cec3..28c34448 100644 --- a/src/ui/desktop/apps/server/widgets/ProcessesWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/ProcessesWidget.tsx @@ -28,7 +28,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) { const topProcesses = processes?.top || []; return ( -
+

diff --git a/src/ui/desktop/apps/server/widgets/SystemWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/SystemWidget.tsx similarity index 92% rename from src/ui/desktop/apps/server/widgets/SystemWidget.tsx rename to src/ui/desktop/apps/server-stats/widgets/SystemWidget.tsx index e9229d5b..2b38008d 100644 --- a/src/ui/desktop/apps/server/widgets/SystemWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/SystemWidget.tsx @@ -21,7 +21,7 @@ export function SystemWidget({ metrics }: SystemWidgetProps) { const system = metricsWithSystem?.system; return ( -
+

diff --git a/src/ui/desktop/apps/server/widgets/UptimeWidget.tsx b/src/ui/desktop/apps/server-stats/widgets/UptimeWidget.tsx similarity index 90% rename from src/ui/desktop/apps/server/widgets/UptimeWidget.tsx rename to src/ui/desktop/apps/server-stats/widgets/UptimeWidget.tsx index c8f02db7..04a72347 100644 --- a/src/ui/desktop/apps/server/widgets/UptimeWidget.tsx +++ b/src/ui/desktop/apps/server-stats/widgets/UptimeWidget.tsx @@ -20,7 +20,7 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) { const uptime = metricsWithUptime?.uptime; return ( -
+

diff --git a/src/ui/desktop/apps/server/widgets/index.ts b/src/ui/desktop/apps/server-stats/widgets/index.ts similarity index 100% rename from src/ui/desktop/apps/server/widgets/index.ts rename to src/ui/desktop/apps/server-stats/widgets/index.ts diff --git a/src/ui/desktop/apps/tunnel/TunnelManager.tsx b/src/ui/desktop/apps/tunnel/TunnelManager.tsx new file mode 100644 index 00000000..e280a594 --- /dev/null +++ b/src/ui/desktop/apps/tunnel/TunnelManager.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { useSidebar } from "@/components/ui/sidebar.tsx"; +import { Separator } from "@/components/ui/separator.tsx"; +import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx"; +import { useTranslation } from "react-i18next"; + +interface HostConfig { + id: number; + name: string; + ip: string; + username: string; + folder?: string; + enableFileManager?: boolean; + tunnelConnections?: unknown[]; + [key: string]: unknown; +} + +interface TunnelManagerProps { + hostConfig?: HostConfig; + title?: string; + isVisible?: boolean; + isTopbarOpen?: boolean; + embedded?: boolean; +} + +export function TunnelManager({ + hostConfig, + title, + isVisible = true, + isTopbarOpen = true, + embedded = false, +}: TunnelManagerProps): React.ReactElement { + const { t } = useTranslation(); + const { state: sidebarState } = useSidebar(); + const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); + + React.useEffect(() => { + if (hostConfig?.id !== currentHostConfig?.id) { + setCurrentHostConfig(hostConfig); + } + }, [hostConfig?.id]); + + React.useEffect(() => { + const fetchLatestHostConfig = async () => { + if (hostConfig?.id) { + try { + const { getSSHHosts } = await import("@/ui/main-axios.ts"); + const hosts = await getSSHHosts(); + const updatedHost = hosts.find((h) => h.id === hostConfig.id); + if (updatedHost) { + setCurrentHostConfig(updatedHost); + } + } catch { + // Silently handle error + } + } + }; + + fetchLatestHostConfig(); + + const handleHostsChanged = async () => { + if (hostConfig?.id) { + try { + const { getSSHHosts } = await import("@/ui/main-axios.ts"); + const hosts = await getSSHHosts(); + const updatedHost = hosts.find((h) => h.id === hostConfig.id); + if (updatedHost) { + setCurrentHostConfig(updatedHost); + } + } catch { + // Silently handle error + } + } + }; + + window.addEventListener("ssh-hosts:changed", handleHostsChanged); + return () => + window.removeEventListener("ssh-hosts:changed", handleHostsChanged); + }, [hostConfig?.id]); + + const topMarginPx = isTopbarOpen ? 74 : 16; + const leftMarginPx = sidebarState === "collapsed" ? 16 : 8; + const bottomMarginPx = 8; + + const wrapperStyle: React.CSSProperties = embedded + ? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" } + : { + opacity: isVisible ? 1 : 0, + marginLeft: leftMarginPx, + marginRight: 17, + marginTop: topMarginPx, + marginBottom: bottomMarginPx, + height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`, + }; + + const containerClass = embedded + ? "h-full w-full text-white overflow-hidden bg-transparent" + : "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden"; + + return ( +
+
+
+
+
+

+ {currentHostConfig?.folder} / {title} +

+
+
+
+ + +
+ {currentHostConfig?.tunnelConnections && + currentHostConfig.tunnelConnections.length > 0 ? ( +
+ +
+ ) : ( +
+
+

+ {t("tunnel.noTunnelsConfigured")} +

+

+ {t("tunnel.configureTunnelsInHostSettings")} +

+
+
+ )} +
+
+
+ ); +} diff --git a/src/ui/desktop/apps/tunnel/TunnelViewer.tsx b/src/ui/desktop/apps/tunnel/TunnelViewer.tsx index 10df33f7..28d22b73 100644 --- a/src/ui/desktop/apps/tunnel/TunnelViewer.tsx +++ b/src/ui/desktop/apps/tunnel/TunnelViewer.tsx @@ -43,11 +43,6 @@ export function TunnelViewer({ return (
-
-

- {t("tunnels.title")} -

-
{activeHost.tunnelConnections.map((t, idx) => ( diff --git a/src/ui/desktop/navigation/AppView.tsx b/src/ui/desktop/navigation/AppView.tsx index 51baf6e4..3d2f845c 100644 --- a/src/ui/desktop/navigation/AppView.tsx +++ b/src/ui/desktop/navigation/AppView.tsx @@ -1,7 +1,9 @@ import React, { useEffect, useRef, useState, useMemo } from "react"; import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx"; -import { Server as ServerView } from "@/ui/desktop/apps/server/Server.tsx"; +import { ServerStats as ServerView } from "@/ui/desktop/apps/server-stats/ServerStats.tsx"; import { FileManager } from "@/ui/desktop/apps/file-manager/FileManager.tsx"; +import { TunnelManager } from "@/ui/desktop/apps/tunnel/TunnelManager.tsx"; +import { DockerManager } from "@/ui/desktop/apps/docker/DockerManager.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { ResizablePanelGroup, @@ -58,7 +60,9 @@ export function AppView({ (tab: TabData) => tab.type === "terminal" || tab.type === "server" || - tab.type === "file_manager", + tab.type === "file_manager" || + tab.type === "tunnel" || + tab.type === "docker", ), [tabs], ); @@ -210,7 +214,10 @@ export function AppView({ const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab); if (allSplitScreenTab.length === 0 && mainTab) { - const isFileManagerTab = mainTab.type === "file_manager"; + const isFileManagerTab = + mainTab.type === "file_manager" || + mainTab.type === "tunnel" || + mainTab.type === "docker"; const newStyle = { position: "absolute" as const, top: isFileManagerTab ? 0 : 4, @@ -257,9 +264,14 @@ export function AppView({ const isVisible = hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab); + const effectiveVisible = isVisible; + const previousStyle = previousStylesRef.current[t.id]; - const isFileManagerTab = t.type === "file_manager"; + const isFileManagerTab = + t.type === "file_manager" || + t.type === "tunnel" || + t.type === "docker"; const standardStyle = { position: "absolute" as const, top: isFileManagerTab ? 0 : 4, @@ -270,16 +282,24 @@ export function AppView({ const finalStyle: React.CSSProperties = hasStyle ? { ...styles[t.id], overflow: "hidden" } - : ({ - ...(previousStyle || standardStyle), - opacity: 0, - pointerEvents: "none", - zIndex: 0, - transition: "opacity 150ms ease-in-out", - overflow: "hidden", - } as React.CSSProperties); - - const effectiveVisible = isVisible; + : effectiveVisible + ? { + ...(previousStyle || standardStyle), + opacity: 1, + pointerEvents: "auto", + zIndex: 20, + display: "block", + transition: "opacity 150ms ease-in-out", + overflow: "hidden", + } + : ({ + ...(previousStyle || standardStyle), + opacity: 0, + pointerEvents: "none", + zIndex: 0, + transition: "opacity 150ms ease-in-out", + overflow: "hidden", + } as React.CSSProperties); const isTerminal = t.type === "terminal"; const terminalConfig = { @@ -317,6 +337,22 @@ export function AppView({ isTopbarOpen={isTopbarOpen} embedded /> + ) : t.type === "tunnel" ? ( + + ) : t.type === "docker" ? ( + ) : ( tab.id === currentTab); const isFileManager = currentTabData?.type === "file_manager"; + const isTunnel = currentTabData?.type === "tunnel"; + const isDocker = currentTabData?.type === "docker"; const isTerminal = currentTabData?.type === "terminal"; const isSplitScreen = allSplitScreenTab.length > 0; @@ -653,7 +691,7 @@ export function AppView({ const bottomMarginPx = 8; let containerBackground = "var(--color-dark-bg)"; - if (isFileManager && !isSplitScreen) { + if ((isFileManager || isTunnel || isDocker) && !isSplitScreen) { containerBackground = "var(--color-dark-bg-darkest)"; } else if (isTerminal) { containerBackground = terminalBackgroundColor; diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx index 2cb11ef4..4838726c 100644 --- a/src/ui/desktop/navigation/TopNavbar.tsx +++ b/src/ui/desktop/navigation/TopNavbar.tsx @@ -369,10 +369,13 @@ export function TopNavbar({ const isTerminal = tab.type === "terminal"; const isServer = tab.type === "server"; const isFileManager = tab.type === "file_manager"; + const isTunnel = tab.type === "tunnel"; + const isDocker = tab.type === "docker"; const isSshManager = tab.type === "ssh_manager"; const isAdmin = tab.type === "admin"; const isUserProfile = tab.type === "user_profile"; - const isSplittable = isTerminal || isServer || isFileManager; + const isSplittable = + isTerminal || isServer || isFileManager || isTunnel || isDocker; const disableSplit = !isSplittable; const disableActivate = isSplit || @@ -484,6 +487,8 @@ export function TopNavbar({ isTerminal || isServer || isFileManager || + isTunnel || + isDocker || isSshManager || isAdmin || isUserProfile @@ -498,6 +503,8 @@ export function TopNavbar({ isTerminal || isServer || isFileManager || + isTunnel || + isDocker || isSshManager || isAdmin || isUserProfile diff --git a/src/ui/desktop/navigation/hosts/Host.tsx b/src/ui/desktop/navigation/hosts/Host.tsx index 18032159..f085f641 100644 --- a/src/ui/desktop/navigation/hosts/Host.tsx +++ b/src/ui/desktop/navigation/hosts/Host.tsx @@ -8,6 +8,8 @@ import { Server, FolderOpen, Pencil, + ArrowDownUp, + Container, } from "lucide-react"; import { DropdownMenu, @@ -63,6 +65,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement { }, [host.statsConfig]); const shouldShowStatus = statsConfig.statusCheckEnabled !== false; + const shouldShowMetrics = statsConfig.metricsEnabled !== false; useEffect(() => { if (!shouldShowStatus) { @@ -151,24 +154,50 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement { side="right" className="w-56 bg-dark-bg border-dark-border text-white" > - - addTab({ type: "server", title, hostConfig: host }) - } - className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" - > - - Open Server Details - - - addTab({ type: "file_manager", title, hostConfig: host }) - } - className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" - > - - Open File Manager - + {shouldShowMetrics && ( + + addTab({ type: "server", title, hostConfig: host }) + } + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + Open Server Stats + + )} + {host.enableFileManager && ( + + addTab({ type: "file_manager", title, hostConfig: host }) + } + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + Open File Manager + + )} + {host.enableTunnel && ( + + addTab({ type: "tunnel", title, hostConfig: host }) + } + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + Open Tunnels + + )} + {host.enableDocker && ( + + addTab({ type: "docker", title, hostConfig: host }) + } + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + Open Docker + + )} addTab({ diff --git a/src/ui/desktop/navigation/tabs/Tab.tsx b/src/ui/desktop/navigation/tabs/Tab.tsx index 18dc75e0..0d9f2c1e 100644 --- a/src/ui/desktop/navigation/tabs/Tab.tsx +++ b/src/ui/desktop/navigation/tabs/Tab.tsx @@ -10,6 +10,8 @@ import { Server as ServerIcon, Folder as FolderIcon, User as UserIcon, + ArrowDownUp as TunnelIcon, + Container as DockerIcon, } from "lucide-react"; interface TabProps { @@ -119,10 +121,14 @@ export function Tab({ tabType === "terminal" || tabType === "server" || tabType === "file_manager" || + tabType === "tunnel" || + tabType === "docker" || tabType === "user_profile" ) { const isServer = tabType === "server"; const isFileManager = tabType === "file_manager"; + const isTunnel = tabType === "tunnel"; + const isDocker = tabType === "docker"; const isUserProfile = tabType === "user_profile"; const displayTitle = @@ -131,9 +137,13 @@ export function Tab({ ? t("nav.serverStats") : isFileManager ? t("nav.fileManager") - : isUserProfile - ? t("nav.userProfile") - : t("nav.terminal")); + : isTunnel + ? t("nav.tunnels") + : isDocker + ? t("nav.docker") + : isUserProfile + ? t("nav.userProfile") + : t("nav.terminal")); const { base, suffix } = splitTitle(displayTitle); @@ -151,6 +161,10 @@ export function Tab({ ) : isFileManager ? ( + ) : isTunnel ? ( + + ) : isDocker ? ( + ) : isUserProfile ? ( ) : ( diff --git a/src/ui/desktop/navigation/tabs/TabContext.tsx b/src/ui/desktop/navigation/tabs/TabContext.tsx index 7bcd6fe9..61347d6b 100644 --- a/src/ui/desktop/navigation/tabs/TabContext.tsx +++ b/src/ui/desktop/navigation/tabs/TabContext.tsx @@ -76,7 +76,11 @@ export function TabProvider({ children }: TabProviderProps) { ? t("nav.serverStats") : tabType === "file_manager" ? t("nav.fileManager") - : t("nav.terminal"); + : tabType === "tunnel" + ? t("nav.tunnels") + : tabType === "docker" + ? t("nav.docker") + : t("nav.terminal"); const baseTitle = (desiredTitle || defaultTitle).trim(); const match = baseTitle.match(/^(.*) \((\d+)\)$/); const root = match ? match[1] : baseTitle; @@ -137,7 +141,9 @@ export function TabProvider({ children }: TabProviderProps) { const needsUniqueTitle = tabData.type === "terminal" || tabData.type === "server" || - tabData.type === "file_manager"; + tabData.type === "file_manager" || + tabData.type === "tunnel" || + tabData.type === "docker"; const effectiveTitle = needsUniqueTitle ? computeUniqueTitle(tabData.type, tabData.title) : tabData.title || ""; diff --git a/src/ui/desktop/navigation/tabs/TabDropdown.tsx b/src/ui/desktop/navigation/tabs/TabDropdown.tsx index 578a550b..fdc2a711 100644 --- a/src/ui/desktop/navigation/tabs/TabDropdown.tsx +++ b/src/ui/desktop/navigation/tabs/TabDropdown.tsx @@ -12,6 +12,8 @@ import { Terminal as TerminalIcon, Server as ServerIcon, Folder as FolderIcon, + ArrowDownUp as TunnelIcon, + Container as DockerIcon, Shield as AdminIcon, Network as SshManagerIcon, User as UserIcon, @@ -33,6 +35,10 @@ export function TabDropdown(): React.ReactElement { return ; case "file_manager": return ; + case "tunnel": + return ; + case "docker": + return ; case "user_profile": return ; case "ssh_manager": @@ -52,6 +58,10 @@ export function TabDropdown(): React.ReactElement { return tab.title || t("nav.serverStats"); case "file_manager": return tab.title || t("nav.fileManager"); + case "tunnel": + return tab.title || t("nav.tunnels"); + case "docker": + return tab.title || t("nav.docker"); case "user_profile": return tab.title || t("nav.userProfile"); case "ssh_manager": diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index f4f948f6..5cd70bf5 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -7,6 +7,10 @@ import type { TunnelStatus, FileManagerFile, FileManagerShortcut, + DockerContainer, + DockerStats, + DockerLogOptions, + DockerValidation, } from "../types/index.js"; // ============================================================================ @@ -639,6 +643,9 @@ function initializeApiInstances() { // RBAC API (port 30001) rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC"); + + // Docker Management API (port 30007) + dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER"); } // SSH Host Management API (port 30001) @@ -662,6 +669,9 @@ export let homepageApi: AxiosInstance; // RBAC API (port 30001) export let rbacApi: AxiosInstance; +// Docker Management API (port 30007) +export let dockerApi: AxiosInstance; + function initializeApp() { if (isElectron()) { getServerConfig() @@ -904,6 +914,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise { enableTerminal: Boolean(hostData.enableTerminal), enableTunnel: Boolean(hostData.enableTunnel), enableFileManager: Boolean(hostData.enableFileManager), + enableDocker: Boolean(hostData.enableDocker), defaultPath: hostData.defaultPath || "/", tunnelConnections: hostData.tunnelConnections || [], jumpHosts: hostData.jumpHosts || [], @@ -913,6 +924,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise { ? hostData.statsConfig : JSON.stringify(hostData.statsConfig) : null, + dockerConfig: hostData.dockerConfig + ? typeof hostData.dockerConfig === "string" + ? hostData.dockerConfig + : JSON.stringify(hostData.dockerConfig) + : null, terminalConfig: hostData.terminalConfig || null, forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), }; @@ -970,6 +986,7 @@ export async function updateSSHHost( enableTerminal: Boolean(hostData.enableTerminal), enableTunnel: Boolean(hostData.enableTunnel), enableFileManager: Boolean(hostData.enableFileManager), + enableDocker: Boolean(hostData.enableDocker), defaultPath: hostData.defaultPath || "/", tunnelConnections: hostData.tunnelConnections || [], jumpHosts: hostData.jumpHosts || [], @@ -979,6 +996,11 @@ export async function updateSSHHost( ? hostData.statsConfig : JSON.stringify(hostData.statsConfig) : null, + dockerConfig: hostData.dockerConfig + ? typeof hostData.dockerConfig === "string" + ? hostData.dockerConfig + : JSON.stringify(hostData.dockerConfig) + : null, terminalConfig: hostData.terminalConfig || null, forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), }; @@ -3280,5 +3302,239 @@ export async function revokeHostAccess( return response.data; } catch (error) { throw handleApiError(error, "revoke host access"); + +// ============================================================================ +// DOCKER MANAGEMENT API +// ============================================================================ + +export async function connectDockerSession( + sessionId: string, + hostId: number, +): Promise<{ success: boolean; message: string }> { + try { + const response = await dockerApi.post("/ssh/connect", { + sessionId, + hostId, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "connect to Docker SSH session"); + } +} + +export async function disconnectDockerSession( + sessionId: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await dockerApi.post("/ssh/disconnect", { + sessionId, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "disconnect from Docker SSH session"); + } +} + +export async function keepaliveDockerSession( + sessionId: string, +): Promise<{ success: boolean }> { + try { + const response = await dockerApi.post("/ssh/keepalive", { + sessionId, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "keepalive Docker SSH session"); + } +} + +export async function getDockerSessionStatus( + sessionId: string, +): Promise<{ success: boolean; connected: boolean }> { + try { + const response = await dockerApi.get("/ssh/status", { + params: { sessionId }, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "get Docker session status"); + } +} + +export async function validateDockerAvailability( + sessionId: string, +): Promise { + try { + const response = await dockerApi.get(`/validate/${sessionId}`); + return response.data; + } catch (error) { + throw handleApiError(error, "validate Docker availability"); + } +} + +export async function listDockerContainers( + sessionId: string, + all: boolean = true, +): Promise { + try { + const response = await dockerApi.get(`/containers/${sessionId}`, { + params: { all }, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "list Docker containers"); + } +} + +export async function getDockerContainerDetails( + sessionId: string, + containerId: string, +): Promise { + try { + const response = await dockerApi.get( + `/containers/${sessionId}/${containerId}`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "get Docker container details"); + } +} + +export async function startDockerContainer( + sessionId: string, + containerId: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await dockerApi.post( + `/containers/${sessionId}/${containerId}/start`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "start Docker container"); + } +} + +export async function stopDockerContainer( + sessionId: string, + containerId: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await dockerApi.post( + `/containers/${sessionId}/${containerId}/stop`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "stop Docker container"); + } +} + +export async function restartDockerContainer( + sessionId: string, + containerId: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await dockerApi.post( + `/containers/${sessionId}/${containerId}/restart`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "restart Docker container"); + } +} + +export async function pauseDockerContainer( + sessionId: string, + containerId: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await dockerApi.post( + `/containers/${sessionId}/${containerId}/pause`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "pause Docker container"); + } +} + +export async function unpauseDockerContainer( + sessionId: string, + containerId: string, +): Promise<{ success: boolean; message: string }> { + try { + const response = await dockerApi.post( + `/containers/${sessionId}/${containerId}/unpause`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "unpause Docker container"); + } +} + +export async function removeDockerContainer( + sessionId: string, + containerId: string, + force: boolean = false, +): Promise<{ success: boolean; message: string }> { + try { + const response = await dockerApi.delete( + `/containers/${sessionId}/${containerId}/remove`, + { + params: { force }, + }, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "remove Docker container"); + } +} + +export async function getContainerLogs( + sessionId: string, + containerId: string, + options?: DockerLogOptions, +): Promise<{ logs: string }> { + try { + const response = await dockerApi.get( + `/containers/${sessionId}/${containerId}/logs`, + { + params: options, + }, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "get container logs"); + } +} + +export async function downloadContainerLogs( + sessionId: string, + containerId: string, + options?: DockerLogOptions, +): Promise { + try { + const response = await dockerApi.get( + `/containers/${sessionId}/${containerId}/logs`, + { + params: { ...options, download: true }, + responseType: "blob", + }, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "download container logs"); + } +} + +export async function getContainerStats( + sessionId: string, + containerId: string, +): Promise { + try { + const response = await dockerApi.get( + `/containers/${sessionId}/${containerId}/stats`, + ); + return response.data; + } catch (error) { + throw handleApiError(error, "get container stats"); } }