Merge branch 'dev-1.10.0' into fix/rbac-improvements

This commit is contained in:
Luke Gustafson
2025-12-19 20:13:25 -06:00
committed by GitHub
42 changed files with 5293 additions and 135 deletions

View File

@@ -312,6 +312,42 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; 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; error_page 500 502 503 504 /50x.html;
location = /50x.html { location = /50x.html {
root /usr/share/nginx/html; root /usr/share/nginx/html;

View File

@@ -309,6 +309,42 @@ http {
proxy_set_header X-Forwarded-Proto $scheme; 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; error_page 500 502 503 504 /50x.html;
location = /50x.html { location = /50x.html {
root /usr/share/nginx/html; root /usr/share/nginx/html;

245
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "termix", "name": "termix",
"version": "1.8.1", "version": "1.9.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "termix", "name": "termix",
"version": "1.8.1", "version": "1.9.0",
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.18.7", "@codemirror/autocomplete": "^6.18.7",
"@codemirror/commands": "^6.3.3", "@codemirror/commands": "^6.3.3",
@@ -16,6 +16,7 @@
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.11", "@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-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -26,7 +27,7 @@
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6", "@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-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
@@ -154,6 +155,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -439,6 +441,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz", "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz",
"integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==", "integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/language": "^6.0.0", "@codemirror/language": "^6.0.0",
"@codemirror/state": "^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", "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.0.0", "@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^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", "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz",
"integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.0.0", "@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^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", "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/autocomplete": "^6.0.0", "@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0", "@codemirror/language": "^6.6.0",
@@ -740,6 +746,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.0.0", "@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0", "@codemirror/view": "^6.23.0",
@@ -816,6 +823,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@marijn/find-cluster-break": "^1.0.0" "@marijn/find-cluster-break": "^1.0.0"
} }
@@ -837,6 +845,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz",
"integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@codemirror/state": "^6.5.0", "@codemirror/state": "^6.5.0",
"crelt": "^1.0.6", "crelt": "^1.0.6",
@@ -1164,6 +1173,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -1550,7 +1560,6 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"cross-dirname": "^0.1.0", "cross-dirname": "^0.1.0",
"debug": "^4.3.4", "debug": "^4.3.4",
@@ -1572,7 +1581,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@@ -2529,7 +2537,8 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz",
"integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==", "integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@lezer/cpp": { "node_modules/@lezer/cpp": {
"version": "1.1.3", "version": "1.1.3",
@@ -2569,6 +2578,7 @@
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@lezer/common": "^1.3.0" "@lezer/common": "^1.3.0"
} }
@@ -2600,6 +2610,7 @@
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz",
"integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@lezer/common": "^1.2.0", "@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3", "@lezer/highlight": "^1.1.3",
@@ -2622,6 +2633,7 @@
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@lezer/common": "^1.0.0" "@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": { "node_modules/@radix-ui/react-arrow": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", "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": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "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": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", "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": { "node_modules/@radix-ui/react-popover": {
"version": "1.1.15", "version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", "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": { "node_modules/@radix-ui/react-popper": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", "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": { "node_modules/@radix-ui/react-progress": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", "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": { "node_modules/@radix-ui/react-separator": {
"version": "1.1.7", "version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", "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": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.3", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.2" "@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": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "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==", "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
@@ -5002,6 +5187,7 @@
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz",
"integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/body-parser": "*", "@types/body-parser": "*",
"@types/express-serve-static-core": "^5.0.0", "@types/express-serve-static-core": "^5.0.0",
@@ -5124,6 +5310,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz",
"integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -5166,6 +5353,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -5176,6 +5364,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -5343,6 +5532,7 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2", "@typescript-eslint/types": "8.46.2",
@@ -5719,7 +5909,8 @@
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/7zip-bin": { "node_modules/7zip-bin": {
"version": "5.2.0", "version": "5.2.0",
@@ -5754,6 +5945,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -6176,6 +6368,7 @@
"integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bindings": "^1.5.0", "bindings": "^1.5.0",
"prebuild-install": "^7.1.1" "prebuild-install": "^7.1.1"
@@ -6310,6 +6503,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.19", "baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751", "caniuse-lite": "^1.0.30001751",
@@ -7313,6 +7507,7 @@
"integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"env-paths": "^2.2.1", "env-paths": "^2.2.1",
"import-fresh": "^3.3.0", "import-fresh": "^3.3.0",
@@ -7389,8 +7584,7 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true
"peer": true
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
@@ -7849,6 +8043,7 @@
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "26.0.12", "app-builder-lib": "26.0.12",
"builder-util": "26.0.11", "builder-util": "26.0.11",
@@ -7946,8 +8141,7 @@
"version": "3.1.7", "version": "3.1.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
"integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)"
"peer": true
}, },
"node_modules/dot-prop": { "node_modules/dot-prop": {
"version": "5.3.0", "version": "5.3.0",
@@ -8299,7 +8493,6 @@
"dev": true, "dev": true,
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@electron/asar": "^3.2.1", "@electron/asar": "^3.2.1",
"debug": "^4.1.1", "debug": "^4.1.1",
@@ -8320,7 +8513,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.1.2", "graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0", "jsonfile": "^4.0.0",
@@ -8336,7 +8528,6 @@
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"optionalDependencies": { "optionalDependencies": {
"graceful-fs": "^4.1.6" "graceful-fs": "^4.1.6"
} }
@@ -8347,7 +8538,6 @@
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
@@ -8610,6 +8800,7 @@
"integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -10208,6 +10399,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.27.6" "@babel/runtime": "^7.27.6"
}, },
@@ -11749,7 +11941,6 @@
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
}, },
@@ -13777,7 +13968,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"commander": "^9.4.0" "commander": "^9.4.0"
}, },
@@ -13795,7 +13985,6 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": "^12.20.0 || >=14" "node": "^12.20.0 || >=14"
} }
@@ -14247,6 +14436,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -14256,6 +14446,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -14282,6 +14473,7 @@
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz",
"integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
@@ -14429,6 +14621,7 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@@ -14637,7 +14830,8 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@@ -15958,7 +16152,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"mkdirp": "^0.5.1", "mkdirp": "^0.5.1",
"rimraf": "~2.6.2" "rimraf": "~2.6.2"
@@ -15999,7 +16192,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"minimist": "^1.2.6" "minimist": "^1.2.6"
}, },
@@ -16014,7 +16206,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported", "deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true, "dev": true,
"license": "ISC", "license": "ISC",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.3" "glob": "^7.1.3"
}, },
@@ -16119,6 +16310,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -16324,6 +16516,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -16736,6 +16929,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -16827,6 +17021,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },

View File

@@ -35,6 +35,7 @@
"@hookform/resolvers": "^5.1.1", "@hookform/resolvers": "^5.1.1",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@radix-ui/react-accordion": "^1.2.11", "@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-checkbox": "^1.3.2",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15",
@@ -45,7 +46,7 @@
"@radix-ui/react-select": "^2.2.5", "@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.6", "@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-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",

View File

@@ -201,12 +201,14 @@ async function initializeCompleteDatabase(): Promise<void> {
enable_tunnel INTEGER NOT NULL DEFAULT 1, enable_tunnel INTEGER NOT NULL DEFAULT 1,
tunnel_connections TEXT, tunnel_connections TEXT,
enable_file_manager INTEGER NOT NULL DEFAULT 1, enable_file_manager INTEGER NOT NULL DEFAULT 1,
enable_docker INTEGER NOT NULL DEFAULT 0,
default_path TEXT, default_path TEXT,
autostart_password TEXT, autostart_password TEXT,
autostart_key TEXT, autostart_key TEXT,
autostart_key_password TEXT, autostart_key_password TEXT,
force_keyboard_interactive TEXT, force_keyboard_interactive TEXT,
stats_config TEXT, stats_config TEXT,
docker_config TEXT,
terminal_config TEXT, terminal_config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_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", "stats_config", "TEXT");
addColumnIfNotExists("ssh_data", "terminal_config", "TEXT"); addColumnIfNotExists("ssh_data", "terminal_config", "TEXT");
addColumnIfNotExists("ssh_data", "quick_actions", "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", "private_key", "TEXT");
addColumnIfNotExists("ssh_credentials", "public_key", "TEXT"); addColumnIfNotExists("ssh_credentials", "public_key", "TEXT");

View File

@@ -86,6 +86,9 @@ export const sshData = sqliteTable("ssh_data", {
enableFileManager: integer("enable_file_manager", { mode: "boolean" }) enableFileManager: integer("enable_file_manager", { mode: "boolean" })
.notNull() .notNull()
.default(true), .default(true),
enableDocker: integer("enable_docker", { mode: "boolean" })
.notNull()
.default(false),
defaultPath: text("default_path"), defaultPath: text("default_path"),
statsConfig: text("stats_config"), statsConfig: text("stats_config"),
terminalConfig: text("terminal_config"), terminalConfig: text("terminal_config"),

View File

@@ -237,6 +237,7 @@ router.post(
enableTerminal, enableTerminal,
enableTunnel, enableTunnel,
enableFileManager, enableFileManager,
enableDocker,
defaultPath, defaultPath,
tunnelConnections, tunnelConnections,
jumpHosts, jumpHosts,
@@ -282,6 +283,7 @@ router.post(
? JSON.stringify(quickActions) ? JSON.stringify(quickActions)
: null, : null,
enableFileManager: enableFileManager ? 1 : 0, enableFileManager: enableFileManager ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
@@ -343,6 +345,7 @@ router.post(
? JSON.parse(createdHost.jumpHosts as string) ? JSON.parse(createdHost.jumpHosts as string)
: [], : [],
enableFileManager: !!createdHost.enableFileManager, enableFileManager: !!createdHost.enableFileManager,
enableDocker: !!createdHost.enableDocker,
statsConfig: createdHost.statsConfig statsConfig: createdHost.statsConfig
? JSON.parse(createdHost.statsConfig as string) ? JSON.parse(createdHost.statsConfig as string)
: undefined, : undefined,
@@ -459,6 +462,7 @@ router.put(
enableTerminal, enableTerminal,
enableTunnel, enableTunnel,
enableFileManager, enableFileManager,
enableDocker,
defaultPath, defaultPath,
tunnelConnections, tunnelConnections,
jumpHosts, jumpHosts,
@@ -505,6 +509,7 @@ router.put(
? JSON.stringify(quickActions) ? JSON.stringify(quickActions)
: null, : null,
enableFileManager: enableFileManager ? 1 : 0, enableFileManager: enableFileManager ? 1 : 0,
enableDocker: enableDocker ? 1 : 0,
defaultPath: defaultPath || null, defaultPath: defaultPath || null,
statsConfig: statsConfig ? JSON.stringify(statsConfig) : null, statsConfig: statsConfig ? JSON.stringify(statsConfig) : null,
terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null, terminalConfig: terminalConfig ? JSON.stringify(terminalConfig) : null,
@@ -584,9 +589,13 @@ router.put(
? JSON.parse(updatedHost.jumpHosts as string) ? JSON.parse(updatedHost.jumpHosts as string)
: [], : [],
enableFileManager: !!updatedHost.enableFileManager, enableFileManager: !!updatedHost.enableFileManager,
enableDocker: !!updatedHost.enableDocker,
statsConfig: updatedHost.statsConfig statsConfig: updatedHost.statsConfig
? JSON.parse(updatedHost.statsConfig as string) ? JSON.parse(updatedHost.statsConfig as string)
: undefined, : undefined,
dockerConfig: updatedHost.dockerConfig
? JSON.parse(updatedHost.dockerConfig as string)
: undefined,
}; };
const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost; const resolvedHost = (await resolveHostCredentials(baseHost)) || baseHost;
@@ -775,6 +784,7 @@ router.get(
? JSON.parse(row.quickActions as string) ? JSON.parse(row.quickActions as string)
: [], : [],
enableFileManager: !!row.enableFileManager, enableFileManager: !!row.enableFileManager,
enableDocker: !!row.enableDocker,
statsConfig: row.statsConfig statsConfig: row.statsConfig
? JSON.parse(row.statsConfig as string) ? JSON.parse(row.statsConfig as string)
: undefined, : undefined,

View File

@@ -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<string, SSHSession>();
// 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<string> {
const shells = ["bash", "sh", "ash"];
for (const shell of shells) {
try {
await new Promise<void>((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<SSHClient | null> {
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<void>((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<void>((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<any>((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<void>((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);
});
});

1464
src/backend/ssh/docker.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -316,7 +316,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
let sshConn: Client | null = null; let sshConn: Client | null = null;
let sshStream: ClientChannel | null = null; let sshStream: ClientChannel | null = null;
let pingInterval: NodeJS.Timeout | null = null;
let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null; let keyboardInteractiveFinish: ((responses: string[]) => void) | null = null;
let totpPromptSent = false; let totpPromptSent = false;
let isKeyboardInteractive = false; let isKeyboardInteractive = false;
@@ -802,8 +801,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
); );
}); });
setupPingInterval();
if (initialPath && initialPath.trim() !== "") { if (initialPath && initialPath.trim() !== "") {
const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`; const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`;
stream.write(cdCommand); stream.write(cdCommand);
@@ -1279,11 +1276,6 @@ wss.on("connection", async (ws: WebSocket, req) => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
} }
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
if (sshStream) { if (sshStream) {
try { try {
sshStream.end(); sshStream.end();
@@ -1320,24 +1312,12 @@ wss.on("connection", async (ws: WebSocket, req) => {
}, 100); }, 100);
} }
function setupPingInterval() { // Note: PTY-level keepalive (writing \x00 to the stream) was removed.
pingInterval = setInterval(() => { // It was causing ^@ characters to appear in terminals with echoctl enabled.
if (sshConn && sshStream) { // SSH-level keepalive is configured via connectConfig (keepaliveInterval,
try { // keepaliveCountMax, tcpKeepAlive), which handles connection health monitoring
sshStream.write("\x00"); // without producing visible output on the terminal.
} catch (e: unknown) { //
sshLogger.error( // See: https://github.com/Termix-SSH/Support/issues/232
"SSH keepalive failed: " + // See: https://github.com/Termix-SSH/Support/issues/309
(e instanceof Error ? e.message : "Unknown error"),
);
cleanupSSH();
}
} else if (!sshConn || !sshStream) {
if (pingInterval) {
clearInterval(pingInterval);
pingInterval = null;
}
}
}, 30000);
}
}); });

View File

@@ -102,6 +102,8 @@ import { systemLogger, versionLogger } from "./utils/logger.js";
await import("./ssh/tunnel.js"); await import("./ssh/tunnel.js");
await import("./ssh/file-manager.js"); await import("./ssh/file-manager.js");
await import("./ssh/server-stats.js"); await import("./ssh/server-stats.js");
await import("./ssh/docker.js");
await import("./ssh/docker-console.js");
await import("./dashboard.js"); await import("./dashboard.js");
process.on("SIGINT", () => { process.on("SIGINT", () => {

View File

@@ -36,7 +36,7 @@ const SENSITIVE_FIELDS = [
const TRUNCATE_FIELDS = ["data", "content", "body", "response", "request"]; const TRUNCATE_FIELDS = ["data", "content", "body", "response", "request"];
class Logger { export class Logger {
private serviceName: string; private serviceName: string;
private serviceIcon: string; private serviceIcon: string;
private serviceColor: string; private serviceColor: string;

View File

@@ -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<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className,
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className,
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -40,6 +40,7 @@ export interface SSHHost {
enableTerminal: boolean; enableTerminal: boolean;
enableTunnel: boolean; enableTunnel: boolean;
enableFileManager: boolean; enableFileManager: boolean;
enableDocker: boolean;
defaultPath: string; defaultPath: string;
tunnelConnections: TunnelConnection[]; tunnelConnections: TunnelConnection[];
jumpHosts?: JumpHost[]; jumpHosts?: JumpHost[];
@@ -82,6 +83,7 @@ export interface SSHHostData {
enableTerminal?: boolean; enableTerminal?: boolean;
enableTunnel?: boolean; enableTunnel?: boolean;
enableFileManager?: boolean; enableFileManager?: boolean;
enableDocker?: boolean;
defaultPath?: string; defaultPath?: string;
forceKeyboardInteractive?: boolean; forceKeyboardInteractive?: boolean;
tunnelConnections?: TunnelConnection[]; tunnelConnections?: TunnelConnection[];
@@ -350,7 +352,8 @@ export interface TabContextTab {
| "server" | "server"
| "admin" | "admin"
| "file_manager" | "file_manager"
| "user_profile"; | "user_profile"
| "docker";
title: string; title: string;
hostConfig?: SSHHost; hostConfig?: SSHHost;
terminalRef?: any; terminalRef?: any;
@@ -676,3 +679,55 @@ export interface RestoreRequestBody {
backupPath: string; backupPath: string;
targetPath?: 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<string, string>;
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;
}

View File

@@ -155,7 +155,9 @@ function AppContent() {
const showTerminalView = const showTerminalView =
currentTabData?.type === "terminal" || currentTabData?.type === "terminal" ||
currentTabData?.type === "server" || currentTabData?.type === "server" ||
currentTabData?.type === "file_manager"; currentTabData?.type === "file_manager" ||
currentTabData?.type === "tunnel" ||
currentTabData?.type === "docker";
const showHome = currentTabData?.type === "home"; const showHome = currentTabData?.type === "home";
const showSshManager = currentTabData?.type === "ssh_manager"; const showSshManager = currentTabData?.type === "ssh_manager";
const showAdmin = currentTabData?.type === "admin"; const showAdmin = currentTabData?.type === "admin";

View File

@@ -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<string | null>(null);
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
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<DockerValidation | null>(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 (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Docker is not enabled for this host. Enable it in Host Settings
to use Docker features.
</AlertDescription>
</Alert>
</div>
</div>
</div>
);
}
// Loading state
if (isConnecting || isValidating) {
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-4 flex items-center justify-center">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-gray-400 mt-4">
{isValidating
? "Validating Docker..."
: "Connecting to host..."}
</p>
</div>
</div>
</div>
</div>
);
}
// Docker not available
if (dockerValidation && !dockerValidation.available) {
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-4">
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
<div className="font-semibold mb-2">Docker Error</div>
<div>{dockerValidation.error}</div>
{dockerValidation.code && (
<div className="mt-2 text-xs opacity-70">
Error code: {dockerValidation.code}
</div>
)}
</AlertDescription>
</Alert>
</div>
</div>
</div>
);
}
return (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
{dockerValidation?.version && (
<p className="text-xs text-gray-400">
Docker v{dockerValidation.version}
</p>
)}
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0">
{viewMode === "list" ? (
<div className="h-full px-4 py-4">
{sessionId ? (
<ContainerList
containers={containers}
sessionId={sessionId}
onSelectContainer={(id) => {
setSelectedContainer(id);
setViewMode("detail");
}}
selectedContainerId={selectedContainer}
onRefresh={refreshContainers}
/>
) : (
<div className="text-center py-8">
<p className="text-gray-400">No session available</p>
</div>
)}
</div>
) : sessionId && selectedContainer && currentHostConfig ? (
<ContainerDetail
sessionId={sessionId}
containerId={selectedContainer}
containers={containers}
hostConfig={currentHostConfig}
onBack={handleBack}
/>
) : (
<div className="text-center py-8">
<p className="text-gray-400">
Select a container to view details
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -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<string>("bash");
const wsRef = React.useRef<WebSocket | null>(null);
const fitAddonRef = React.useRef<FitAddon | null>(null);
const pingIntervalRef = React.useRef<NodeJS.Timeout | null>(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 (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
<p className="text-gray-400 text-lg">Container is not running</p>
<p className="text-gray-500 text-sm">
Start the container to access the console
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full gap-3">
{/* Controls */}
<Card className="py-3">
<CardContent className="px-3">
<div className="flex flex-col sm:flex-row gap-2 items-center sm:items-center">
<div className="flex items-center gap-2 flex-1">
<TerminalIcon className="h-5 w-5" />
<span className="text-base font-medium">Console</span>
</div>
<Select
value={selectedShell}
onValueChange={setSelectedShell}
disabled={isConnected}
>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Select shell" />
</SelectTrigger>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="sh">Sh</SelectItem>
<SelectItem value="ash">Ash</SelectItem>
</SelectContent>
</Select>
<div className="flex gap-2 sm:gap-2">
{!isConnected ? (
<Button
onClick={connect}
disabled={isConnecting}
className="min-w-[120px]"
>
{isConnecting ? (
<>
<div className="h-4 w-4 mr-2 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
Connecting...
</>
) : (
<>
<Power className="h-4 w-4 mr-2" />
Connect
</>
)}
</Button>
) : (
<Button
onClick={disconnect}
variant="destructive"
className="min-w-[120px]"
>
<PowerOff className="h-4 w-4 mr-2" />
Disconnect
</Button>
)}
</div>
</div>
</CardContent>
</Card>
{/* Terminal */}
<Card className="flex-1 overflow-hidden pt-1 pb-0">
<CardContent className="p-0 h-full relative">
{/* Terminal container - always rendered */}
<div
ref={xtermRef}
className="h-full w-full"
style={{ display: isConnected ? "block" : "none" }}
/>
{/* Not connected message */}
{!isConnected && !isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center space-y-2">
<TerminalIcon className="h-12 w-12 text-gray-600 mx-auto" />
<p className="text-gray-400">Not connected</p>
<p className="text-gray-500 text-sm">
Click Connect to start an interactive shell
</p>
</div>
</div>
)}
{/* Connecting message */}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-gray-400 mt-4">
Connecting to {containerName}...
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -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 (
<>
<Card
className={`cursor-pointer transition-all hover:shadow-lg ${
isSelected
? "ring-2 ring-primary border-primary"
: `border-2 ${colors.border}`
} ${colors.bg} pt-3 pb-0`}
onClick={onSelect}
>
<CardHeader className="pb-2 px-4">
<div className="flex items-start justify-between gap-2">
<CardTitle className="text-base font-semibold truncate flex-1">
{container.name.startsWith("/")
? container.name.slice(1)
: container.name}
</CardTitle>
<Badge className={`${colors.badge} border shrink-0`}>
{container.state}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-3">
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-gray-400 min-w-[50px] text-xs">Image:</span>
<span className="truncate text-gray-200 text-xs">
{container.image}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-400 min-w-[50px] text-xs">ID:</span>
<span className="font-mono text-xs text-gray-200">
{container.id.substring(0, 12)}
</span>
</div>
<div className="flex items-start gap-2">
<span className="text-gray-400 min-w-[50px] text-xs shrink-0">
Ports:
</span>
<div className="flex flex-wrap gap-1">
{portsList.length > 0 ? (
portsList.map((port, idx) => (
<Badge
key={idx}
variant="outline"
className="text-xs font-mono bg-gray-500/10 text-gray-400 border-gray-500/30"
>
{port}
</Badge>
))
) : (
<Badge
variant="outline"
className="text-xs bg-gray-500/10 text-gray-400 border-gray-500/30"
>
None
</Badge>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-gray-400 min-w-[50px] text-xs">
Created:
</span>
<span className="text-gray-200 text-xs">
{formatCreatedDate(container.created)}
</span>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-2 border-t border-gray-700/50">
<TooltipProvider>
{container.state !== "running" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handleStart}
disabled={isLoading}
>
{isStarting ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Start</TooltipContent>
</Tooltip>
)}
{container.state === "running" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handleStop}
disabled={isLoading}
>
{isStopping ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<Square className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Stop</TooltipContent>
</Tooltip>
)}
{(container.state === "running" ||
container.state === "paused") && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handlePause}
disabled={isLoading}
>
{isPausing ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : container.state === "paused" ? (
<PlayCircle className="h-4 w-4" />
) : (
<Pause className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{container.state === "paused" ? "Unpause" : "Pause"}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8"
onClick={handleRestart}
disabled={isLoading || container.state === "exited"}
>
{isRestarting ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<RotateCw className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Restart</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="h-8 text-red-400 hover:text-red-300 hover:bg-red-500/20"
onClick={(e) => {
e.stopPropagation();
setShowRemoveDialog(true);
}}
disabled={isLoading}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Remove</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardContent>
</Card>
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Container</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to remove container{" "}
<span className="font-semibold">
{container.name.startsWith("/")
? container.name.slice(1)
: container.name}
</span>
?
{container.state === "running" && (
<div className="mt-2 text-yellow-400">
Warning: This container is currently running and will be
force-removed.
</div>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isRemoving}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRemove();
}}
disabled={isRemoving}
className="bg-red-600 hover:bg-red-700"
>
{isRemoving ? "Removing..." : "Remove"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -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 (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-gray-400 text-lg">Container not found</p>
<Button onClick={onBack} variant="outline">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to list
</Button>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
{/* Header with back button */}
<div className="flex items-center gap-4 px-4 pt-3 pb-3">
<Button variant="ghost" onClick={onBack} size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div className="min-w-0 flex-1">
<h2 className="font-bold text-lg truncate">{container.name}</h2>
<p className="text-sm text-gray-400 truncate">{container.image}</p>
</div>
</div>
<Separator className="p-0.25 w-full" />
{/* Tabs for Logs, Stats, Console */}
<div className="flex-1 overflow-hidden min-h-0">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="h-full flex flex-col"
>
<div className="px-4 pt-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="stats">Stats</TabsTrigger>
<TabsTrigger value="console">Console</TabsTrigger>
</TabsList>
</div>
<TabsContent
value="logs"
className="flex-1 overflow-auto px-3 pb-3 mt-3"
>
<LogViewer
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
/>
</TabsContent>
<TabsContent
value="stats"
className="flex-1 overflow-auto px-3 pb-3 mt-3"
>
<ContainerStats
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
containerState={container.state}
/>
</TabsContent>
<TabsContent
value="console"
className="flex-1 overflow-hidden px-3 pb-3 mt-3"
>
<ConsoleTerminal
sessionId={sessionId}
containerId={containerId}
containerName={container.name}
containerState={container.state}
hostConfig={hostConfig}
/>
</TabsContent>
</Tabs>
</div>
</div>
);
}

View File

@@ -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<string>("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<string, number> = {};
containers.forEach((c) => {
counts[c.state] = (counts[c.state] || 0) + 1;
});
return counts;
}, [containers]);
if (containers.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-gray-400 text-lg">No containers found</p>
<p className="text-gray-500 text-sm">
Start by creating containers on your server
</p>
</div>
</div>
);
}
return (
<div className="flex flex-col h-full gap-3">
{/* Search and Filter Bar */}
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search by name, image, or ID..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex items-center gap-2 sm:min-w-[200px]">
<Filter className="h-4 w-4 text-gray-400" />
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All ({containers.length})</SelectItem>
{Object.entries(statusCounts).map(([status, count]) => (
<SelectItem key={status} value={status}>
{status.charAt(0).toUpperCase() + status.slice(1)} ({count})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Container Grid */}
{filteredContainers.length === 0 ? (
<div className="flex items-center justify-center flex-1">
<div className="text-center space-y-2">
<p className="text-gray-400">No containers match your filters</p>
<p className="text-gray-500 text-sm">
Try adjusting your search or filter
</p>
</div>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3 overflow-auto pb-2">
{filteredContainers.map((container) => (
<ContainerCard
key={container.id}
container={container}
sessionId={sessionId}
onSelect={() => onSelectContainer(container.id)}
isSelected={selectedContainerId === container.id}
onRefresh={onRefresh}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -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<DockerStats | null>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(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 (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<Activity className="h-12 w-12 text-gray-600 mx-auto" />
<p className="text-gray-400 text-lg">Container is not running</p>
<p className="text-gray-500 text-sm">
Start the container to view statistics
</p>
</div>
</div>
);
}
if (isLoading && !stats) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<SimpleLoader size="lg" />
<p className="text-gray-400 mt-4">Loading stats...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-red-400 text-lg">Error loading stats</p>
<p className="text-gray-500 text-sm">{error}</p>
</div>
</div>
);
}
if (!stats) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-gray-400">No stats available</p>
</div>
);
}
const cpuPercent = parseFloat(stats.cpu) || 0;
const memPercent = parseFloat(stats.memoryPercent) || 0;
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 h-full overflow-auto">
{/* CPU Usage */}
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<Cpu className="h-5 w-5 text-blue-400" />
CPU Usage
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Current</span>
<span className="font-mono font-semibold text-blue-300">
{stats.cpu}
</span>
</div>
<Progress value={Math.min(cpuPercent, 100)} className="h-2" />
</div>
</CardContent>
</Card>
{/* Memory Usage */}
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<MemoryStick className="h-5 w-5 text-purple-400" />
Memory Usage
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Used / Limit</span>
<span className="font-mono font-semibold text-purple-300">
{stats.memoryUsed} / {stats.memoryLimit}
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Percentage</span>
<span className="font-mono text-purple-300">
{stats.memoryPercent}
</span>
</div>
<Progress value={Math.min(memPercent, 100)} className="h-2" />
</div>
</CardContent>
</Card>
{/* Network I/O */}
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<Network className="h-5 w-5 text-green-400" />
Network I/O
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Input</span>
<span className="font-mono text-green-300">{stats.netInput}</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Output</span>
<span className="font-mono text-green-300">
{stats.netOutput}
</span>
</div>
</div>
</CardContent>
</Card>
{/* Block I/O */}
<Card className="py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<HardDrive className="h-5 w-5 text-orange-400" />
Block I/O
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="space-y-2">
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Read</span>
<span className="font-mono text-orange-300">
{stats.blockRead}
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">Write</span>
<span className="font-mono text-orange-300">
{stats.blockWrite}
</span>
</div>
{stats.pids && (
<div className="flex justify-between items-center text-sm">
<span className="text-gray-400">PIDs</span>
<span className="font-mono text-orange-300">{stats.pids}</span>
</div>
)}
</div>
</CardContent>
</Card>
{/* Container Info */}
<Card className="md:col-span-2 py-3">
<CardHeader className="pb-2 px-4">
<CardTitle className="text-base flex items-center gap-2">
<Activity className="h-5 w-5 text-cyan-400" />
Container Information
</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
<div className="flex justify-between items-center">
<span className="text-gray-400">Name:</span>
<span className="font-mono text-gray-200">{containerName}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">ID:</span>
<span className="font-mono text-sm text-gray-300">
{containerId.substring(0, 12)}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-gray-400">State:</span>
<span className="font-semibold text-green-400 capitalize">
{containerState}
</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -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<string>("");
const [isLoading, setIsLoading] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState(false);
const [tailLines, setTailLines] = React.useState<string>("100");
const [showTimestamps, setShowTimestamps] = React.useState(false);
const [autoRefresh, setAutoRefresh] = React.useState(false);
const [searchFilter, setSearchFilter] = React.useState("");
const logsEndRef = React.useRef<HTMLDivElement>(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 (
<div className="flex flex-col h-full gap-3">
{/* Controls */}
<Card className="py-3">
<CardContent className="px-3">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
{/* Tail Lines */}
<div className="flex flex-col">
<Label htmlFor="tail-lines" className="mb-1">
Lines to show
</Label>
<Select value={tailLines} onValueChange={setTailLines}>
<SelectTrigger id="tail-lines">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="50">Last 50 lines</SelectItem>
<SelectItem value="100">Last 100 lines</SelectItem>
<SelectItem value="500">Last 500 lines</SelectItem>
<SelectItem value="1000">Last 1000 lines</SelectItem>
<SelectItem value="all">All logs</SelectItem>
</SelectContent>
</Select>
</div>
{/* Timestamps */}
<div className="flex flex-col">
<Label htmlFor="timestamps" className="mb-1">
Show Timestamps
</Label>
<div className="flex items-center h-10 px-3 border rounded-md">
<Switch
id="timestamps"
checked={showTimestamps}
onCheckedChange={setShowTimestamps}
/>
<span className="ml-2 text-sm">
{showTimestamps ? "Enabled" : "Disabled"}
</span>
</div>
</div>
{/* Auto Refresh */}
<div className="flex flex-col">
<Label htmlFor="auto-refresh" className="mb-1">
Auto Refresh
</Label>
<div className="flex items-center h-10 px-3 border rounded-md">
<Switch
id="auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<span className="ml-2 text-sm">
{autoRefresh ? "On" : "Off"}
</span>
</div>
</div>
{/* Actions */}
<div className="flex flex-col">
<Label className="mb-1">Actions</Label>
<div className="flex gap-2 h-10">
<Button
size="sm"
variant="outline"
onClick={fetchLogs}
disabled={isLoading}
className="flex-1 h-full"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleDownload}
disabled={isDownloading}
className="flex-1 h-full"
>
{isDownloading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-400 border-t-transparent" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
{/* Search Filter */}
<div className="mt-2">
<div className="relative">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Filter logs..."
value={searchFilter}
onChange={(e) => 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"
/>
</div>
</div>
</CardContent>
</Card>
{/* Logs Display */}
<Card className="flex-1 overflow-hidden py-0">
<CardContent className="p-0 h-full">
{isLoading && !logs ? (
<div className="flex items-center justify-center h-full">
<SimpleLoader size="lg" />
</div>
) : (
<div className="h-full overflow-auto">
<pre className="p-4 text-xs font-mono whitespace-pre-wrap break-words text-gray-200 leading-relaxed">
{filteredLogs || (
<span className="text-gray-500">No logs available</span>
)}
<div ref={logsEndRef} />
</pre>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -567,6 +567,7 @@ export function HostManagerEditor({
}), }),
) )
.default([]), .default([]),
enableDocker: z.boolean().default(false),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.authType === "none") { if (data.authType === "none") {
@@ -659,6 +660,7 @@ export function HostManagerEditor({
statsConfig: DEFAULT_STATS_CONFIG, statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false, forceKeyboardInteractive: false,
enableDocker: false,
}, },
}); });
@@ -754,6 +756,7 @@ export function HostManagerEditor({
: [], : [],
}, },
forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(cleanedHost.forceKeyboardInteractive),
enableDocker: Boolean(cleanedHost.enableDocker),
}; };
if (defaultAuthType === "password") { if (defaultAuthType === "password") {
@@ -805,6 +808,7 @@ export function HostManagerEditor({
statsConfig: DEFAULT_STATS_CONFIG, statsConfig: DEFAULT_STATS_CONFIG,
terminalConfig: DEFAULT_TERMINAL_CONFIG, terminalConfig: DEFAULT_TERMINAL_CONFIG,
forceKeyboardInteractive: false, forceKeyboardInteractive: false,
enableDocker: false,
}; };
form.reset(defaultFormData); form.reset(defaultFormData);
@@ -862,6 +866,7 @@ export function HostManagerEditor({
authType: data.authType, authType: data.authType,
overrideCredentialUsername: Boolean(data.overrideCredentialUsername), overrideCredentialUsername: Boolean(data.overrideCredentialUsername),
enableTerminal: Boolean(data.enableTerminal), enableTerminal: Boolean(data.enableTerminal),
enableDocker: Boolean(data.enableDocker),
enableTunnel: Boolean(data.enableTunnel), enableTunnel: Boolean(data.enableTunnel),
enableFileManager: Boolean(data.enableFileManager), enableFileManager: Boolean(data.enableFileManager),
defaultPath: data.defaultPath || "/", defaultPath: data.defaultPath || "/",
@@ -949,9 +954,8 @@ export function HostManagerEditor({
window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
if (savedHost?.id) { if (savedHost?.id) {
const { notifyHostCreatedOrUpdated } = await import( const { notifyHostCreatedOrUpdated } =
"@/ui/main-axios.ts" await import("@/ui/main-axios.ts");
);
notifyHostCreatedOrUpdated(savedHost.id); notifyHostCreatedOrUpdated(savedHost.id);
} }
} catch (error) { } catch (error) {
@@ -984,6 +988,8 @@ export function HostManagerEditor({
setActiveTab("general"); setActiveTab("general");
} else if (errors.enableTerminal || errors.terminalConfig) { } else if (errors.enableTerminal || errors.terminalConfig) {
setActiveTab("terminal"); setActiveTab("terminal");
} else if (errors.enableDocker) {
setActiveTab("docker");
} else if (errors.enableTunnel || errors.tunnelConnections) { } else if (errors.enableTunnel || errors.tunnelConnections) {
setActiveTab("tunnel"); setActiveTab("tunnel");
} else if (errors.enableFileManager || errors.defaultPath) { } else if (errors.enableFileManager || errors.defaultPath) {
@@ -1176,6 +1182,7 @@ export function HostManagerEditor({
<TabsTrigger value="terminal"> <TabsTrigger value="terminal">
{t("hosts.terminal")} {t("hosts.terminal")}
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="docker">Docker</TabsTrigger>
<TabsTrigger value="tunnel">{t("hosts.tunnel")}</TabsTrigger> <TabsTrigger value="tunnel">{t("hosts.tunnel")}</TabsTrigger>
<TabsTrigger value="file_manager"> <TabsTrigger value="file_manager">
{t("hosts.fileManager")} {t("hosts.fileManager")}
@@ -2551,6 +2558,26 @@ export function HostManagerEditor({
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>
</TabsContent> </TabsContent>
<TabsContent value="docker" className="space-y-4">
<FormField
control={form.control}
name="enableDocker"
render={({ field }) => (
<FormItem>
<FormLabel>Enable Docker</FormLabel>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormDescription>
Enable Docker integration for this host
</FormDescription>
</FormItem>
)}
/>
</TabsContent>
<TabsContent value="tunnel"> <TabsContent value="tunnel">
<FormField <FormField
control={form.control} control={form.control}

View File

@@ -3,7 +3,6 @@ import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status"; import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button.tsx";
import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
import { import {
getServerStatusById, getServerStatusById,
getServerMetricsById, getServerMetricsById,
@@ -64,7 +63,7 @@ interface ServerProps {
embedded?: boolean; embedded?: boolean;
} }
export function Server({ export function ServerStats({
hostConfig, hostConfig,
title, title,
isVisible = true, isVisible = true,
@@ -462,7 +461,7 @@ export function Server({
{(metricsEnabled && showStatsUI) || {(metricsEnabled && showStatsUI) ||
(currentHostConfig?.quickActions && (currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0) ? ( currentHostConfig.quickActions.length > 0) ? (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 overflow-y-auto relative flex-1 flex flex-col"> <div className="rounded-lg border-dark-border m-3 p-1 overflow-y-auto relative flex-1 flex flex-col">
{currentHostConfig?.quickActions && {currentHostConfig?.quickActions &&
currentHostConfig.quickActions.length > 0 && ( currentHostConfig.quickActions.length > 0 && (
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}> <div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
@@ -600,20 +599,6 @@ export function Server({
)} )}
</div> </div>
) : null} ) : null}
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 && (
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -30,7 +30,7 @@ export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
}, [metricsHistory]); }, [metricsHistory]);
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Cpu className="h-5 w-5 text-blue-400" /> <Cpu className="h-5 w-5 text-blue-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -27,7 +27,7 @@ export function DiskWidget({ metrics }: DiskWidgetProps) {
}, [metrics]); }, [metrics]);
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<HardDrive className="h-5 w-5 text-orange-400" /> <HardDrive className="h-5 w-5 text-orange-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -35,7 +35,7 @@ export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
const uniqueIPs = loginStats?.uniqueIPs || 0; const uniqueIPs = loginStats?.uniqueIPs || 0;
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<UserCheck className="h-5 w-5 text-green-400" /> <UserCheck className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -30,7 +30,7 @@ export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
}, [metricsHistory]); }, [metricsHistory]);
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<MemoryStick className="h-5 w-5 text-green-400" /> <MemoryStick className="h-5 w-5 text-green-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -24,7 +24,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
const interfaces = network?.interfaces || []; const interfaces = network?.interfaces || [];
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Network className="h-5 w-5 text-indigo-400" /> <Network className="h-5 w-5 text-indigo-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -28,7 +28,7 @@ export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
const topProcesses = processes?.top || []; const topProcesses = processes?.top || [];
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<List className="h-5 w-5 text-yellow-400" /> <List className="h-5 w-5 text-yellow-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -21,7 +21,7 @@ export function SystemWidget({ metrics }: SystemWidgetProps) {
const system = metricsWithSystem?.system; const system = metricsWithSystem?.system;
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Server className="h-5 w-5 text-purple-400" /> <Server className="h-5 w-5 text-purple-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -20,7 +20,7 @@ export function UptimeWidget({ metrics }: UptimeWidgetProps) {
const uptime = metricsWithUptime?.uptime; const uptime = metricsWithUptime?.uptime;
return ( return (
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden"> <div className="h-full w-full p-4 rounded-lg bg-dark-bg-darker border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 flex-shrink-0 mb-3"> <div className="flex items-center gap-2 flex-shrink-0 mb-3">
<Clock className="h-5 w-5 text-cyan-400" /> <Clock className="h-5 w-5 text-cyan-400" />
<h3 className="font-semibold text-lg text-white"> <h3 className="font-semibold text-lg text-white">

View File

@@ -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 (
<div style={wrapperStyle} className={containerClass}>
<div className="h-full w-full flex flex-col">
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
<div className="flex items-center gap-4 min-w-0">
<div className="min-w-0">
<h1 className="font-bold text-lg truncate">
{currentHostConfig?.folder} / {title}
</h1>
</div>
</div>
</div>
<Separator className="p-0.25 w-full" />
<div className="flex-1 overflow-hidden min-h-0 p-1">
{currentHostConfig?.tunnelConnections &&
currentHostConfig.tunnelConnections.length > 0 ? (
<div className="rounded-lg h-full overflow-hidden flex flex-col min-h-0">
<Tunnel
filterHostKey={
currentHostConfig?.name &&
currentHostConfig.name.trim() !== ""
? currentHostConfig.name
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
}
/>
</div>
) : (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-gray-400 text-lg">
{t("tunnel.noTunnelsConfigured")}
</p>
<p className="text-gray-500 text-sm mt-2">
{t("tunnel.configureTunnelsInHostSettings")}
</p>
</div>
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -43,11 +43,6 @@ export function TunnelViewer({
return ( return (
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0"> <div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
<div className="w-full flex-shrink-0 mb-2">
<h1 className="text-xl font-semibold text-foreground">
{t("tunnels.title")}
</h1>
</div>
<div className="min-h-0 flex-1 overflow-auto pr-1"> <div className="min-h-0 flex-1 overflow-auto pr-1">
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full"> <div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
{activeHost.tunnelConnections.map((t, idx) => ( {activeHost.tunnelConnections.map((t, idx) => (

View File

@@ -1,7 +1,9 @@
import React, { useEffect, useRef, useState, useMemo } from "react"; import React, { useEffect, useRef, useState, useMemo } from "react";
import { Terminal } from "@/ui/desktop/apps/terminal/Terminal.tsx"; 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 { 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 { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { import {
ResizablePanelGroup, ResizablePanelGroup,
@@ -58,7 +60,9 @@ export function AppView({
(tab: TabData) => (tab: TabData) =>
tab.type === "terminal" || tab.type === "terminal" ||
tab.type === "server" || tab.type === "server" ||
tab.type === "file_manager", tab.type === "file_manager" ||
tab.type === "tunnel" ||
tab.type === "docker",
), ),
[tabs], [tabs],
); );
@@ -210,7 +214,10 @@ export function AppView({
const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab); const mainTab = terminalTabs.find((tab: TabData) => tab.id === currentTab);
if (allSplitScreenTab.length === 0 && mainTab) { 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 = { const newStyle = {
position: "absolute" as const, position: "absolute" as const,
top: isFileManagerTab ? 0 : 4, top: isFileManagerTab ? 0 : 4,
@@ -257,9 +264,14 @@ export function AppView({
const isVisible = const isVisible =
hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab); hasStyle || (allSplitScreenTab.length === 0 && t.id === currentTab);
const effectiveVisible = isVisible;
const previousStyle = previousStylesRef.current[t.id]; 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 = { const standardStyle = {
position: "absolute" as const, position: "absolute" as const,
top: isFileManagerTab ? 0 : 4, top: isFileManagerTab ? 0 : 4,
@@ -270,6 +282,16 @@ export function AppView({
const finalStyle: React.CSSProperties = hasStyle const finalStyle: React.CSSProperties = hasStyle
? { ...styles[t.id], overflow: "hidden" } ? { ...styles[t.id], overflow: "hidden" }
: effectiveVisible
? {
...(previousStyle || standardStyle),
opacity: 1,
pointerEvents: "auto",
zIndex: 20,
display: "block",
transition: "opacity 150ms ease-in-out",
overflow: "hidden",
}
: ({ : ({
...(previousStyle || standardStyle), ...(previousStyle || standardStyle),
opacity: 0, opacity: 0,
@@ -279,8 +301,6 @@ export function AppView({
overflow: "hidden", overflow: "hidden",
} as React.CSSProperties); } as React.CSSProperties);
const effectiveVisible = isVisible;
const isTerminal = t.type === "terminal"; const isTerminal = t.type === "terminal";
const terminalConfig = { const terminalConfig = {
...DEFAULT_TERMINAL_CONFIG, ...DEFAULT_TERMINAL_CONFIG,
@@ -317,6 +337,22 @@ export function AppView({
isTopbarOpen={isTopbarOpen} isTopbarOpen={isTopbarOpen}
embedded embedded
/> />
) : t.type === "tunnel" ? (
<TunnelManager
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen}
embedded
/>
) : t.type === "docker" ? (
<DockerManager
hostConfig={t.hostConfig}
title={t.title}
isVisible={effectiveVisible}
isTopbarOpen={isTopbarOpen}
embedded
/>
) : ( ) : (
<FileManager <FileManager
embedded embedded
@@ -636,6 +672,8 @@ export function AppView({
const currentTabData = tabs.find((tab: TabData) => tab.id === currentTab); const currentTabData = tabs.find((tab: TabData) => tab.id === currentTab);
const isFileManager = currentTabData?.type === "file_manager"; const isFileManager = currentTabData?.type === "file_manager";
const isTunnel = currentTabData?.type === "tunnel";
const isDocker = currentTabData?.type === "docker";
const isTerminal = currentTabData?.type === "terminal"; const isTerminal = currentTabData?.type === "terminal";
const isSplitScreen = allSplitScreenTab.length > 0; const isSplitScreen = allSplitScreenTab.length > 0;
@@ -653,7 +691,7 @@ export function AppView({
const bottomMarginPx = 8; const bottomMarginPx = 8;
let containerBackground = "var(--color-dark-bg)"; let containerBackground = "var(--color-dark-bg)";
if (isFileManager && !isSplitScreen) { if ((isFileManager || isTunnel || isDocker) && !isSplitScreen) {
containerBackground = "var(--color-dark-bg-darkest)"; containerBackground = "var(--color-dark-bg-darkest)";
} else if (isTerminal) { } else if (isTerminal) {
containerBackground = terminalBackgroundColor; containerBackground = terminalBackgroundColor;

View File

@@ -369,10 +369,13 @@ export function TopNavbar({
const isTerminal = tab.type === "terminal"; const isTerminal = tab.type === "terminal";
const isServer = tab.type === "server"; const isServer = tab.type === "server";
const isFileManager = tab.type === "file_manager"; const isFileManager = tab.type === "file_manager";
const isTunnel = tab.type === "tunnel";
const isDocker = tab.type === "docker";
const isSshManager = tab.type === "ssh_manager"; const isSshManager = tab.type === "ssh_manager";
const isAdmin = tab.type === "admin"; const isAdmin = tab.type === "admin";
const isUserProfile = tab.type === "user_profile"; const isUserProfile = tab.type === "user_profile";
const isSplittable = isTerminal || isServer || isFileManager; const isSplittable =
isTerminal || isServer || isFileManager || isTunnel || isDocker;
const disableSplit = !isSplittable; const disableSplit = !isSplittable;
const disableActivate = const disableActivate =
isSplit || isSplit ||
@@ -484,6 +487,8 @@ export function TopNavbar({
isTerminal || isTerminal ||
isServer || isServer ||
isFileManager || isFileManager ||
isTunnel ||
isDocker ||
isSshManager || isSshManager ||
isAdmin || isAdmin ||
isUserProfile isUserProfile
@@ -498,6 +503,8 @@ export function TopNavbar({
isTerminal || isTerminal ||
isServer || isServer ||
isFileManager || isFileManager ||
isTunnel ||
isDocker ||
isSshManager || isSshManager ||
isAdmin || isAdmin ||
isUserProfile isUserProfile

View File

@@ -8,6 +8,8 @@ import {
Server, Server,
FolderOpen, FolderOpen,
Pencil, Pencil,
ArrowDownUp,
Container,
} from "lucide-react"; } from "lucide-react";
import { import {
DropdownMenu, DropdownMenu,
@@ -63,6 +65,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
}, [host.statsConfig]); }, [host.statsConfig]);
const shouldShowStatus = statsConfig.statusCheckEnabled !== false; const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
const shouldShowMetrics = statsConfig.metricsEnabled !== false;
useEffect(() => { useEffect(() => {
if (!shouldShowStatus) { if (!shouldShowStatus) {
@@ -151,6 +154,7 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
side="right" side="right"
className="w-56 bg-dark-bg border-dark-border text-white" className="w-56 bg-dark-bg border-dark-border text-white"
> >
{shouldShowMetrics && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
addTab({ type: "server", title, hostConfig: host }) addTab({ type: "server", title, hostConfig: host })
@@ -158,8 +162,10 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
> >
<Server className="h-4 w-4" /> <Server className="h-4 w-4" />
<span className="flex-1">Open Server Details</span> <span className="flex-1">Open Server Stats</span>
</DropdownMenuItem> </DropdownMenuItem>
)}
{host.enableFileManager && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
addTab({ type: "file_manager", title, hostConfig: host }) addTab({ type: "file_manager", title, hostConfig: host })
@@ -169,6 +175,29 @@ export function Host({ host: initialHost }: HostProps): React.ReactElement {
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
<span className="flex-1">Open File Manager</span> <span className="flex-1">Open File Manager</span>
</DropdownMenuItem> </DropdownMenuItem>
)}
{host.enableTunnel && (
<DropdownMenuItem
onClick={() =>
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"
>
<ArrowDownUp className="h-4 w-4" />
<span className="flex-1">Open Tunnels</span>
</DropdownMenuItem>
)}
{host.enableDocker && (
<DropdownMenuItem
onClick={() =>
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"
>
<Container className="h-4 w-4" />
<span className="flex-1">Open Docker</span>
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={() => onClick={() =>
addTab({ addTab({

View File

@@ -10,6 +10,8 @@ import {
Server as ServerIcon, Server as ServerIcon,
Folder as FolderIcon, Folder as FolderIcon,
User as UserIcon, User as UserIcon,
ArrowDownUp as TunnelIcon,
Container as DockerIcon,
} from "lucide-react"; } from "lucide-react";
interface TabProps { interface TabProps {
@@ -119,10 +121,14 @@ export function Tab({
tabType === "terminal" || tabType === "terminal" ||
tabType === "server" || tabType === "server" ||
tabType === "file_manager" || tabType === "file_manager" ||
tabType === "tunnel" ||
tabType === "docker" ||
tabType === "user_profile" tabType === "user_profile"
) { ) {
const isServer = tabType === "server"; const isServer = tabType === "server";
const isFileManager = tabType === "file_manager"; const isFileManager = tabType === "file_manager";
const isTunnel = tabType === "tunnel";
const isDocker = tabType === "docker";
const isUserProfile = tabType === "user_profile"; const isUserProfile = tabType === "user_profile";
const displayTitle = const displayTitle =
@@ -131,6 +137,10 @@ export function Tab({
? t("nav.serverStats") ? t("nav.serverStats")
: isFileManager : isFileManager
? t("nav.fileManager") ? t("nav.fileManager")
: isTunnel
? t("nav.tunnels")
: isDocker
? t("nav.docker")
: isUserProfile : isUserProfile
? t("nav.userProfile") ? t("nav.userProfile")
: t("nav.terminal")); : t("nav.terminal"));
@@ -151,6 +161,10 @@ export function Tab({
<ServerIcon className="h-4 w-4 flex-shrink-0" /> <ServerIcon className="h-4 w-4 flex-shrink-0" />
) : isFileManager ? ( ) : isFileManager ? (
<FolderIcon className="h-4 w-4 flex-shrink-0" /> <FolderIcon className="h-4 w-4 flex-shrink-0" />
) : isTunnel ? (
<TunnelIcon className="h-4 w-4 flex-shrink-0" />
) : isDocker ? (
<DockerIcon className="h-4 w-4 flex-shrink-0" />
) : isUserProfile ? ( ) : isUserProfile ? (
<UserIcon className="h-4 w-4 flex-shrink-0" /> <UserIcon className="h-4 w-4 flex-shrink-0" />
) : ( ) : (

View File

@@ -76,6 +76,10 @@ export function TabProvider({ children }: TabProviderProps) {
? t("nav.serverStats") ? t("nav.serverStats")
: tabType === "file_manager" : tabType === "file_manager"
? t("nav.fileManager") ? t("nav.fileManager")
: tabType === "tunnel"
? t("nav.tunnels")
: tabType === "docker"
? t("nav.docker")
: t("nav.terminal"); : t("nav.terminal");
const baseTitle = (desiredTitle || defaultTitle).trim(); const baseTitle = (desiredTitle || defaultTitle).trim();
const match = baseTitle.match(/^(.*) \((\d+)\)$/); const match = baseTitle.match(/^(.*) \((\d+)\)$/);
@@ -137,7 +141,9 @@ export function TabProvider({ children }: TabProviderProps) {
const needsUniqueTitle = const needsUniqueTitle =
tabData.type === "terminal" || tabData.type === "terminal" ||
tabData.type === "server" || tabData.type === "server" ||
tabData.type === "file_manager"; tabData.type === "file_manager" ||
tabData.type === "tunnel" ||
tabData.type === "docker";
const effectiveTitle = needsUniqueTitle const effectiveTitle = needsUniqueTitle
? computeUniqueTitle(tabData.type, tabData.title) ? computeUniqueTitle(tabData.type, tabData.title)
: tabData.title || ""; : tabData.title || "";

View File

@@ -12,6 +12,8 @@ import {
Terminal as TerminalIcon, Terminal as TerminalIcon,
Server as ServerIcon, Server as ServerIcon,
Folder as FolderIcon, Folder as FolderIcon,
ArrowDownUp as TunnelIcon,
Container as DockerIcon,
Shield as AdminIcon, Shield as AdminIcon,
Network as SshManagerIcon, Network as SshManagerIcon,
User as UserIcon, User as UserIcon,
@@ -33,6 +35,10 @@ export function TabDropdown(): React.ReactElement {
return <ServerIcon className="h-4 w-4" />; return <ServerIcon className="h-4 w-4" />;
case "file_manager": case "file_manager":
return <FolderIcon className="h-4 w-4" />; return <FolderIcon className="h-4 w-4" />;
case "tunnel":
return <TunnelIcon className="h-4 w-4" />;
case "docker":
return <DockerIcon className="h-4 w-4" />;
case "user_profile": case "user_profile":
return <UserIcon className="h-4 w-4" />; return <UserIcon className="h-4 w-4" />;
case "ssh_manager": case "ssh_manager":
@@ -52,6 +58,10 @@ export function TabDropdown(): React.ReactElement {
return tab.title || t("nav.serverStats"); return tab.title || t("nav.serverStats");
case "file_manager": case "file_manager":
return tab.title || t("nav.fileManager"); 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": case "user_profile":
return tab.title || t("nav.userProfile"); return tab.title || t("nav.userProfile");
case "ssh_manager": case "ssh_manager":

View File

@@ -7,6 +7,10 @@ import type {
TunnelStatus, TunnelStatus,
FileManagerFile, FileManagerFile,
FileManagerShortcut, FileManagerShortcut,
DockerContainer,
DockerStats,
DockerLogOptions,
DockerValidation,
} from "../types/index.js"; } from "../types/index.js";
// ============================================================================ // ============================================================================
@@ -639,6 +643,9 @@ function initializeApiInstances() {
// RBAC API (port 30001) // RBAC API (port 30001)
rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC"); rbacApi = createApiInstance(getApiUrl("", 30001), "RBAC");
// Docker Management API (port 30007)
dockerApi = createApiInstance(getApiUrl("/docker", 30007), "DOCKER");
} }
// SSH Host Management API (port 30001) // SSH Host Management API (port 30001)
@@ -662,6 +669,9 @@ export let homepageApi: AxiosInstance;
// RBAC API (port 30001) // RBAC API (port 30001)
export let rbacApi: AxiosInstance; export let rbacApi: AxiosInstance;
// Docker Management API (port 30007)
export let dockerApi: AxiosInstance;
function initializeApp() { function initializeApp() {
if (isElectron()) { if (isElectron()) {
getServerConfig() getServerConfig()
@@ -904,6 +914,7 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
enableTerminal: Boolean(hostData.enableTerminal), enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel), enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager), enableFileManager: Boolean(hostData.enableFileManager),
enableDocker: Boolean(hostData.enableDocker),
defaultPath: hostData.defaultPath || "/", defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [], tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [], jumpHosts: hostData.jumpHosts || [],
@@ -913,6 +924,11 @@ export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
? hostData.statsConfig ? hostData.statsConfig
: JSON.stringify(hostData.statsConfig) : JSON.stringify(hostData.statsConfig)
: null, : null,
dockerConfig: hostData.dockerConfig
? typeof hostData.dockerConfig === "string"
? hostData.dockerConfig
: JSON.stringify(hostData.dockerConfig)
: null,
terminalConfig: hostData.terminalConfig || null, terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
}; };
@@ -970,6 +986,7 @@ export async function updateSSHHost(
enableTerminal: Boolean(hostData.enableTerminal), enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel), enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager), enableFileManager: Boolean(hostData.enableFileManager),
enableDocker: Boolean(hostData.enableDocker),
defaultPath: hostData.defaultPath || "/", defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [], tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [], jumpHosts: hostData.jumpHosts || [],
@@ -979,6 +996,11 @@ export async function updateSSHHost(
? hostData.statsConfig ? hostData.statsConfig
: JSON.stringify(hostData.statsConfig) : JSON.stringify(hostData.statsConfig)
: null, : null,
dockerConfig: hostData.dockerConfig
? typeof hostData.dockerConfig === "string"
? hostData.dockerConfig
: JSON.stringify(hostData.dockerConfig)
: null,
terminalConfig: hostData.terminalConfig || null, terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive), forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
}; };
@@ -3280,5 +3302,239 @@ export async function revokeHostAccess(
return response.data; return response.data;
} catch (error) { } catch (error) {
throw handleApiError(error, "revoke host access"); 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<DockerValidation> {
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<DockerContainer[]> {
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<DockerContainer> {
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<Blob> {
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<DockerStats> {
try {
const response = await dockerApi.get(
`/containers/${sessionId}/${containerId}/stats`,
);
return response.data;
} catch (error) {
throw handleApiError(error, "get container stats");
} }
} }