Compare commits
24 Commits
v0.3.0
...
feat/shell
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2daa0cf719 | ||
|
|
04a8575998 | ||
|
|
8d9f119e1c | ||
|
|
8efff60025 | ||
|
|
ec9bdf54ba | ||
|
|
0555e4c0dd | ||
|
|
08e0c82f4e | ||
|
|
e3fccca0fc | ||
|
|
7454799971 | ||
|
|
892b3ae5df | ||
|
|
bb52d5a077 | ||
|
|
1d8c8685f5 | ||
|
|
68981c98d5 | ||
|
|
ff1ce89ecb | ||
|
|
cde534735f | ||
|
|
5b45293b4d | ||
|
|
53b5074f35 | ||
|
|
aaa09b4745 | ||
|
|
24afce49a3 | ||
|
|
9d83697d45 | ||
|
|
c12c96cfb9 | ||
|
|
7a550bbd61 | ||
|
|
99b639e6d8 | ||
|
|
f0f22fde83 |
4
.github/release-drafter.yml
vendored
4
.github/release-drafter.yml
vendored
@@ -1,6 +1,6 @@
|
||||
# Template for release drafts
|
||||
name-template: 'v$NEXT_MINOR_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
|
||||
tag-template: 'v$NEXT_MINOR_VERSION'
|
||||
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
|
||||
tag-template: 'v$NEXT_PATCH_VERSION'
|
||||
|
||||
# Exclude PRs with this label from release notes
|
||||
exclude-labels:
|
||||
|
||||
256
package-lock.json
generated
256
package-lock.json
generated
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"@tanstack/react-query": "^5.87.4",
|
||||
"@tanstack/react-query": "^5.90.3",
|
||||
"@trpc/client": "^11.6.0",
|
||||
"@trpc/react-query": "^11.6.0",
|
||||
"@trpc/server": "^11.6.0",
|
||||
@@ -25,7 +25,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.545.0",
|
||||
"next": "^15.5.3",
|
||||
"next": "^15.5.5",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -44,12 +44,12 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.7.1",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
@@ -61,7 +61,7 @@
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.27.0",
|
||||
"typescript-eslint": "^8.46.1",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
},
|
||||
@@ -1797,9 +1797,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.4.tgz",
|
||||
"integrity": "sha512-27SQhYp5QryzIT5uO8hq99C69eLQ7qkzkDPsk3N+GuS2XgOgoYEeOav7Pf8Tn4drECOVDsDg8oj+/DVy8qQL2A==",
|
||||
"version": "15.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.5.tgz",
|
||||
"integrity": "sha512-2Zhvss36s/yL+YSxD5ZL5dz5pI6ki1OLxYlh6O77VJ68sBnlUrl5YqhBgCy7FkdMsp9RBeGFwpuDCdpJOqdKeQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -1813,9 +1813,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "15.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.4.tgz",
|
||||
"integrity": "sha512-nopqz+Ov6uvorej8ndRX6HlxCYWCO3AHLfKK2TYvxoSB2scETOcfm/HSS3piPqc3A+MUgyHoqE6je4wnkjfrOA==",
|
||||
"version": "15.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.5.tgz",
|
||||
"integrity": "sha512-lYExGHuFIHeOxf40mRLWoA84iY2sLELB23BV5FIDHhdJkN1LpRTPc1MDOawgTo5ifbM5dvAwnGuHyNm60G1+jw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1829,9 +1829,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "15.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.4.tgz",
|
||||
"integrity": "sha512-QOTCFq8b09ghfjRJKfb68kU9k2K+2wsC4A67psOiMn849K9ZXgCSRQr0oVHfmKnoqCbEmQWG1f2h1T2vtJJ9mA==",
|
||||
"version": "15.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.5.tgz",
|
||||
"integrity": "sha512-cacs/WQqa96IhqUm+7CY+z/0j9sW6X80KE07v3IAJuv+z0UNvJtKSlT/T1w1SpaQRa9l0wCYYZlRZUhUOvEVmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1845,9 +1845,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "15.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.4.tgz",
|
||||
"integrity": "sha512-eRD5zkts6jS3VfE/J0Kt1VxdFqTnMc3QgO5lFE5GKN3KDI/uUpSyK3CjQHmfEkYR4wCOl0R0XrsjpxfWEA++XA==",
|
||||
"version": "15.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.5.tgz",
|
||||
"integrity": "sha512-tLd90SvkRFik6LSfuYjcJEmwqcNEnVYVOyKTacSazya/SLlSwy/VYKsDE4GIzOBd+h3gW+FXqShc2XBavccHCg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1861,9 +1861,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "15.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.4.tgz",
|
||||
"integrity": "sha512-TOK7iTxmXFc45UrtKqWdZ1shfxuL4tnVAOuuJK4S88rX3oyVV4ZkLjtMT85wQkfBrOOvU55aLty+MV8xmcJR8A==",
|
||||
"version": "15.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.5.tgz",
|
||||
"integrity": "sha512-ekV76G2R/l3nkvylkfy9jBSYHeB4QcJ7LdDseT6INnn1p51bmDS1eGoSoq+RxfQ7B1wt+Qa0pIl5aqcx0GLpbw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1877,9 +1877,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "15.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.4.tgz",
|
||||
"integrity": "sha512-7HKolaj+481FSW/5lL0BcTkA4Ueam9SPYWyN/ib/WGAFZf0DGAN8frNpNZYFHtM4ZstrHZS3LY3vrwlIQfsiMA==",
|
||||
"version": "15.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.5.tgz",
|
||||
"integrity": "sha512-tI+sBu+3FmWtqlqD4xKJcj3KJtqbniLombKTE7/UWyyoHmOyAo3aZ7QcEHIOgInXOG1nt0rwh0KGmNbvSB0Djg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1893,9 +1893,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "15.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.4.tgz",
|
||||
"integrity": "sha512-nlQQ6nfgN0nCO/KuyEUwwOdwQIGjOs4WNMjEUtpIQJPR2NUfmGpW2wkJln1d4nJ7oUzd1g4GivH5GoEPBgfsdw==",
|
||||
"version": "15.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.5.tgz",
|
||||
"integrity": "sha512-kDRh+epN/ulroNJLr+toDjN+/JClY5L+OAWjOrrKCI0qcKvTw9GBx7CU/rdA2bgi4WpZN3l0rf/3+b8rduEwrQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1909,9 +1909,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "15.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.4.tgz",
|
||||
"integrity": "sha512-PcR2bN7FlM32XM6eumklmyWLLbu2vs+D7nJX8OAIoWy69Kef8mfiN4e8TUv2KohprwifdpFKPzIP1njuCjD0YA==",
|
||||
"version": "15.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.5.tgz",
|
||||
"integrity": "sha512-GDgdNPFFqiKjTrmfw01sMMRWhVN5wOCmFzPloxa7ksDfX6TZt62tAK986f0ZYqWpvDFqeBCLAzmgTURvtQBdgw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1925,9 +1925,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "15.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.4.tgz",
|
||||
"integrity": "sha512-1ur2tSHZj8Px/KMAthmuI9FMp/YFusMMGoRNJaRZMOlSkgvLjzosSdQI0cJAKogdHl3qXUQKL9MGaYvKwA7DXg==",
|
||||
"version": "15.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.5.tgz",
|
||||
"integrity": "sha512-5kE3oRJxc7M8RmcTANP8RGoJkaYlwIiDD92gSwCjJY0+j8w8Sl1lvxgQ3bxfHY2KkHFai9tpy/Qx1saWV8eaJQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2769,9 +2769,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.90.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.2.tgz",
|
||||
"integrity": "sha512-k/TcR3YalnzibscALLwxeiLUub6jN5EDLwKDiO7q5f4ICEoptJ+n9+7vcEFy5/x/i6Q+Lb/tXrsKCggf5uQJXQ==",
|
||||
"version": "5.90.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.3.tgz",
|
||||
"integrity": "sha512-HtPOnCwmx4dd35PfXU8jjkhwYrsHfuqgC8RCJIwWglmhIUIlzPP0ZcEkDAc+UtAWCiLm7T8rxeEfHZlz3hYMCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -2779,12 +2779,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.90.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.2.tgz",
|
||||
"integrity": "sha512-CLABiR+h5PYfOWr/z+vWFt5VsOA2ekQeRQBFSKlcoW6Ndx/f8rfyVmq4LbgOM4GG2qtxAxjLYLOpCNTYm4uKzw==",
|
||||
"version": "5.90.3",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.3.tgz",
|
||||
"integrity": "sha512-i/LRL6DtuhG6bjGzavIMIVuKKPWx2AnEBIsBfuMm3YoHne0a20nWmsatOCBcVSaT0/8/5YFjNkebHAPLVUSi0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.2"
|
||||
"@tanstack/query-core": "5.90.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -2991,11 +2991,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcryptjs": {
|
||||
"version": "2.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
|
||||
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-3.0.0.tgz",
|
||||
"integrity": "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==",
|
||||
"deprecated": "This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcryptjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/better-sqlite3": {
|
||||
"version": "7.6.13",
|
||||
@@ -3073,9 +3077,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.1.tgz",
|
||||
"integrity": "sha512-CmyhGZanP88uuC5GpWU9q+fI61j2SkhO3UGMUdfYRE6Bcy0ccyzn1Rqj9YAB/ZY4kOXmNf0ocah5GtphmLMP6Q==",
|
||||
"version": "24.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.2.tgz",
|
||||
"integrity": "sha512-/NbVmcGTP+lj5oa4yiYxxeBjRivKQ5Ns1eSZeB99ExsEQ6rX5XYU1Zy/gGxY/ilqtD4Etx9mKyrPxZRetiahhA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.14.0"
|
||||
@@ -3097,9 +3101,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.1.tgz",
|
||||
"integrity": "sha512-/EEvYBdT3BflCWvTMO7YkYBHVE9Ci6XdqZciZANQgKpaiDRGOLIlRo91jbTNRQjgPFWVaRxcYc0luVNFitz57A==",
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz",
|
||||
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
@@ -3131,17 +3135,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz",
|
||||
"integrity": "sha512-hA8gxBq4ukonVXPy0OKhiaUh/68D0E88GSmtC1iAEnGaieuDi38LhS7jdCHRLi6ErJBNDGCzvh5EnzdPwUc0DA==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.1.tgz",
|
||||
"integrity": "sha512-rUsLh8PXmBjdiPY+Emjz9NX2yHvhS11v0SR6xNJkm5GM1MO9ea/1GoDKlHHZGrOJclL/cZ2i/vRUYVtjRhrHVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.10.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.0",
|
||||
"@typescript-eslint/type-utils": "8.46.0",
|
||||
"@typescript-eslint/utils": "8.46.0",
|
||||
"@typescript-eslint/visitor-keys": "8.46.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.1",
|
||||
"@typescript-eslint/type-utils": "8.46.1",
|
||||
"@typescript-eslint/utils": "8.46.1",
|
||||
"@typescript-eslint/visitor-keys": "8.46.1",
|
||||
"graphemer": "^1.4.0",
|
||||
"ignore": "^7.0.0",
|
||||
"natural-compare": "^1.4.0",
|
||||
@@ -3155,7 +3159,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.46.0",
|
||||
"@typescript-eslint/parser": "^8.46.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
@@ -3171,16 +3175,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.0.tgz",
|
||||
"integrity": "sha512-n1H6IcDhmmUEG7TNVSspGmiHHutt7iVKtZwRppD7e04wha5MrkV1h3pti9xQLcCMt6YWsncpoT0HMjkH1FNwWQ==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz",
|
||||
"integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/typescript-estree": "8.46.0",
|
||||
"@typescript-eslint/visitor-keys": "8.46.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.1",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/typescript-estree": "8.46.1",
|
||||
"@typescript-eslint/visitor-keys": "8.46.1",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3196,14 +3200,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.0.tgz",
|
||||
"integrity": "sha512-OEhec0mH+U5Je2NZOeK1AbVCdm0ChyapAyTeXVIYTPXDJ3F07+cu87PPXcGoYqZ7M9YJVvFnfpGg1UmCIqM+QQ==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.1.tgz",
|
||||
"integrity": "sha512-FOIaFVMHzRskXr5J4Jp8lFVV0gz5ngv3RHmn+E4HYxSJ3DgDzU7fVI1/M7Ijh1zf6S7HIoaIOtln1H5y8V+9Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.46.0",
|
||||
"@typescript-eslint/types": "^8.46.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.46.1",
|
||||
"@typescript-eslint/types": "^8.46.1",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3218,14 +3222,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.0.tgz",
|
||||
"integrity": "sha512-lWETPa9XGcBes4jqAMYD9fW0j4n6hrPtTJwWDmtqgFO/4HF4jmdH/Q6wggTw5qIT5TXjKzbt7GsZUBnWoO3dqw==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.1.tgz",
|
||||
"integrity": "sha512-weL9Gg3/5F0pVQKiF8eOXFZp8emqWzZsOJuWRUNtHT+UNV2xSJegmpCNQHy37aEQIbToTq7RHKhWvOsmbM680A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/visitor-keys": "8.46.0"
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/visitor-keys": "8.46.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3236,9 +3240,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.0.tgz",
|
||||
"integrity": "sha512-WrYXKGAHY836/N7zoK/kzi6p8tXFhasHh8ocFL9VZSAkvH956gfeRfcnhs3xzRy8qQ/dq3q44v1jvQieMFg2cw==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.1.tgz",
|
||||
"integrity": "sha512-X88+J/CwFvlJB+mK09VFqx5FE4H5cXD+H/Bdza2aEWkSb8hnWIQorNcscRl4IEo1Cz9VI/+/r/jnGWkbWPx54g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3253,15 +3257,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.0.tgz",
|
||||
"integrity": "sha512-hy+lvYV1lZpVs2jRaEYvgCblZxUoJiPyCemwbQZ+NGulWkQRy0HRPYAoef/CNSzaLt+MLvMptZsHXHlkEilaeg==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.1.tgz",
|
||||
"integrity": "sha512-+BlmiHIiqufBxkVnOtFwjah/vrkF4MtKKvpXrKSPLCkCtAp8H01/VV43sfqA98Od7nJpDcFnkwgyfQbOG0AMvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/typescript-estree": "8.46.0",
|
||||
"@typescript-eslint/utils": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/typescript-estree": "8.46.1",
|
||||
"@typescript-eslint/utils": "8.46.1",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^2.1.0"
|
||||
},
|
||||
@@ -3278,9 +3282,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.0.tgz",
|
||||
"integrity": "sha512-bHGGJyVjSE4dJJIO5yyEWt/cHyNwga/zXGJbJJ8TiO01aVREK6gCTu3L+5wrkb1FbDkQ+TKjMNe9R/QQQP9+rA==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.1.tgz",
|
||||
"integrity": "sha512-C+soprGBHwWBdkDpbaRC4paGBrkIXxVlNohadL5o0kfhsXqOC6GYH2S/Obmig+I0HTDl8wMaRySwrfrXVP8/pQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3292,16 +3296,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.0.tgz",
|
||||
"integrity": "sha512-ekDCUfVpAKWJbRfm8T1YRrCot1KFxZn21oV76v5Fj4tr7ELyk84OS+ouvYdcDAwZL89WpEkEj2DKQ+qg//+ucg==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.1.tgz",
|
||||
"integrity": "sha512-uIifjT4s8cQKFQ8ZBXXyoUODtRoAd7F7+G8MKmtzj17+1UbdzFl52AzRyZRyKqPHhgzvXunnSckVu36flGy8cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.46.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/visitor-keys": "8.46.0",
|
||||
"@typescript-eslint/project-service": "8.46.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.46.1",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/visitor-keys": "8.46.1",
|
||||
"debug": "^4.3.4",
|
||||
"fast-glob": "^3.3.2",
|
||||
"is-glob": "^4.0.3",
|
||||
@@ -3377,9 +3381,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
|
||||
"version": "7.7.2",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
|
||||
"integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
@@ -3390,16 +3394,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.0.tgz",
|
||||
"integrity": "sha512-nD6yGWPj1xiOm4Gk0k6hLSZz2XkNXhuYmyIrOWcHoPuAhjT9i5bAG+xbWPgFeNR8HPHHtpNKdYUXJl/D3x7f5g==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.1.tgz",
|
||||
"integrity": "sha512-vkYUy6LdZS7q1v/Gxb2Zs7zziuXN0wxqsetJdeZdRe/f5dwJFglmuvZBfTUivCtjH725C1jWCDfpadadD95EDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/typescript-estree": "8.46.0"
|
||||
"@typescript-eslint/scope-manager": "8.46.1",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"@typescript-eslint/typescript-estree": "8.46.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -3414,13 +3418,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.0.tgz",
|
||||
"integrity": "sha512-FrvMpAK+hTbFy7vH5j1+tMYHMSKLE6RzluFJlkFNKD0p9YsUT75JlBSmr5so3QRzvMwU5/bIEdeNrxm8du8l3Q==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.1.tgz",
|
||||
"integrity": "sha512-ptkmIf2iDkNUjdeu2bQqhFPV1m6qTnFFjg7PPDjxKWaMaP0Z6I9l30Jr3g5QqbZGdw8YdYvLp+XnqnWWZOg/NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.46.0",
|
||||
"@typescript-eslint/types": "8.46.1",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -7937,12 +7941,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.5.4",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.4.tgz",
|
||||
"integrity": "sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==",
|
||||
"version": "15.5.5",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.5.tgz",
|
||||
"integrity": "sha512-OQVdBPtpBfq7HxFN0kOVb7rXXOSIkt5lTzDJDGRBcOyVvNRIWFauMqi1gIHd1pszq1542vMOGY0HP4CaiALfkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.4",
|
||||
"@next/env": "15.5.5",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -7955,14 +7959,14 @@
|
||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "15.5.4",
|
||||
"@next/swc-darwin-x64": "15.5.4",
|
||||
"@next/swc-linux-arm64-gnu": "15.5.4",
|
||||
"@next/swc-linux-arm64-musl": "15.5.4",
|
||||
"@next/swc-linux-x64-gnu": "15.5.4",
|
||||
"@next/swc-linux-x64-musl": "15.5.4",
|
||||
"@next/swc-win32-arm64-msvc": "15.5.4",
|
||||
"@next/swc-win32-x64-msvc": "15.5.4",
|
||||
"@next/swc-darwin-arm64": "15.5.5",
|
||||
"@next/swc-darwin-x64": "15.5.5",
|
||||
"@next/swc-linux-arm64-gnu": "15.5.5",
|
||||
"@next/swc-linux-arm64-musl": "15.5.5",
|
||||
"@next/swc-linux-x64-gnu": "15.5.5",
|
||||
"@next/swc-linux-x64-musl": "15.5.5",
|
||||
"@next/swc-win32-arm64-msvc": "15.5.5",
|
||||
"@next/swc-win32-x64-msvc": "15.5.5",
|
||||
"sharp": "^0.34.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -10426,16 +10430,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.46.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.0.tgz",
|
||||
"integrity": "sha512-6+ZrB6y2bT2DX3K+Qd9vn7OFOJR+xSLDj+Aw/N3zBwUt27uTw2sw2TE2+UcY1RiyBZkaGbTkVg9SSdPNUG6aUw==",
|
||||
"version": "8.46.1",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.1.tgz",
|
||||
"integrity": "sha512-VHgijW803JafdSsDO8I761r3SHrgk4T00IdyQ+/UsthtgPRsBWQLqoSxOolxTpxRKi1kGXK0bSz4CoAc9ObqJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.46.0",
|
||||
"@typescript-eslint/parser": "8.46.0",
|
||||
"@typescript-eslint/typescript-estree": "8.46.0",
|
||||
"@typescript-eslint/utils": "8.46.0"
|
||||
"@typescript-eslint/eslint-plugin": "8.46.1",
|
||||
"@typescript-eslint/parser": "8.46.1",
|
||||
"@typescript-eslint/typescript-estree": "8.46.1",
|
||||
"@typescript-eslint/utils": "8.46.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
|
||||
12
package.json
12
package.json
@@ -24,7 +24,7 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"@tanstack/react-query": "^5.87.4",
|
||||
"@tanstack/react-query": "^5.90.3",
|
||||
"@trpc/client": "^11.6.0",
|
||||
"@trpc/react-query": "^11.6.0",
|
||||
"@trpc/server": "^11.6.0",
|
||||
@@ -39,7 +39,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.545.0",
|
||||
"next": "^15.5.3",
|
||||
"next": "^15.5.5",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
@@ -58,12 +58,12 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^24.7.1",
|
||||
"@types/node": "^24.7.2",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
@@ -75,7 +75,7 @@
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.27.0",
|
||||
"typescript-eslint": "^8.46.1",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
SCRIPT_DIR="$(dirname "$0")"
|
||||
source "$SCRIPT_DIR/../core/build.func"
|
||||
# Copyright (c) 2021-2025 tteck
|
||||
# Author: tteck (tteckster)
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://www.debian.org/
|
||||
|
||||
APP="Debian"
|
||||
var_tags="${var_tags:-os}"
|
||||
var_cpu="${var_cpu:-1}"
|
||||
var_ram="${var_ram:-512}"
|
||||
var_disk="${var_disk:-2}"
|
||||
var_os="${var_os:-debian}"
|
||||
var_version="${var_version:-13}"
|
||||
var_unprivileged="${var_unprivileged:-1}"
|
||||
|
||||
header_info "$APP"
|
||||
variables
|
||||
color
|
||||
catch_errors
|
||||
|
||||
function update_script() {
|
||||
header_info
|
||||
check_container_storage
|
||||
check_container_resources
|
||||
if [[ ! -d /var ]]; then
|
||||
msg_error "No ${APP} Installation Found!"
|
||||
exit
|
||||
fi
|
||||
msg_info "Updating $APP LXC"
|
||||
$STD apt update
|
||||
$STD apt -y upgrade
|
||||
msg_ok "Updated $APP LXC"
|
||||
exit
|
||||
}
|
||||
|
||||
start
|
||||
build_container
|
||||
description
|
||||
|
||||
msg_ok "Completed Successfully!\n"
|
||||
echo -e "${CREATING}${GN}${APP} setup has been successfully initialized!${CL}"
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Copyright (c) 2021-2025 tteck
|
||||
# Author: tteck (tteckster)
|
||||
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
|
||||
# Source: https://www.debian.org/
|
||||
|
||||
source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||
color
|
||||
verb_ip6
|
||||
catch_errors
|
||||
setting_up_container
|
||||
network_check
|
||||
update_os
|
||||
|
||||
motd_ssh
|
||||
customize
|
||||
|
||||
msg_info "Cleaning up"
|
||||
$STD apt -y autoremove
|
||||
$STD apt -y autoclean
|
||||
$STD apt -y clean
|
||||
msg_ok "Cleaned"
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"name": "OpenWrt",
|
||||
"slug": "openwrt",
|
||||
"categories": [
|
||||
4,
|
||||
2
|
||||
],
|
||||
"date_created": "2024-05-02",
|
||||
"type": "vm",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"interface_port": null,
|
||||
"documentation": "https://openwrt.org/docs/start",
|
||||
"website": "https://openwrt.org/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/openwrt.webp",
|
||||
"config_path": "",
|
||||
"description": "OpenWrt is a powerful open-source firmware that can transform a wide range of networking devices into highly customizable and feature-rich routers, providing users with greater control and flexibility over their network infrastructure.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "vm/openwrt.sh",
|
||||
"resources": {
|
||||
"cpu": 1,
|
||||
"ram": 256,
|
||||
"hdd": 0.5,
|
||||
"os": null,
|
||||
"version": null
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": [
|
||||
{
|
||||
"text": "If you use VLANs (default LAN is set to VLAN 999), make sure the Proxmox Linux Bridge is configured as VLAN-aware, otherwise the VM may fail to start.",
|
||||
"type": "info"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "Petio",
|
||||
"slug": "petio",
|
||||
"categories": [
|
||||
13
|
||||
],
|
||||
"date_created": "2024-06-12",
|
||||
"type": "ct",
|
||||
"updateable": true,
|
||||
"privileged": false,
|
||||
"interface_port": 7777,
|
||||
"documentation": "https://docs.petio.tv/",
|
||||
"website": "https://petio.tv/",
|
||||
"logo": "https://cdn.jsdelivr.net/gh/selfhst/icons/webp/petio.webp",
|
||||
"config_path": "",
|
||||
"description": "Petio is a third party companion app available to Plex server owners to allow their users to request, review and discover content.",
|
||||
"install_methods": [
|
||||
{
|
||||
"type": "default",
|
||||
"script": "ct/petio.sh",
|
||||
"resources": {
|
||||
"cpu": 2,
|
||||
"ram": 1024,
|
||||
"hdd": 4,
|
||||
"os": "ubuntu",
|
||||
"version": "20.04"
|
||||
}
|
||||
}
|
||||
],
|
||||
"default_credentials": {
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"notes": []
|
||||
}
|
||||
144
server.js
144
server.js
@@ -51,6 +51,7 @@ const handle = app.getRequestHandler();
|
||||
* @property {string} [mode]
|
||||
* @property {ServerInfo} [server]
|
||||
* @property {boolean} [isUpdate]
|
||||
* @property {boolean} [isShell]
|
||||
* @property {string} [containerId]
|
||||
*/
|
||||
|
||||
@@ -207,13 +208,15 @@ class ScriptExecutionHandler {
|
||||
* @param {WebSocketMessage} message
|
||||
*/
|
||||
async handleMessage(ws, message) {
|
||||
const { action, scriptPath, executionId, input, mode, server, isUpdate, containerId } = message;
|
||||
const { action, scriptPath, executionId, input, mode, server, isUpdate, isShell, containerId } = message;
|
||||
|
||||
switch (action) {
|
||||
case 'start':
|
||||
if (scriptPath && executionId) {
|
||||
if (isUpdate && containerId) {
|
||||
await this.startUpdateExecution(ws, containerId, executionId, mode, server);
|
||||
} else if (isShell && containerId) {
|
||||
await this.startShellExecution(ws, containerId, executionId, mode, server);
|
||||
} else {
|
||||
await this.startScriptExecution(ws, scriptPath, executionId, mode, server);
|
||||
}
|
||||
@@ -709,6 +712,145 @@ class ScriptExecutionHandler {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start shell execution
|
||||
* @param {ExtendedWebSocket} ws
|
||||
* @param {string} containerId
|
||||
* @param {string} executionId
|
||||
* @param {string} mode
|
||||
* @param {ServerInfo|null} server
|
||||
*/
|
||||
async startShellExecution(ws, containerId, executionId, mode = 'local', server = null) {
|
||||
try {
|
||||
|
||||
// Send start message
|
||||
this.sendMessage(ws, {
|
||||
type: 'start',
|
||||
data: `Starting shell session for container ${containerId}...`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
if (mode === 'ssh' && server) {
|
||||
await this.startSSHShellExecution(ws, containerId, executionId, server);
|
||||
} else {
|
||||
await this.startLocalShellExecution(ws, containerId, executionId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `Failed to start shell: ${error instanceof Error ? error.message : String(error)}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start local shell execution
|
||||
* @param {ExtendedWebSocket} ws
|
||||
* @param {string} containerId
|
||||
* @param {string} executionId
|
||||
*/
|
||||
async startLocalShellExecution(ws, containerId, executionId) {
|
||||
const { spawn } = await import('node-pty');
|
||||
|
||||
// Create a shell process that will run pct enter
|
||||
const childProcess = spawn('bash', ['-c', `pct enter ${containerId}`], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: process.cwd(),
|
||||
env: process.env
|
||||
});
|
||||
|
||||
// Store the execution
|
||||
this.activeExecutions.set(executionId, {
|
||||
process: childProcess,
|
||||
ws
|
||||
});
|
||||
|
||||
// Handle pty data
|
||||
childProcess.onData((data) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: data.toString(),
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// Note: No automatic command is sent - user can type commands interactively
|
||||
|
||||
// Handle process exit
|
||||
childProcess.onExit((e) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'end',
|
||||
data: `Shell session ended with exit code: ${e.exitCode}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
this.activeExecutions.delete(executionId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start SSH shell execution
|
||||
* @param {ExtendedWebSocket} ws
|
||||
* @param {string} containerId
|
||||
* @param {string} executionId
|
||||
* @param {ServerInfo} server
|
||||
*/
|
||||
async startSSHShellExecution(ws, containerId, executionId, server) {
|
||||
const sshService = getSSHExecutionService();
|
||||
|
||||
try {
|
||||
const execution = await sshService.executeCommand(
|
||||
server,
|
||||
`pct enter ${containerId}`,
|
||||
/** @param {string} data */
|
||||
(data) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'output',
|
||||
data: data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
},
|
||||
/** @param {string} error */
|
||||
(error) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: error,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
},
|
||||
/** @param {number} code */
|
||||
(code) => {
|
||||
this.sendMessage(ws, {
|
||||
type: 'end',
|
||||
data: `Shell session ended with exit code: ${code}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
this.activeExecutions.delete(executionId);
|
||||
}
|
||||
);
|
||||
|
||||
// Store the execution
|
||||
this.activeExecutions.set(executionId, {
|
||||
process: /** @type {any} */ (execution).process,
|
||||
ws
|
||||
});
|
||||
|
||||
// Note: No automatic command is sent - user can type commands interactively
|
||||
|
||||
} catch (error) {
|
||||
this.sendMessage(ws, {
|
||||
type: 'error',
|
||||
data: `SSH shell execution failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TerminalHandler removed - not used by current application
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
|
||||
interface CategorySidebarProps {
|
||||
categories: string[];
|
||||
@@ -201,9 +202,12 @@ export function CategorySidebar({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
{!isCollapsed && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Categories</h3>
|
||||
<p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">Categories</h3>
|
||||
<p className="text-sm text-muted-foreground">{totalScripts} Total scripts</p>
|
||||
</div>
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with categories" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
|
||||
111
src/app/_components/ConfirmationModal.tsx
Normal file
111
src/app/_components/ConfirmationModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { AlertTriangle, Info } from 'lucide-react';
|
||||
|
||||
interface ConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
variant: 'simple' | 'danger';
|
||||
confirmText?: string; // What the user must type for danger variant
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
}
|
||||
|
||||
export function ConfirmationModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
variant,
|
||||
confirmText,
|
||||
confirmButtonText = 'Confirm',
|
||||
cancelButtonText = 'Cancel'
|
||||
}: ConfirmationModalProps) {
|
||||
const [typedText, setTypedText] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const isDanger = variant === 'danger';
|
||||
const isConfirmEnabled = isDanger ? typedText === confirmText : true;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (isConfirmEnabled) {
|
||||
onConfirm();
|
||||
setTypedText(''); // Reset for next time
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
onClose();
|
||||
setTypedText(''); // Reset when closing
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
{isDanger ? (
|
||||
<AlertTriangle className="h-8 w-8 text-red-600" />
|
||||
) : (
|
||||
<Info className="h-8 w-8 text-blue-600" />
|
||||
)}
|
||||
<h2 className="text-2xl font-bold text-card-foreground">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
{message}
|
||||
</p>
|
||||
|
||||
{/* Type-to-confirm input for danger variant */}
|
||||
{isDanger && confirmText && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Type <code className="bg-muted px-2 py-1 rounded text-sm">{confirmText}</code> to confirm:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={typedText}
|
||||
onChange={(e) => setTypedText(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder={`Type "${confirmText}" here`}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row justify-end gap-3">
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{cancelButtonText}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!isConfirmEnabled}
|
||||
variant={isDanger ? "destructive" : "default"}
|
||||
size="default"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{confirmButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/app/_components/ContextualHelpIcon.tsx
Normal file
46
src/app/_components/ContextualHelpIcon.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { HelpModal } from './HelpModal';
|
||||
import { Button } from './ui/button';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
|
||||
interface ContextualHelpIconProps {
|
||||
section: string;
|
||||
className?: string;
|
||||
size?: 'sm' | 'default';
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
export function ContextualHelpIcon({
|
||||
section,
|
||||
className = '',
|
||||
size = 'sm',
|
||||
tooltip = 'Help'
|
||||
}: ContextualHelpIconProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const sizeClasses = size === 'sm'
|
||||
? 'h-7 w-7 p-1.5'
|
||||
: 'h-9 w-9 p-2';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={`${sizeClasses} text-muted-foreground hover:text-foreground hover:bg-muted ${className}`}
|
||||
title={tooltip}
|
||||
>
|
||||
<HelpCircle className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<HelpModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
initialSection={section}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getCtScripts.useQuery();
|
||||
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
||||
{ slug: selectedSlug ?? '' },
|
||||
{ enabled: !!selectedSlug }
|
||||
@@ -385,7 +385,7 @@ export function DownloadedScriptsTab({ onInstallScript }: DownloadedScriptsTabPr
|
||||
);
|
||||
}
|
||||
|
||||
if (!downloadedScripts || downloadedScripts.length === 0) {
|
||||
if (!downloadedScripts?.length) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-muted-foreground">
|
||||
|
||||
87
src/app/_components/ErrorModal.tsx
Normal file
87
src/app/_components/ErrorModal.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { AlertCircle, CheckCircle } from 'lucide-react';
|
||||
|
||||
interface ErrorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
type?: 'error' | 'success';
|
||||
}
|
||||
|
||||
export function ErrorModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
details,
|
||||
type = 'error'
|
||||
}: ErrorModalProps) {
|
||||
// Auto-close after 10 seconds
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, 10000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-lg w-full border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center p-6 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
{type === 'success' ? (
|
||||
<CheckCircle className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-8 w-8 text-red-600 dark:text-red-400" />
|
||||
)}
|
||||
<h2 className="text-xl font-semibold text-foreground">{title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-foreground mb-4">{message}</p>
|
||||
{details && (
|
||||
<div className={`rounded-lg p-3 ${
|
||||
type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800'
|
||||
}`}>
|
||||
<p className={`text-xs font-medium mb-1 ${
|
||||
type === 'success'
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: 'text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{type === 'success' ? 'Details:' : 'Error Details:'}
|
||||
</p>
|
||||
<pre className={`text-xs whitespace-pre-wrap break-words ${
|
||||
type === 'success'
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-red-700 dark:text-red-300'
|
||||
}`}>
|
||||
{details}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-border">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button } from "./ui/button";
|
||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
|
||||
|
||||
export interface FilterState {
|
||||
@@ -104,6 +105,14 @@ export function FilterBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Header */}
|
||||
{!isLoadingFilters && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="relative max-w-md w-full">
|
||||
|
||||
64
src/app/_components/Footer.tsx
Normal file
64
src/app/_components/Footer.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { ExternalLink, FileText } from 'lucide-react';
|
||||
|
||||
interface FooterProps {
|
||||
onOpenReleaseNotes: () => void;
|
||||
}
|
||||
|
||||
export function Footer({ onOpenReleaseNotes }: FooterProps) {
|
||||
const { data: versionData } = api.version.getCurrentVersion.useQuery();
|
||||
|
||||
return (
|
||||
<footer className="sticky bottom-0 mt-auto border-t border-border bg-muted/30 py-3 backdrop-blur-sm">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>© 2024 PVE Scripts Local</span>
|
||||
{versionData?.success && versionData.version && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenReleaseNotes}
|
||||
className="h-auto p-1 text-xs hover:text-foreground"
|
||||
>
|
||||
v{versionData.version}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onOpenReleaseNotes}
|
||||
className="h-auto p-2 text-xs hover:text-foreground"
|
||||
>
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
Release Notes
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="h-auto p-2 text-xs hover:text-foreground"
|
||||
>
|
||||
<a
|
||||
href="https://github.com/community-scripts/ProxmoxVE-Local"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
GitHub
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Toggle } from './ui/toggle';
|
||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
|
||||
interface GeneralSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -280,7 +281,10 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
|
||||
<ContextualHelpIcon section="general-settings" tooltip="Help with General Settings" />
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
|
||||
40
src/app/_components/HelpButton.tsx
Normal file
40
src/app/_components/HelpButton.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { HelpModal } from './HelpModal';
|
||||
import { Button } from './ui/button';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
|
||||
interface HelpButtonProps {
|
||||
initialSection?: string;
|
||||
}
|
||||
|
||||
export function HelpButton({ initialSection }: HelpButtonProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
Need help?
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="inline-flex items-center"
|
||||
title="Open Help"
|
||||
>
|
||||
<HelpCircle className="w-5 h-5 mr-2" />
|
||||
Help
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<HelpModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
initialSection={initialSection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
515
src/app/_components/HelpModal.tsx
Normal file
515
src/app/_components/HelpModal.tsx
Normal file
@@ -0,0 +1,515 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { HelpCircle, Server, Settings, RefreshCw, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react';
|
||||
|
||||
interface HelpModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialSection?: string;
|
||||
}
|
||||
|
||||
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'update-system';
|
||||
|
||||
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
|
||||
const [activeSection, setActiveSection] = useState<HelpSection>(initialSection as HelpSection);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sections = [
|
||||
{ id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server },
|
||||
{ id: 'general-settings' as HelpSection, label: 'General Settings', icon: Settings },
|
||||
{ id: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw },
|
||||
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
|
||||
{ id: 'downloaded-scripts' as HelpSection, label: 'Downloaded Scripts', icon: HardDrive },
|
||||
{ id: 'installed-scripts' as HelpSection, label: 'Installed Scripts', icon: FolderOpen },
|
||||
{ id: 'update-system' as HelpSection, label: 'Update System', icon: Download },
|
||||
];
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeSection) {
|
||||
case 'server-settings':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">Server Settings</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Manage your Proxmox VE servers and configure connection settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Adding PVE Servers</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Server Name:</strong> A friendly name to identify your server</li>
|
||||
<li>• <strong>IP Address:</strong> The IP address or hostname of your PVE server</li>
|
||||
<li>• <strong>Username:</strong> PVE user account (usually root or a dedicated user)</li>
|
||||
<li>• <strong>SSH Port:</strong> Default is 22, change if your server uses a different port</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Authentication Types</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Password:</strong> Use username and password authentication</li>
|
||||
<li>• <strong>SSH Key:</strong> Use SSH key pair for secure authentication</li>
|
||||
<li>• <strong>Both:</strong> Try SSH key first, fallback to password if needed</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Server Color Coding</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Assign colors to servers for visual distinction throughout the application.
|
||||
This helps identify which server you're working with when managing scripts.
|
||||
This needs to be enabled in the General Settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'general-settings':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">General Settings</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Configure application preferences and behavior.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Save Filters</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
When enabled, your script filter preferences (search terms, categories, sorting)
|
||||
will be automatically saved and restored when you return to the application.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Search queries are preserved</li>
|
||||
<li>• Selected script types are remembered</li>
|
||||
<li>• Sort preferences are maintained</li>
|
||||
<li>• Category selections are saved</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Server Color Coding</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enable visual color coding for servers throughout the application.
|
||||
This makes it easier to identify which server you're working with.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">GitHub Integration</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Add a GitHub Personal Access Token to increase API rate limits and improve performance.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Bypasses GitHub's rate limiting for unauthenticated requests</li>
|
||||
<li>• Improves script loading and syncing performance</li>
|
||||
<li>• Token is stored securely and only used for API calls</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Authentication</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Secure your application with username and password authentication.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Set up username and password for app access</li>
|
||||
<li>• Enable/disable authentication as needed</li>
|
||||
<li>• Credentials are stored securely</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'sync-button':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">Sync Button</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Synchronize script metadata from the ProxmoxVE GitHub repository.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">What Does Syncing Do?</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Updates Script Metadata:</strong> Downloads the latest script information (JSON files)</li>
|
||||
<li>• <strong>Refreshes Available Scripts:</strong> Updates the list of scripts you can download</li>
|
||||
<li>• <strong>Updates Categories:</strong> Refreshes script categories and organization</li>
|
||||
<li>• <strong>Checks for Updates:</strong> Identifies which downloaded scripts have newer versions</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Important Notes</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Metadata Only:</strong> Syncing only updates script information, not the actual script files</li>
|
||||
<li>• <strong>No Downloads:</strong> Script files are downloaded separately when you choose to install them</li>
|
||||
<li>• <strong>Last Sync Time:</strong> Shows when the last successful sync occurred</li>
|
||||
<li>• <strong>Rate Limits:</strong> GitHub API limits may apply without a personal access token</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">When to Sync</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• When you want to see the latest available scripts</li>
|
||||
<li>• To check for updates to your downloaded scripts</li>
|
||||
<li>• If you notice scripts are missing or outdated</li>
|
||||
<li>• After the ProxmoxVE repository has been updated</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'available-scripts':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">Available Scripts</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Browse and discover scripts from the ProxmoxVE repository.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Browsing Scripts</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Category Sidebar:</strong> Filter scripts by category (Storage, Network, Security, etc.)</li>
|
||||
<li>• <strong>Search:</strong> Find scripts by name or description</li>
|
||||
<li>• <strong>View Modes:</strong> Switch between card and list view</li>
|
||||
<li>• <strong>Sorting:</strong> Sort by name or creation date</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Filtering Options</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Script Types:</strong> Filter by CT (Container) or other script types</li>
|
||||
<li>• <strong>Update Status:</strong> Show only scripts with available updates</li>
|
||||
<li>• <strong>Search Query:</strong> Search within script names and descriptions</li>
|
||||
<li>• <strong>Categories:</strong> Filter by specific script categories</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Script Actions</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>View Details:</strong> Click on a script to see full information and documentation</li>
|
||||
<li>• <strong>Download:</strong> Download script files to your local system</li>
|
||||
<li>• <strong>Install:</strong> Run scripts directly on your PVE servers</li>
|
||||
<li>• <strong>Preview:</strong> View script content before downloading</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'downloaded-scripts':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">Downloaded Scripts</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Manage scripts that have been downloaded to your local system.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">What Are Downloaded Scripts?</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
These are scripts that you've downloaded from the repository and are stored locally on your system.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Script files are stored in your local scripts directory</li>
|
||||
<li>• You can run these scripts on your PVE servers</li>
|
||||
<li>• Scripts can be updated when newer versions are available</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Update Detection</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
The system automatically checks if newer versions of your downloaded scripts are available.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Scripts with updates available are marked with an update indicator</li>
|
||||
<li>• You can filter to show only scripts with available updates</li>
|
||||
<li>• Update detection happens when you sync with the repository</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Managing Downloaded Scripts</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Update Scripts:</strong> Download the latest version of a script</li>
|
||||
<li>• <strong>View Details:</strong> See script information and documentation</li>
|
||||
<li>• <strong>Install/Run:</strong> Execute scripts on your PVE servers</li>
|
||||
<li>• <strong>Filter & Search:</strong> Use the same filtering options as Available Scripts</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'installed-scripts':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">Installed Scripts</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Track and manage scripts that are installed on your PVE servers.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg bg-muted/50 border-primary/20">
|
||||
<h4 className="font-medium text-foreground mb-2 flex items-center gap-2">
|
||||
<Search className="w-4 h-4" />
|
||||
Auto-Detection (Primary Feature)
|
||||
</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
The system can automatically detect LXC containers that have community-script tags on your PVE servers.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Automatic Discovery:</strong> Scans your PVE servers for containers with community-script tags</li>
|
||||
<li>• <strong>Container Detection:</strong> Identifies LXC containers running Proxmox helper scripts</li>
|
||||
<li>• <strong>Server Association:</strong> Links detected scripts to the specific PVE server</li>
|
||||
<li>• <strong>Bulk Import:</strong> Automatically creates records for all detected scripts</li>
|
||||
</ul>
|
||||
<div className="mt-3 p-3 bg-primary/10 rounded-lg border border-primary/20">
|
||||
<p className="text-sm font-medium text-primary">How Auto-Detection Works:</p>
|
||||
<ol className="text-sm text-muted-foreground mt-1 space-y-1">
|
||||
<li>1. Connects to your configured PVE servers</li>
|
||||
<li>2. Scans LXC container configurations</li>
|
||||
<li>3. Looks for containers with community-script tags</li>
|
||||
<li>4. Creates installed script records automatically</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Manual Script Management</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Add Scripts Manually:</strong> Create records for scripts not auto-detected</li>
|
||||
<li>• <strong>Edit Script Details:</strong> Update script names and container IDs</li>
|
||||
<li>• <strong>Delete Scripts:</strong> Remove scripts from tracking</li>
|
||||
<li>• <strong>Bulk Operations:</strong> Clean up old or invalid script records</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Script Tracking Features</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Installation Status:</strong> Track success, failure, or in-progress installations</li>
|
||||
<li>• <strong>Server Association:</strong> Know which server each script is installed on</li>
|
||||
<li>• <strong>Container ID:</strong> Link scripts to specific LXC containers</li>
|
||||
<li>• <strong>Execution Logs:</strong> View output and logs from script installations</li>
|
||||
<li>• <strong>Filtering:</strong> Filter by server, status, or search terms</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Managing Installed Scripts</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>View All Scripts:</strong> See all tracked scripts across all servers</li>
|
||||
<li>• <strong>Filter by Server:</strong> Show scripts for a specific PVE server</li>
|
||||
<li>• <strong>Filter by Status:</strong> Show successful, failed, or in-progress installations</li>
|
||||
<li>• <strong>Sort Options:</strong> Sort by name, container ID, server, status, or date</li>
|
||||
<li>• <strong>Update Scripts:</strong> Re-run or update existing script installations</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-accent/50 dark:bg-accent/20">
|
||||
<h4 className="font-medium text-foreground mb-2">Container Control (NEW)</h4>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Directly control LXC containers from the installed scripts page via SSH.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Start/Stop Button:</strong> Control container state with <code>pct start/stop <ID></code></li>
|
||||
<li>• <strong>Container Status:</strong> Real-time status indicator (running/stopped/unknown)</li>
|
||||
<li>• <strong>Destroy Button:</strong> Permanently remove LXC container with <code>pct destroy <ID></code></li>
|
||||
<li>• <strong>Confirmation Modals:</strong> Simple OK/Cancel for start/stop, type container ID to confirm destroy</li>
|
||||
<li>• <strong>SSH Execution:</strong> All commands executed remotely via configured SSH connections</li>
|
||||
</ul>
|
||||
<div className="mt-3 p-3 bg-muted/30 dark:bg-muted/20 rounded-lg border border-border">
|
||||
<p className="text-sm font-medium text-foreground">⚠️ Safety Features:</p>
|
||||
<ul className="text-sm text-muted-foreground mt-1 space-y-1">
|
||||
<li>• Start/Stop actions require simple confirmation</li>
|
||||
<li>• Destroy action requires typing the container ID to confirm</li>
|
||||
<li>• All actions show loading states and error handling</li>
|
||||
<li>• Only works with SSH scripts that have valid container IDs</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'update-system':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">Update System</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Keep your PVE Scripts Management application up to date with the latest features and improvements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">What Does Updating Do?</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Downloads Latest Version:</strong> Fetches the newest release from the GitHub repository</li>
|
||||
<li>• <strong>Updates Application Files:</strong> Replaces current files with the latest version</li>
|
||||
<li>• <strong>Installs Dependencies:</strong> Updates Node.js packages and dependencies</li>
|
||||
<li>• <strong>Rebuilds Application:</strong> Compiles the application with latest changes</li>
|
||||
<li>• <strong>Restarts Server:</strong> Automatically restarts the application server</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">How to Update</h4>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h5 className="font-medium text-foreground mb-2">Automatic Update (Recommended)</h5>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Click the "Update Now" button when an update is available</li>
|
||||
<li>• The system will handle everything automatically</li>
|
||||
<li>• You'll see a progress overlay with update logs</li>
|
||||
<li>• The page will reload automatically when complete</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h5 className="font-medium text-foreground mb-2">Manual Update (Advanced)</h5>
|
||||
<p className="text-sm text-muted-foreground mb-2">If automatic update fails, you can update manually:</p>
|
||||
<div className="bg-muted p-3 rounded-lg font-mono text-sm">
|
||||
<div className="text-muted-foreground"># Navigate to the application directory</div>
|
||||
<div>cd $PVESCRIPTLOCAL_DIR</div>
|
||||
<div className="text-muted-foreground"># Pull latest changes</div>
|
||||
<div>git pull</div>
|
||||
<div className="text-muted-foreground"># Install dependencies</div>
|
||||
<div>npm install</div>
|
||||
<div className="text-muted-foreground"># Build the application</div>
|
||||
<div>npm run build</div>
|
||||
<div className="text-muted-foreground"># Start the application</div>
|
||||
<div>npm start</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Update Process</h4>
|
||||
<ol className="text-sm text-muted-foreground space-y-2">
|
||||
<li><strong>1. Check for Updates:</strong> System automatically checks GitHub for new releases</li>
|
||||
<li><strong>2. Download Update:</strong> Downloads the latest release files</li>
|
||||
<li><strong>3. Backup Current Version:</strong> Creates backup of current installation</li>
|
||||
<li><strong>4. Install New Version:</strong> Replaces files and updates dependencies</li>
|
||||
<li><strong>5. Build Application:</strong> Compiles the updated code</li>
|
||||
<li><strong>6. Restart Server:</strong> Stops old server and starts new version</li>
|
||||
<li><strong>7. Reload Page:</strong> Automatically refreshes the browser</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Release Notes</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Click the external link icon next to the update button to view detailed release notes on GitHub.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• See what's new in each version</li>
|
||||
<li>• Read about bug fixes and improvements</li>
|
||||
<li>• Check for any breaking changes</li>
|
||||
<li>• View installation requirements</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-muted/50">
|
||||
<h4 className="font-medium text-foreground mb-2">Important Notes</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Backup:</strong> Your data and settings are preserved during updates</li>
|
||||
<li>• <strong>Downtime:</strong> Brief downtime occurs during the update process</li>
|
||||
<li>• <strong>Compatibility:</strong> Updates maintain backward compatibility with your data</li>
|
||||
<li>• <strong>Rollback:</strong> If issues occur, you can manually revert to previous version</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-2 sm:p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground flex items-center gap-2">
|
||||
<HelpCircle className="w-6 h-6" />
|
||||
Help & Documentation
|
||||
</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(95vh-120px)] sm:h-[calc(90vh-140px)]">
|
||||
{/* Sidebar Navigation */}
|
||||
<div className="w-64 border-r border-border bg-muted/30 overflow-y-auto">
|
||||
<nav className="p-4 space-y-2">
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<Button
|
||||
key={section.id}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
variant={activeSection === section.id ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="w-full justify-start gap-2 text-left"
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{section.label}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="p-4 sm:p-6">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Terminal } from './Terminal';
|
||||
import { StatusBadge } from './Badge';
|
||||
import { Button } from './ui/button';
|
||||
import { ScriptInstallationCard } from './ScriptInstallationCard';
|
||||
import { ConfirmationModal } from './ConfirmationModal';
|
||||
import { ErrorModal } from './ErrorModal';
|
||||
import { getContrastColor } from '../../lib/colorUtils';
|
||||
|
||||
interface InstalledScript {
|
||||
@@ -18,19 +20,26 @@ interface InstalledScript {
|
||||
server_ip: string | null;
|
||||
server_user: string | null;
|
||||
server_password: string | null;
|
||||
server_auth_type: string | null;
|
||||
server_ssh_key: string | null;
|
||||
server_ssh_key_passphrase: string | null;
|
||||
server_ssh_port: number | null;
|
||||
server_color: string | null;
|
||||
installation_date: string;
|
||||
status: 'in_progress' | 'success' | 'failed';
|
||||
output_log: string | null;
|
||||
execution_mode: 'local' | 'ssh';
|
||||
container_status?: 'running' | 'stopped' | 'unknown';
|
||||
}
|
||||
|
||||
export function InstalledScriptsTab() {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'success' | 'failed' | 'in_progress'>('all');
|
||||
const [serverFilter, setServerFilter] = useState<string>('all');
|
||||
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('script_name');
|
||||
const [sortField, setSortField] = useState<'script_name' | 'container_id' | 'server_name' | 'status' | 'installation_date'>('server_name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [updatingScript, setUpdatingScript] = useState<{ id: number; containerId: string; server?: any } | null>(null);
|
||||
const [openingShell, setOpeningShell] = useState<{ id: number; containerId: string; server?: any } | null>(null);
|
||||
const [editingScriptId, setEditingScriptId] = useState<number | null>(null);
|
||||
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
@@ -41,6 +50,31 @@ export function InstalledScriptsTab() {
|
||||
const [cleanupStatus, setCleanupStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
|
||||
const cleanupRunRef = useRef(false);
|
||||
|
||||
// Container control state
|
||||
const [containerStatuses, setContainerStatuses] = useState<Map<number, 'running' | 'stopped' | 'unknown'>>(new Map());
|
||||
const [confirmationModal, setConfirmationModal] = useState<{
|
||||
isOpen: boolean;
|
||||
variant: 'simple' | 'danger';
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
confirmButtonText?: string;
|
||||
cancelButtonText?: string;
|
||||
onConfirm: () => void;
|
||||
} | null>(null);
|
||||
const [controllingScriptId, setControllingScriptId] = useState<number | null>(null);
|
||||
const scriptsRef = useRef<InstalledScript[]>([]);
|
||||
const statusCheckTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Error modal state
|
||||
const [errorModal, setErrorModal] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
type?: 'error' | 'success';
|
||||
} | null>(null);
|
||||
|
||||
// Fetch installed scripts
|
||||
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
const { data: statsData } = api.installedScripts.getInstallationStats.useQuery();
|
||||
@@ -80,7 +114,6 @@ export function InstalledScriptsTab() {
|
||||
// Auto-detect LXC containers mutation
|
||||
const autoDetectMutation = api.installedScripts.autoDetectLXCContainers.useMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('Auto-detect success:', data);
|
||||
void refetchScripts();
|
||||
setShowAutoDetectForm(false);
|
||||
setAutoDetectServerId('');
|
||||
@@ -114,10 +147,42 @@ export function InstalledScriptsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Get container statuses mutation
|
||||
const containerStatusMutation = api.installedScripts.getContainerStatuses.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.success) {
|
||||
|
||||
// Map container IDs to script IDs
|
||||
const currentScripts = scriptsRef.current;
|
||||
const statusMap = new Map<number, 'running' | 'stopped' | 'unknown'>();
|
||||
|
||||
// For each script, find its container status
|
||||
currentScripts.forEach(script => {
|
||||
if (script.container_id && data.statusMap) {
|
||||
const containerStatus = (data.statusMap as Record<string, 'running' | 'stopped' | 'unknown'>)[script.container_id];
|
||||
if (containerStatus) {
|
||||
statusMap.set(script.id, containerStatus);
|
||||
} else {
|
||||
statusMap.set(script.id, 'unknown');
|
||||
}
|
||||
} else {
|
||||
statusMap.set(script.id, 'unknown');
|
||||
}
|
||||
});
|
||||
|
||||
setContainerStatuses(statusMap);
|
||||
} else {
|
||||
console.error('Container status fetch failed:', data.error);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error fetching container statuses:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup orphaned scripts mutation
|
||||
const cleanupMutation = api.installedScripts.cleanupOrphanedScripts.useMutation({
|
||||
onSuccess: (data) => {
|
||||
console.log('Cleanup success:', data);
|
||||
void refetchScripts();
|
||||
|
||||
if (data.deletedCount > 0) {
|
||||
@@ -145,21 +210,175 @@ export function InstalledScriptsTab() {
|
||||
}
|
||||
});
|
||||
|
||||
// Container control mutations
|
||||
// Note: getStatusMutation removed - using direct API calls instead
|
||||
|
||||
const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? [];
|
||||
const controlContainerMutation = api.installedScripts.controlContainer.useMutation({
|
||||
onSuccess: (data, variables) => {
|
||||
setControllingScriptId(null);
|
||||
|
||||
if (data.success) {
|
||||
// Update container status immediately in UI for instant feedback
|
||||
const newStatus = variables.action === 'start' ? 'running' : 'stopped';
|
||||
setContainerStatuses(prev => {
|
||||
const newMap = new Map(prev);
|
||||
// Find the script ID for this container using the container ID from the response
|
||||
const currentScripts = scriptsRef.current;
|
||||
const script = currentScripts.find(s => s.container_id === data.containerId);
|
||||
if (script) {
|
||||
newMap.set(script.id, newStatus);
|
||||
}
|
||||
return newMap;
|
||||
});
|
||||
|
||||
// Show success modal
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: `Container ${variables.action === 'start' ? 'Started' : 'Stopped'}`,
|
||||
message: data.message ?? `Container has been ${variables.action === 'start' ? 'started' : 'stopped'} successfully.`,
|
||||
details: undefined,
|
||||
type: 'success'
|
||||
});
|
||||
|
||||
// Re-fetch status for all containers using bulk method (in background)
|
||||
fetchContainerStatuses();
|
||||
} else {
|
||||
// Show error message from backend
|
||||
const errorMessage = data.error ?? 'Unknown error occurred';
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Container Control Failed',
|
||||
message: 'Failed to control the container. Please check the error details below.',
|
||||
details: errorMessage
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Container control error:', error);
|
||||
setControllingScriptId(null);
|
||||
|
||||
// Show detailed error message
|
||||
const errorMessage = error.message ?? 'Unknown error occurred';
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Container Control Failed',
|
||||
message: 'An unexpected error occurred while controlling the container.',
|
||||
details: errorMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const destroyContainerMutation = api.installedScripts.destroyContainer.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setControllingScriptId(null);
|
||||
|
||||
if (data.success) {
|
||||
void refetchScripts();
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Container Destroyed',
|
||||
message: data.message ?? 'The container has been successfully destroyed and removed from the database.',
|
||||
details: undefined,
|
||||
type: 'success'
|
||||
});
|
||||
} else {
|
||||
// Show error message from backend
|
||||
const errorMessage = data.error ?? 'Unknown error occurred';
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Container Destroy Failed',
|
||||
message: 'Failed to destroy the container. Please check the error details below.',
|
||||
details: errorMessage
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Container destroy error:', error);
|
||||
setControllingScriptId(null);
|
||||
|
||||
// Show detailed error message
|
||||
const errorMessage = error.message ?? 'Unknown error occurred';
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Container Destroy Failed',
|
||||
message: 'An unexpected error occurred while destroying the container.',
|
||||
details: errorMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const scripts: InstalledScript[] = useMemo(() => (scriptsData?.scripts as InstalledScript[]) ?? [], [scriptsData?.scripts]);
|
||||
const stats = statsData?.stats;
|
||||
|
||||
// Update ref when scripts change
|
||||
useEffect(() => {
|
||||
scriptsRef.current = scripts;
|
||||
}, [scripts]);
|
||||
|
||||
// Function to fetch container statuses - simplified to just check all servers
|
||||
const fetchContainerStatuses = useCallback(() => {
|
||||
console.log('fetchContainerStatuses called, isPending:', containerStatusMutation.isPending);
|
||||
|
||||
// Prevent multiple simultaneous status checks
|
||||
if (containerStatusMutation.isPending) {
|
||||
console.log('Status check already pending, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear any existing timeout
|
||||
if (statusCheckTimeoutRef.current) {
|
||||
clearTimeout(statusCheckTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce status checks by 500ms
|
||||
statusCheckTimeoutRef.current = setTimeout(() => {
|
||||
const currentScripts = scriptsRef.current;
|
||||
|
||||
// Get unique server IDs from scripts
|
||||
const serverIds = [...new Set(currentScripts
|
||||
.filter(script => script.server_id)
|
||||
.map(script => script.server_id!))];
|
||||
|
||||
console.log('Executing status check for server IDs:', serverIds);
|
||||
if (serverIds.length > 0) {
|
||||
containerStatusMutation.mutate({ serverIds });
|
||||
}
|
||||
}, 500);
|
||||
}, [containerStatusMutation]);
|
||||
|
||||
// Run cleanup when component mounts and scripts are loaded (only once)
|
||||
useEffect(() => {
|
||||
if (scripts.length > 0 && serversData?.servers && !cleanupMutation.isPending && !cleanupRunRef.current) {
|
||||
console.log('Running automatic cleanup check...');
|
||||
cleanupRunRef.current = true;
|
||||
void cleanupMutation.mutate();
|
||||
}
|
||||
}, [scripts.length, serversData?.servers, cleanupMutation]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (scripts.length > 0) {
|
||||
console.log('Status check triggered - scripts length:', scripts.length);
|
||||
fetchContainerStatuses();
|
||||
}
|
||||
}, [scripts.length, fetchContainerStatuses]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (statusCheckTimeoutRef.current) {
|
||||
clearTimeout(statusCheckTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scriptsWithStatus = scripts.map(script => ({
|
||||
...script,
|
||||
container_status: script.container_id ? containerStatuses.get(script.id) ?? 'unknown' : undefined
|
||||
}));
|
||||
|
||||
// Filter and sort scripts
|
||||
const filteredScripts = scripts
|
||||
const filteredScripts = scriptsWithStatus
|
||||
.filter((script: InstalledScript) => {
|
||||
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(script.container_id?.includes(searchTerm) ?? false) ||
|
||||
@@ -174,6 +393,33 @@ export function InstalledScriptsTab() {
|
||||
return matchesSearch && matchesStatus && matchesServer;
|
||||
})
|
||||
.sort((a: InstalledScript, b: InstalledScript) => {
|
||||
// Default sorting: group by server, then by container ID
|
||||
if (sortField === 'server_name') {
|
||||
const aServer = a.server_name ?? 'Local';
|
||||
const bServer = b.server_name ?? 'Local';
|
||||
|
||||
// First sort by server name
|
||||
if (aServer !== bServer) {
|
||||
return sortDirection === 'asc' ?
|
||||
aServer.localeCompare(bServer) :
|
||||
bServer.localeCompare(aServer);
|
||||
}
|
||||
|
||||
// If same server, sort by container ID
|
||||
const aContainerId = a.container_id ?? '';
|
||||
const bContainerId = b.container_id ?? '';
|
||||
|
||||
if (aContainerId !== bContainerId) {
|
||||
// Convert to numbers for proper numeric sorting
|
||||
const aNum = parseInt(aContainerId) || 0;
|
||||
const bNum = parseInt(bContainerId) || 0;
|
||||
return sortDirection === 'asc' ? aNum - bNum : bNum - aNum;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// For other sort fields, use the original logic
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
@@ -186,10 +432,6 @@ export function InstalledScriptsTab() {
|
||||
aValue = a.container_id ?? '';
|
||||
bValue = b.container_id ?? '';
|
||||
break;
|
||||
case 'server_name':
|
||||
aValue = a.server_name ?? 'Local';
|
||||
bValue = b.server_name ?? 'Local';
|
||||
break;
|
||||
case 'status':
|
||||
aValue = a.status;
|
||||
bValue = b.status;
|
||||
@@ -227,37 +469,181 @@ export function InstalledScriptsTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateScript = (script: InstalledScript) => {
|
||||
// Container control handlers
|
||||
const handleStartStop = (script: InstalledScript, action: 'start' | 'stop') => {
|
||||
if (!script.container_id) {
|
||||
alert('No Container ID available for this script');
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`Are you sure you want to update ${script.script_name}?`)) {
|
||||
// Get server info if it's SSH mode
|
||||
let server = null;
|
||||
if (script.server_id && script.server_user && script.server_password) {
|
||||
server = {
|
||||
id: script.server_id,
|
||||
name: script.server_name,
|
||||
ip: script.server_ip,
|
||||
user: script.server_user,
|
||||
password: script.server_password
|
||||
};
|
||||
|
||||
setConfirmationModal({
|
||||
isOpen: true,
|
||||
variant: 'simple',
|
||||
title: `${action === 'start' ? 'Start' : 'Stop'} Container`,
|
||||
message: `Are you sure you want to ${action} container ${script.container_id} (${script.script_name})?`,
|
||||
onConfirm: () => {
|
||||
setControllingScriptId(script.id);
|
||||
void controlContainerMutation.mutate({ id: script.id, action });
|
||||
setConfirmationModal(null);
|
||||
}
|
||||
|
||||
setUpdatingScript({
|
||||
id: script.id,
|
||||
containerId: script.container_id,
|
||||
server: server
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDestroy = (script: InstalledScript) => {
|
||||
if (!script.container_id) {
|
||||
alert('No Container ID available for this script');
|
||||
return;
|
||||
}
|
||||
|
||||
setConfirmationModal({
|
||||
isOpen: true,
|
||||
variant: 'danger',
|
||||
title: 'Destroy Container',
|
||||
message: `This will permanently destroy the LXC container ${script.container_id} (${script.script_name}) and all its data. This action cannot be undone!`,
|
||||
confirmText: script.container_id,
|
||||
onConfirm: () => {
|
||||
setControllingScriptId(script.id);
|
||||
void destroyContainerMutation.mutate({ id: script.id });
|
||||
setConfirmationModal(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateScript = (script: InstalledScript) => {
|
||||
if (!script.container_id) {
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Update Failed',
|
||||
message: 'No Container ID available for this script',
|
||||
details: 'This script does not have a valid container ID and cannot be updated.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Show confirmation modal with type-to-confirm for update
|
||||
setConfirmationModal({
|
||||
isOpen: true,
|
||||
title: 'Confirm Script Update',
|
||||
message: `Are you sure you want to update "${script.script_name}"?\n\n⚠️ WARNING: This will update the script and may affect the container. Consider backing up your data beforehand.`,
|
||||
variant: 'danger',
|
||||
confirmText: script.container_id,
|
||||
confirmButtonText: 'Update Script',
|
||||
onConfirm: () => {
|
||||
// Get server info if it's SSH mode
|
||||
let server = null;
|
||||
if (script.server_id && script.server_user) {
|
||||
server = {
|
||||
id: script.server_id,
|
||||
name: script.server_name,
|
||||
ip: script.server_ip,
|
||||
user: script.server_user,
|
||||
password: script.server_password,
|
||||
auth_type: script.server_auth_type ?? 'password',
|
||||
ssh_key: script.server_ssh_key,
|
||||
ssh_key_passphrase: script.server_ssh_key_passphrase,
|
||||
ssh_port: script.server_ssh_port ?? 22
|
||||
};
|
||||
}
|
||||
|
||||
setUpdatingScript({
|
||||
id: script.id,
|
||||
containerId: script.container_id!,
|
||||
server: server
|
||||
});
|
||||
setConfirmationModal(null);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseUpdateTerminal = () => {
|
||||
setUpdatingScript(null);
|
||||
};
|
||||
|
||||
const handleOpenShell = (script: InstalledScript) => {
|
||||
if (!script.container_id) {
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Shell Access Failed',
|
||||
message: 'No Container ID available for this script',
|
||||
details: 'This script does not have a valid container ID and cannot be accessed via shell.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get server info if it's SSH mode
|
||||
let server = null;
|
||||
if (script.server_id && script.server_user) {
|
||||
server = {
|
||||
id: script.server_id,
|
||||
name: script.server_name,
|
||||
ip: script.server_ip,
|
||||
user: script.server_user,
|
||||
password: script.server_password,
|
||||
auth_type: script.server_auth_type ?? 'password',
|
||||
ssh_key: script.server_ssh_key,
|
||||
ssh_key_passphrase: script.server_ssh_key_passphrase,
|
||||
ssh_port: script.server_ssh_port ?? 22
|
||||
};
|
||||
}
|
||||
|
||||
setOpeningShell({
|
||||
id: script.id,
|
||||
containerId: script.container_id,
|
||||
server: server
|
||||
});
|
||||
};
|
||||
|
||||
const handleCloseShellTerminal = () => {
|
||||
setOpeningShell(null);
|
||||
};
|
||||
|
||||
// Auto-scroll to terminals when they open
|
||||
useEffect(() => {
|
||||
if (openingShell) {
|
||||
// Small delay to ensure the terminal is rendered
|
||||
setTimeout(() => {
|
||||
const terminalElement = document.querySelector('[data-terminal="shell"]');
|
||||
if (terminalElement) {
|
||||
// Scroll to the terminal with smooth animation
|
||||
terminalElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
});
|
||||
|
||||
// Add a subtle highlight effect
|
||||
terminalElement.classList.add('animate-pulse');
|
||||
setTimeout(() => {
|
||||
terminalElement.classList.remove('animate-pulse');
|
||||
}, 2000);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}, [openingShell]);
|
||||
|
||||
useEffect(() => {
|
||||
if (updatingScript) {
|
||||
// Small delay to ensure the terminal is rendered
|
||||
setTimeout(() => {
|
||||
const terminalElement = document.querySelector('[data-terminal="update"]');
|
||||
if (terminalElement) {
|
||||
// Scroll to the terminal with smooth animation
|
||||
terminalElement.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
inline: 'nearest'
|
||||
});
|
||||
|
||||
// Add a subtle highlight effect
|
||||
terminalElement.classList.add('animate-pulse');
|
||||
setTimeout(() => {
|
||||
terminalElement.classList.remove('animate-pulse');
|
||||
}, 2000);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}, [updatingScript]);
|
||||
|
||||
const handleEditScript = (script: InstalledScript) => {
|
||||
setEditingScriptId(script.id);
|
||||
setEditFormData({
|
||||
@@ -273,7 +659,12 @@ export function InstalledScriptsTab() {
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!editFormData.script_name.trim()) {
|
||||
alert('Script name is required');
|
||||
setErrorModal({
|
||||
isOpen: true,
|
||||
title: 'Validation Error',
|
||||
message: 'Script name is required',
|
||||
details: 'Please enter a valid script name before saving.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,7 +722,6 @@ export function InstalledScriptsTab() {
|
||||
}
|
||||
|
||||
setAutoDetectStatus({ type: null, message: '' });
|
||||
console.log('Starting auto-detect for server ID:', autoDetectServerId);
|
||||
autoDetectMutation.mutate({ serverId: Number(autoDetectServerId) });
|
||||
};
|
||||
|
||||
@@ -366,7 +756,7 @@ export function InstalledScriptsTab() {
|
||||
<div className="space-y-6">
|
||||
{/* Update Terminal */}
|
||||
{updatingScript && (
|
||||
<div className="mb-8">
|
||||
<div className="mb-8" data-terminal="update">
|
||||
<Terminal
|
||||
scriptPath={`update-${updatingScript.containerId}`}
|
||||
onClose={handleCloseUpdateTerminal}
|
||||
@@ -378,6 +768,20 @@ export function InstalledScriptsTab() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Shell Terminal */}
|
||||
{openingShell && (
|
||||
<div className="mb-8" data-terminal="shell">
|
||||
<Terminal
|
||||
scriptPath={`shell-${openingShell.containerId}`}
|
||||
onClose={handleCloseShellTerminal}
|
||||
mode={openingShell.server ? 'ssh' : 'local'}
|
||||
server={openingShell.server}
|
||||
isShell={true}
|
||||
containerId={openingShell.containerId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header with Stats */}
|
||||
<div className="bg-card rounded-lg shadow p-6">
|
||||
<h2 className="text-2xl font-bold text-foreground mb-4">Installed Scripts</h2>
|
||||
@@ -419,6 +823,14 @@ export function InstalledScriptsTab() {
|
||||
>
|
||||
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={fetchContainerStatuses}
|
||||
disabled={containerStatusMutation.isPending || scripts.length === 0}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
{containerStatusMutation.isPending ? '🔄 Checking...' : '🔄 Refresh Container Status'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Add Script Form */}
|
||||
@@ -691,9 +1103,14 @@ export function InstalledScriptsTab() {
|
||||
onSave={handleSaveEdit}
|
||||
onCancel={handleCancelEdit}
|
||||
onUpdate={() => handleUpdateScript(script)}
|
||||
onShell={() => handleOpenShell(script)}
|
||||
onDelete={() => handleDeleteScript(Number(script.id))}
|
||||
isUpdating={updateScriptMutation.isPending}
|
||||
isDeleting={deleteScriptMutation.isPending}
|
||||
containerStatus={containerStatuses.get(script.id) ?? 'unknown'}
|
||||
onStartStop={(action) => handleStartStop(script, action)}
|
||||
onDestroy={() => handleDestroy(script)}
|
||||
isControlling={controllingScriptId === script.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -782,15 +1199,14 @@ export function InstalledScriptsTab() {
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{editingScriptId === script.id ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center min-h-[2.5rem]">
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.script_name}
|
||||
onChange={(e) => handleInputChange('script_name', e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
className="w-full px-3 py-2 text-sm font-medium border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder="Script name"
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground">{script.script_path}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -801,24 +1217,46 @@ export function InstalledScriptsTab() {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{editingScriptId === script.id ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.container_id}
|
||||
onChange={(e) => handleInputChange('container_id', e.target.value)}
|
||||
className="w-full px-2 py-1 text-sm font-mono border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="Container ID"
|
||||
/>
|
||||
<div className="flex items-center min-h-[2.5rem]">
|
||||
<input
|
||||
type="text"
|
||||
value={editFormData.container_id}
|
||||
onChange={(e) => handleInputChange('container_id', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm font-mono border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||
placeholder="Container ID"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
script.container_id ? (
|
||||
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
|
||||
{script.container_status && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
script.container_status === 'running' ? 'bg-green-500' :
|
||||
script.container_status === 'stopped' ? 'bg-red-500' :
|
||||
'bg-gray-400'
|
||||
}`}></div>
|
||||
<span className={`text-xs font-medium ${
|
||||
script.container_status === 'running' ? 'text-green-700 dark:text-green-300' :
|
||||
script.container_status === 'stopped' ? 'text-red-700 dark:text-red-300' :
|
||||
'text-gray-500 dark:text-gray-400'
|
||||
}`}>
|
||||
{script.container_status === 'running' ? 'Running' :
|
||||
script.container_status === 'stopped' ? 'Stopped' :
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-left">
|
||||
<span
|
||||
className="text-sm px-3 py-1 rounded"
|
||||
className="text-sm px-3 py-1 rounded inline-block"
|
||||
style={{
|
||||
backgroundColor: script.server_color ?? 'transparent',
|
||||
color: script.server_color ? getContrastColor(script.server_color) : 'inherit'
|
||||
@@ -842,14 +1280,14 @@ export function InstalledScriptsTab() {
|
||||
<Button
|
||||
onClick={handleSaveEdit}
|
||||
disabled={updateScriptMutation.isPending}
|
||||
variant="default"
|
||||
variant="save"
|
||||
size="sm"
|
||||
>
|
||||
{updateScriptMutation.isPending ? 'Saving...' : 'Save'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancelEdit}
|
||||
variant="outline"
|
||||
variant="cancel"
|
||||
size="sm"
|
||||
>
|
||||
Cancel
|
||||
@@ -859,7 +1297,7 @@ export function InstalledScriptsTab() {
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleEditScript(script)}
|
||||
variant="default"
|
||||
variant="edit"
|
||||
size="sm"
|
||||
>
|
||||
Edit
|
||||
@@ -867,20 +1305,61 @@ export function InstalledScriptsTab() {
|
||||
{script.container_id && (
|
||||
<Button
|
||||
onClick={() => handleUpdateScript(script)}
|
||||
variant="link"
|
||||
variant="update"
|
||||
size="sm"
|
||||
disabled={containerStatuses.get(script.id) === 'stopped'}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => handleDeleteScript(Number(script.id))}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={deleteScriptMutation.isPending}
|
||||
>
|
||||
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
{/* Shell button - only show for SSH scripts with container_id */}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<Button
|
||||
onClick={() => handleOpenShell(script)}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={containerStatuses.get(script.id) === 'stopped'}
|
||||
>
|
||||
Shell
|
||||
</Button>
|
||||
)}
|
||||
{/* Container Control Buttons - only show for SSH scripts with container_id */}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleStartStop(script, (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'stop' : 'start')}
|
||||
disabled={controllingScriptId === script.id || (containerStatuses.get(script.id) ?? 'unknown') === 'unknown'}
|
||||
variant={(containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'destructive' : 'default'}
|
||||
size="sm"
|
||||
className={(containerStatuses.get(script.id) ?? 'unknown') === 'running'
|
||||
? "bg-red-600 hover:bg-red-700 text-white border border-red-500 hover:border-red-400 hover:scale-105 hover:shadow-lg hover:shadow-red-500/25 transition-all duration-200 disabled:hover:scale-100"
|
||||
: "bg-green-600 hover:bg-green-700 text-white border border-green-500 hover:border-green-400 hover:scale-105 hover:shadow-lg hover:shadow-green-500/25 transition-all duration-200 disabled:hover:scale-100"
|
||||
}
|
||||
>
|
||||
{controllingScriptId === script.id ? 'Working...' : (containerStatuses.get(script.id) ?? 'unknown') === 'running' ? 'Stop' : 'Start'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDestroy(script)}
|
||||
disabled={controllingScriptId === script.id}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="bg-red-800 hover:bg-red-900 text-white border border-red-600 hover:border-red-500 hover:scale-105 hover:shadow-lg hover:shadow-red-600/30 transition-all duration-200 disabled:hover:scale-100"
|
||||
>
|
||||
{controllingScriptId === script.id ? 'Working...' : 'Destroy'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Fallback to old Delete button for non-SSH scripts */}
|
||||
{(!script.container_id || script.execution_mode !== 'ssh') && (
|
||||
<Button
|
||||
onClick={() => handleDeleteScript(Number(script.id))}
|
||||
variant="delete"
|
||||
size="sm"
|
||||
disabled={deleteScriptMutation.isPending}
|
||||
>
|
||||
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -893,6 +1372,31 @@ export function InstalledScriptsTab() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{confirmationModal && (
|
||||
<ConfirmationModal
|
||||
isOpen={confirmationModal.isOpen}
|
||||
onClose={() => setConfirmationModal(null)}
|
||||
onConfirm={confirmationModal.onConfirm}
|
||||
title={confirmationModal.title}
|
||||
message={confirmationModal.message}
|
||||
variant={confirmationModal.variant}
|
||||
confirmText={confirmationModal.confirmText}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Error/Success Modal */}
|
||||
{errorModal && (
|
||||
<ErrorModal
|
||||
isOpen={errorModal.isOpen}
|
||||
onClose={() => setErrorModal(null)}
|
||||
title={errorModal.title}
|
||||
message={errorModal.message}
|
||||
details={errorModal.details}
|
||||
type={errorModal.type ?? 'error'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
202
src/app/_components/ReleaseNotesModal.tsx
Normal file
202
src/app/_components/ReleaseNotesModal.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { Badge } from './ui/badge';
|
||||
import { X, ExternalLink, Calendar, Tag, Loader2 } from 'lucide-react';
|
||||
|
||||
interface ReleaseNotesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
highlightVersion?: string;
|
||||
}
|
||||
|
||||
interface Release {
|
||||
tagName: string;
|
||||
name: string;
|
||||
publishedAt: string;
|
||||
htmlUrl: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
// Helper functions for localStorage
|
||||
const getLastSeenVersion = (): string | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem('LAST_SEEN_RELEASE_VERSION');
|
||||
};
|
||||
|
||||
const markVersionAsSeen = (version: string): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.setItem('LAST_SEEN_RELEASE_VERSION', version);
|
||||
};
|
||||
|
||||
export function ReleaseNotesModal({ isOpen, onClose, highlightVersion }: ReleaseNotesModalProps) {
|
||||
const [currentVersion, setCurrentVersion] = useState<string | null>(null);
|
||||
const { data: releasesData, isLoading, error } = api.version.getAllReleases.useQuery(undefined, {
|
||||
enabled: isOpen
|
||||
});
|
||||
const { data: versionData } = api.version.getCurrentVersion.useQuery(undefined, {
|
||||
enabled: isOpen
|
||||
});
|
||||
|
||||
// Get current version when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && versionData?.success && versionData.version) {
|
||||
setCurrentVersion(versionData.version);
|
||||
}
|
||||
}, [isOpen, versionData]);
|
||||
|
||||
// Mark version as seen when modal closes
|
||||
const handleClose = () => {
|
||||
if (currentVersion) {
|
||||
markVersionAsSeen(currentVersion);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const releases: Release[] = releasesData?.success ? releasesData.releases ?? [] : [];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] flex flex-col border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<Tag className="h-6 w-6 text-blue-600" />
|
||||
<h2 className="text-2xl font-bold text-card-foreground">Release Notes</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClose}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-primary" />
|
||||
<span className="text-muted-foreground">Loading release notes...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : error || !releasesData?.success ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive mb-2">Failed to load release notes</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{releasesData?.error ?? 'Please try again later'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : releases.length === 0 ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<p className="text-muted-foreground">No releases found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{releases.map((release, index) => {
|
||||
const isHighlighted = highlightVersion && release.tagName.replace('v', '') === highlightVersion;
|
||||
const isLatest = index === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={release.tagName}
|
||||
className={`border rounded-lg p-6 ${
|
||||
isHighlighted
|
||||
? 'border-blue-500 bg-blue-50/10 dark:bg-blue-950/10'
|
||||
: 'border-border bg-card'
|
||||
} ${isLatest ? 'ring-2 ring-primary/20' : ''}`}
|
||||
>
|
||||
{/* Release Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-card-foreground">
|
||||
{release.name || release.tagName}
|
||||
</h3>
|
||||
{isLatest && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Latest
|
||||
</Badge>
|
||||
)}
|
||||
{isHighlighted && (
|
||||
<Badge variant="secondary" className="text-xs bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1">
|
||||
<Tag className="h-4 w-4" />
|
||||
<span>{release.tagName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>
|
||||
{new Date(release.publishedAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
asChild
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<a
|
||||
href={release.htmlUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="View on GitHub"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Release Body */}
|
||||
{release.body && (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert">
|
||||
<div className="whitespace-pre-wrap text-sm text-card-foreground leading-relaxed">
|
||||
{release.body}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-border bg-muted/30">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{currentVersion && (
|
||||
<span>Current version: <span className="font-medium text-card-foreground">v{currentVersion}</span></span>
|
||||
)}
|
||||
</div>
|
||||
<Button onClick={handleClose} variant="default">
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export helper functions for use in other components
|
||||
export { getLastSeenVersion, markVersionAsSeen };
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
|
||||
export function ResyncButton() {
|
||||
const [isResyncing, setIsResyncing] = useState(false);
|
||||
@@ -44,27 +45,30 @@ export function ResyncButton() {
|
||||
Sync scripts with ProxmoxVE repo
|
||||
</div>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<Button
|
||||
onClick={handleResync}
|
||||
disabled={isResyncing}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
{isResyncing ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
<span>Syncing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>Sync Json Files</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleResync}
|
||||
disabled={isResyncing}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
{isResyncing ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
<span>Syncing...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span>Sync Json Files</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<ContextualHelpIcon section="sync-button" tooltip="Help with Sync Button" />
|
||||
</div>
|
||||
|
||||
{lastSync && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -93,9 +93,22 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
|
||||
);
|
||||
|
||||
if (keyLine) {
|
||||
const keyType = keyLine.includes('RSA') ? 'RSA' :
|
||||
keyLine.includes('ED25519') ? 'ED25519' :
|
||||
keyLine.includes('ECDSA') ? 'ECDSA' : 'Unknown';
|
||||
let keyType = 'Unknown';
|
||||
|
||||
// Check for traditional PEM format keys
|
||||
if (keyLine.includes('RSA')) {
|
||||
keyType = 'RSA';
|
||||
} else if (keyLine.includes('ED25519')) {
|
||||
keyType = 'ED25519';
|
||||
} else if (keyLine.includes('ECDSA')) {
|
||||
keyType = 'ECDSA';
|
||||
} else if (keyLine.includes('OPENSSH PRIVATE KEY')) {
|
||||
// For OpenSSH format keys, try to detect type from the key content
|
||||
// This is a heuristic - OpenSSH ED25519 keys typically start with specific patterns
|
||||
// We'll default to "OpenSSH" for now since we can't reliably detect the type
|
||||
keyType = 'OpenSSH';
|
||||
}
|
||||
|
||||
return `${keyType} key (${keyContent.length} characters)`;
|
||||
}
|
||||
|
||||
@@ -142,7 +155,7 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".pem,.key,.id_rsa,.id_ed25519,.id_ecdsa"
|
||||
accept=".pem,.key,.id_rsa,.id_ed25519,.id_ecdsa,ed25519,id_rsa,id_ed25519,id_ecdsa,*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
disabled={disabled}
|
||||
@@ -153,7 +166,7 @@ export function SSHKeyInput({ value, onChange, onError, disabled = false }: SSHK
|
||||
Drag and drop your SSH private key here, or click to browse
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Supported formats: RSA, ED25519, ECDSA (.pem, .key, .id_rsa, etc.)
|
||||
Supported formats: RSA, ED25519, ECDSA (.pem, .key, .id_rsa, ed25519, etc.)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,20 +8,49 @@ import { TypeBadge, UpdateableBadge } from './Badge';
|
||||
interface ScriptCardProps {
|
||||
script: ScriptCard;
|
||||
onClick: (script: ScriptCard) => void;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: (slug: string) => void;
|
||||
}
|
||||
|
||||
export function ScriptCard({ script, onClick }: ScriptCardProps) {
|
||||
export function ScriptCard({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const handleImageError = () => {
|
||||
setImageError(true);
|
||||
};
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onToggleSelect && script.slug) {
|
||||
onToggleSelect(script.slug);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col"
|
||||
className="bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 cursor-pointer border border-border hover:border-primary h-full flex flex-col relative"
|
||||
onClick={() => onClick(script)}
|
||||
>
|
||||
{/* Checkbox in top-left corner */}
|
||||
{onToggleSelect && (
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
||||
}`}
|
||||
onClick={handleCheckboxClick}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
{/* Header with logo and name */}
|
||||
<div className="flex items-start space-x-4 mb-4">
|
||||
@@ -49,7 +78,7 @@ export function ScriptCard({ script, onClick }: ScriptCardProps) {
|
||||
</h3>
|
||||
<div className="mt-2 space-y-2">
|
||||
{/* Type and Updateable status on first row */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 flex-wrap gap-1">
|
||||
<TypeBadge type={script.type ?? 'unknown'} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
</div>
|
||||
|
||||
@@ -8,15 +8,24 @@ import { TypeBadge, UpdateableBadge } from './Badge';
|
||||
interface ScriptCardListProps {
|
||||
script: ScriptCard;
|
||||
onClick: (script: ScriptCard) => void;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: (slug: string) => void;
|
||||
}
|
||||
|
||||
export function ScriptCardList({ script, onClick }: ScriptCardListProps) {
|
||||
export function ScriptCardList({ script, onClick, isSelected = false, onToggleSelect }: ScriptCardListProps) {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
const handleImageError = () => {
|
||||
setImageError(true);
|
||||
};
|
||||
|
||||
const handleCheckboxClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (onToggleSelect && script.slug) {
|
||||
onToggleSelect(script.slug);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return 'Unknown';
|
||||
try {
|
||||
@@ -37,10 +46,30 @@ export function ScriptCardList({ script, onClick }: ScriptCardListProps) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary"
|
||||
className="bg-card rounded-lg shadow-sm hover:shadow-md transition-shadow duration-200 cursor-pointer border border-border hover:border-primary relative"
|
||||
onClick={() => onClick(script)}
|
||||
>
|
||||
<div className="p-6">
|
||||
{/* Checkbox */}
|
||||
{onToggleSelect && (
|
||||
<div className="absolute top-4 left-4 z-10">
|
||||
<div
|
||||
className={`w-4 h-4 border-2 rounded cursor-pointer transition-all duration-200 flex items-center justify-center ${
|
||||
isSelected
|
||||
? 'bg-primary border-primary text-primary-foreground'
|
||||
: 'bg-card border-border hover:border-primary/60 hover:bg-accent'
|
||||
}`}
|
||||
onClick={handleCheckboxClick}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-6 ${onToggleSelect ? 'pl-12' : ''}`}>
|
||||
<div className="flex items-start space-x-4">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
@@ -70,7 +99,7 @@ export function ScriptCardList({ script, onClick }: ScriptCardListProps) {
|
||||
<h3 className="text-xl font-semibold text-foreground truncate mb-2">
|
||||
{script.name || 'Unnamed Script'}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-3 flex-wrap gap-2">
|
||||
<TypeBadge type={script.type ?? 'unknown'} />
|
||||
{script.updateable && <UpdateableBadge />}
|
||||
<div className="flex items-center space-x-1">
|
||||
|
||||
@@ -14,10 +14,16 @@ interface InstalledScript {
|
||||
server_ip: string | null;
|
||||
server_user: string | null;
|
||||
server_password: string | null;
|
||||
server_auth_type: string | null;
|
||||
server_ssh_key: string | null;
|
||||
server_ssh_key_passphrase: string | null;
|
||||
server_ssh_port: number | null;
|
||||
server_color: string | null;
|
||||
installation_date: string;
|
||||
status: 'in_progress' | 'success' | 'failed';
|
||||
output_log: string | null;
|
||||
execution_mode: 'local' | 'ssh';
|
||||
container_status?: 'running' | 'stopped' | 'unknown';
|
||||
}
|
||||
|
||||
interface ScriptInstallationCardProps {
|
||||
@@ -29,9 +35,15 @@ interface ScriptInstallationCardProps {
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onUpdate: () => void;
|
||||
onShell: () => void;
|
||||
onDelete: () => void;
|
||||
isUpdating: boolean;
|
||||
isDeleting: boolean;
|
||||
// New container control props
|
||||
containerStatus?: 'running' | 'stopped' | 'unknown';
|
||||
onStartStop: (action: 'start' | 'stop') => void;
|
||||
onDestroy: () => void;
|
||||
isControlling: boolean;
|
||||
}
|
||||
|
||||
export function ScriptInstallationCard({
|
||||
@@ -43,9 +55,14 @@ export function ScriptInstallationCard({
|
||||
onSave,
|
||||
onCancel,
|
||||
onUpdate,
|
||||
onShell,
|
||||
onDelete,
|
||||
isUpdating,
|
||||
isDeleting
|
||||
isDeleting,
|
||||
containerStatus,
|
||||
onStartStop,
|
||||
onDestroy,
|
||||
isControlling
|
||||
}: ScriptInstallationCardProps) {
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
@@ -99,7 +116,29 @@ export function ScriptInstallationCard({
|
||||
/>
|
||||
) : (
|
||||
<div className="text-sm font-mono text-foreground break-all">
|
||||
{script.container_id ?? '-'}
|
||||
{script.container_id ? (
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{script.container_id}</span>
|
||||
{script.container_status && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
script.container_status === 'running' ? 'bg-green-500' :
|
||||
script.container_status === 'stopped' ? 'bg-red-500' :
|
||||
'bg-gray-400'
|
||||
}`}></div>
|
||||
<span className={`text-xs font-medium ${
|
||||
script.container_status === 'running' ? 'text-green-700 dark:text-green-300' :
|
||||
script.container_status === 'stopped' ? 'text-red-700 dark:text-red-300' :
|
||||
'text-gray-500 dark:text-gray-400'
|
||||
}`}>
|
||||
{script.container_status === 'running' ? 'Running' :
|
||||
script.container_status === 'stopped' ? 'Stopped' :
|
||||
'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : '-'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -134,7 +173,7 @@ export function ScriptInstallationCard({
|
||||
<Button
|
||||
onClick={onSave}
|
||||
disabled={isUpdating}
|
||||
variant="default"
|
||||
variant="save"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
@@ -142,7 +181,7 @@ export function ScriptInstallationCard({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="outline"
|
||||
variant="cancel"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
@@ -153,7 +192,7 @@ export function ScriptInstallationCard({
|
||||
<>
|
||||
<Button
|
||||
onClick={onEdit}
|
||||
variant="default"
|
||||
variant="edit"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
@@ -162,22 +201,61 @@ export function ScriptInstallationCard({
|
||||
{script.container_id && (
|
||||
<Button
|
||||
onClick={onUpdate}
|
||||
variant="link"
|
||||
variant="update"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
disabled={containerStatus === 'stopped'}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
{/* Shell button - only show for SSH scripts with container_id */}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<Button
|
||||
onClick={onShell}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
disabled={containerStatus === 'stopped'}
|
||||
>
|
||||
Shell
|
||||
</Button>
|
||||
)}
|
||||
{/* Container Control Buttons - only show for SSH scripts with container_id */}
|
||||
{script.container_id && script.execution_mode === 'ssh' && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => onStartStop(containerStatus === 'running' ? 'stop' : 'start')}
|
||||
disabled={isControlling || containerStatus === 'unknown'}
|
||||
variant={containerStatus === 'running' ? 'destructive' : 'default'}
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isControlling ? 'Working...' : containerStatus === 'running' ? 'Stop' : 'Start'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onDestroy}
|
||||
disabled={isControlling}
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isControlling ? 'Working...' : 'Destroy'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{/* Fallback to old Delete button for non-SSH scripts */}
|
||||
{(!script.container_id || script.execution_mode !== 'ssh') && (
|
||||
<Button
|
||||
onClick={onDelete}
|
||||
variant="delete"
|
||||
size="sm"
|
||||
disabled={isDeleting}
|
||||
className="flex-1 min-w-0"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
|
||||
const [selectedSlugs, setSelectedSlugs] = useState<Set<string>>(new Set());
|
||||
const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number; currentScript: string; failed: Array<{ slug: string; error: string }> } | null>(null);
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
searchQuery: '',
|
||||
showUpdatable: null,
|
||||
@@ -34,12 +36,15 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getCtScripts.useQuery();
|
||||
const { data: localScriptsData, isLoading: localLoading, error: localError } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: scriptData } = api.scripts.getScriptBySlug.useQuery(
|
||||
{ slug: selectedSlug ?? '' },
|
||||
{ enabled: !!selectedSlug }
|
||||
);
|
||||
|
||||
// Individual script download mutation
|
||||
const loadSingleScriptMutation = api.scripts.loadScript.useMutation();
|
||||
|
||||
// Load SAVE_FILTER setting, saved filters, and view mode on component mount
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
@@ -328,6 +333,167 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
setSearchQuery(newFilters.searchQuery);
|
||||
};
|
||||
|
||||
// Selection management functions
|
||||
const toggleScriptSelection = (slug: string) => {
|
||||
setSelectedSlugs(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(slug)) {
|
||||
newSet.delete(slug);
|
||||
} else {
|
||||
newSet.add(slug);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAllVisible = () => {
|
||||
const visibleSlugs = new Set(filteredScripts.map(script => script.slug).filter(Boolean));
|
||||
setSelectedSlugs(visibleSlugs);
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedSlugs(new Set());
|
||||
};
|
||||
|
||||
const getFriendlyErrorMessage = (error: string, slug: string): string => {
|
||||
const errorLower = error.toLowerCase();
|
||||
|
||||
// Exact matches first (most specific)
|
||||
if (error === 'Script not found') {
|
||||
return `Script "${slug}" is not available for download. It may not exist in the repository or has been removed.`;
|
||||
}
|
||||
|
||||
if (error === 'Failed to load script') {
|
||||
return `Unable to download script "${slug}". Please check your internet connection and try again.`;
|
||||
}
|
||||
|
||||
// Network/Connection errors
|
||||
if (errorLower.includes('network') || errorLower.includes('connection') || errorLower.includes('timeout')) {
|
||||
return 'Network connection failed. Please check your internet connection and try again.';
|
||||
}
|
||||
|
||||
// GitHub API errors
|
||||
if (errorLower.includes('not found') || errorLower.includes('404')) {
|
||||
return `Script "${slug}" not found in the repository. It may have been removed or renamed.`;
|
||||
}
|
||||
|
||||
if (errorLower.includes('rate limit') || errorLower.includes('403')) {
|
||||
return 'GitHub API rate limit exceeded. Please wait a few minutes and try again.';
|
||||
}
|
||||
|
||||
if (errorLower.includes('unauthorized') || errorLower.includes('401')) {
|
||||
return 'Access denied. The script may be private or require authentication.';
|
||||
}
|
||||
|
||||
// File system errors
|
||||
if (errorLower.includes('permission') || errorLower.includes('eacces')) {
|
||||
return 'Permission denied. Please check file system permissions.';
|
||||
}
|
||||
|
||||
if (errorLower.includes('no space') || errorLower.includes('enospc')) {
|
||||
return 'Insufficient disk space. Please free up some space and try again.';
|
||||
}
|
||||
|
||||
if (errorLower.includes('read-only') || errorLower.includes('erofs')) {
|
||||
return 'Cannot write to read-only file system. Please check your installation directory.';
|
||||
}
|
||||
|
||||
// Script-specific errors
|
||||
if (errorLower.includes('script not found')) {
|
||||
return `Script "${slug}" not found in the local scripts directory.`;
|
||||
}
|
||||
|
||||
if (errorLower.includes('invalid script') || errorLower.includes('malformed')) {
|
||||
return `Script "${slug}" appears to be corrupted or invalid.`;
|
||||
}
|
||||
|
||||
if (errorLower.includes('already exists') || errorLower.includes('file exists')) {
|
||||
return `Script "${slug}" already exists locally. Skipping download.`;
|
||||
}
|
||||
|
||||
// Generic fallbacks
|
||||
if (errorLower.includes('timeout')) {
|
||||
return 'Download timed out. The script may be too large or the connection is slow.';
|
||||
}
|
||||
|
||||
if (errorLower.includes('server error') || errorLower.includes('500')) {
|
||||
return 'Server error occurred. Please try again later.';
|
||||
}
|
||||
|
||||
// If we can't categorize it, return a more helpful generic message
|
||||
if (error.length > 100) {
|
||||
return `Download failed: ${error.substring(0, 100)}...`;
|
||||
}
|
||||
|
||||
return `Download failed: ${error}`;
|
||||
};
|
||||
|
||||
const downloadScriptsIndividually = async (slugsToDownload: string[]) => {
|
||||
setDownloadProgress({ current: 0, total: slugsToDownload.length, currentScript: '', failed: [] });
|
||||
|
||||
const successful: Array<{ slug: string; files: string[] }> = [];
|
||||
const failed: Array<{ slug: string; error: string }> = [];
|
||||
|
||||
for (let i = 0; i < slugsToDownload.length; i++) {
|
||||
const slug = slugsToDownload[i];
|
||||
|
||||
// Update progress with current script
|
||||
setDownloadProgress(prev => prev ? {
|
||||
...prev,
|
||||
current: i,
|
||||
currentScript: slug ?? ''
|
||||
} : null);
|
||||
|
||||
try {
|
||||
// Download individual script
|
||||
const result = await loadSingleScriptMutation.mutateAsync({ slug: slug ?? '' });
|
||||
|
||||
if (result.success) {
|
||||
successful.push({ slug: slug ?? '', files: result.files ?? [] });
|
||||
} else {
|
||||
const error = 'error' in result ? result.error : 'Failed to load script';
|
||||
const userFriendlyError = getFriendlyErrorMessage(error, slug ?? '');
|
||||
failed.push({ slug: slug ?? '', error: userFriendlyError });
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to load script';
|
||||
const userFriendlyError = getFriendlyErrorMessage(errorMessage, slug ?? '');
|
||||
failed.push({
|
||||
slug: slug ?? '',
|
||||
error: userFriendlyError
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Final progress update
|
||||
setDownloadProgress(prev => prev ? {
|
||||
...prev,
|
||||
current: slugsToDownload.length,
|
||||
failed
|
||||
} : null);
|
||||
|
||||
// Clear selection and refetch to update card download status
|
||||
setSelectedSlugs(new Set());
|
||||
void refetch();
|
||||
|
||||
// Keep progress bar visible until user navigates away or manually dismisses
|
||||
// Progress bar will stay visible to show final results
|
||||
};
|
||||
|
||||
const handleBatchDownload = () => {
|
||||
const slugsToDownload = Array.from(selectedSlugs);
|
||||
if (slugsToDownload.length > 0) {
|
||||
void downloadScriptsIndividually(slugsToDownload);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadAllFiltered = () => {
|
||||
const slugsToDownload = filteredScripts.map(script => script.slug).filter(Boolean);
|
||||
if (slugsToDownload.length > 0) {
|
||||
void downloadScriptsIndividually(slugsToDownload);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle category selection with auto-scroll
|
||||
const handleCategorySelect = (category: string | null) => {
|
||||
setSelectedCategory(category);
|
||||
@@ -348,6 +514,18 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
}
|
||||
}, [selectedCategory]);
|
||||
|
||||
// Clear selection when switching between card/list views
|
||||
useEffect(() => {
|
||||
setSelectedSlugs(new Set());
|
||||
}, [viewMode]);
|
||||
|
||||
// Clear progress bar when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setDownloadProgress(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const handleCardClick = (scriptCard: { slug: string }) => {
|
||||
// All scripts are GitHub scripts, open modal
|
||||
@@ -393,7 +571,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!scriptsWithStatus || scriptsWithStatus.length === 0) {
|
||||
if (!scriptsWithStatus?.length) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-muted-foreground">
|
||||
@@ -441,6 +619,157 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{selectedSlugs.size > 0 ? (
|
||||
<Button
|
||||
onClick={handleBatchDownload}
|
||||
disabled={loadSingleScriptMutation.isPending}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-blue-500/10 hover:bg-blue-500/20 border-blue-500/30 text-blue-300 hover:text-blue-200 hover:border-blue-400/50"
|
||||
>
|
||||
{loadSingleScriptMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2"></div>
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
`Download Selected (${selectedSlugs.size})`
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleDownloadAllFiltered}
|
||||
disabled={filteredScripts.length === 0 || loadSingleScriptMutation.isPending}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{loadSingleScriptMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current mr-2"></div>
|
||||
Downloading...
|
||||
</>
|
||||
) : (
|
||||
`Download All Filtered (${filteredScripts.length})`
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{selectedSlugs.size > 0 && (
|
||||
<Button
|
||||
onClick={clearSelection}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
Clear Selection
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{filteredScripts.length > 0 && (
|
||||
<Button
|
||||
onClick={selectAllVisible}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
Select All Visible
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{downloadProgress && (
|
||||
<div className="mb-4 p-4 bg-card border border-border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{downloadProgress.current >= downloadProgress.total ? 'Download completed' : 'Downloading scripts'}... {downloadProgress.current} of {downloadProgress.total}
|
||||
</span>
|
||||
{downloadProgress.currentScript && downloadProgress.current < downloadProgress.total && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Currently downloading: {downloadProgress.currentScript}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{Math.round((downloadProgress.current / downloadProgress.total) * 100)}%
|
||||
</span>
|
||||
{downloadProgress.current >= downloadProgress.total && (
|
||||
<button
|
||||
onClick={() => setDownloadProgress(null)}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
title="Dismiss progress bar"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full bg-muted rounded-full h-2 mb-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ease-out ${
|
||||
downloadProgress.failed.length > 0 ? 'bg-yellow-500' : 'bg-primary'
|
||||
}`}
|
||||
style={{ width: `${(downloadProgress.current / downloadProgress.total) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Progress Visualization */}
|
||||
<div className="flex items-center text-xs text-muted-foreground mb-2">
|
||||
<span className="mr-2">Progress:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Array.from({ length: downloadProgress.total }, (_, i) => {
|
||||
const isCompleted = i < downloadProgress.current;
|
||||
const isCurrent = i === downloadProgress.current;
|
||||
const isFailed = downloadProgress.failed.some(f => f.slug === downloadProgress.currentScript);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className={`px-1 py-0.5 rounded text-xs ${
|
||||
isCompleted
|
||||
? isFailed ? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300' : 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
: isCurrent
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300 animate-pulse'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? (isFailed ? '✗' : '✓') : isCurrent ? '⟳' : '○'}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Failed Scripts Details */}
|
||||
{downloadProgress.failed.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-center mb-2">
|
||||
<svg className="w-4 h-4 text-red-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium text-red-800 dark:text-red-200">
|
||||
Failed Downloads ({downloadProgress.failed.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{downloadProgress.failed.map((failed, index) => (
|
||||
<div key={index} className="text-xs text-red-700 dark:text-red-300">
|
||||
<span className="font-medium">{failed.slug}:</span> {failed.error}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||
<div className="hidden mb-8">
|
||||
<div className="relative max-w-md mx-auto">
|
||||
@@ -532,6 +861,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
key={uniqueKey}
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
isSelected={selectedSlugs.has(script.slug ?? '')}
|
||||
onToggleSelect={toggleScriptSelection}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -552,6 +883,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
key={uniqueKey}
|
||||
script={script}
|
||||
onClick={handleCardClick}
|
||||
isSelected={selectedSlugs.has(script.slug ?? '')}
|
||||
onToggleSelect={toggleScriptSelection}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Server, CreateServerData } from '../../types/server';
|
||||
import { ServerForm } from './ServerForm';
|
||||
import { ServerList } from './ServerList';
|
||||
import { Button } from './ui/button';
|
||||
import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -106,7 +107,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
|
||||
<ContextualHelpIcon section="server-settings" tooltip="Help with Server Settings" />
|
||||
</div>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
|
||||
@@ -11,6 +11,7 @@ interface TerminalProps {
|
||||
mode?: 'local' | 'ssh';
|
||||
server?: any;
|
||||
isUpdate?: boolean;
|
||||
isShell?: boolean;
|
||||
containerId?: string;
|
||||
}
|
||||
|
||||
@@ -20,7 +21,7 @@ interface TerminalMessage {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, containerId }: TerminalProps) {
|
||||
export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate = false, isShell = false, containerId }: TerminalProps) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
@@ -332,6 +333,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
mode,
|
||||
server,
|
||||
isUpdate,
|
||||
isShell,
|
||||
containerId
|
||||
};
|
||||
ws.send(JSON.stringify(message));
|
||||
@@ -372,7 +374,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
wsRef.current.close();
|
||||
}
|
||||
};
|
||||
}, [scriptPath, mode, server, isUpdate, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [scriptPath, mode, server, isUpdate, isShell, containerId, isMobile]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const startScript = () => {
|
||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && !isRunning) {
|
||||
@@ -388,6 +390,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
||||
mode,
|
||||
server,
|
||||
isUpdate,
|
||||
isShell,
|
||||
containerId
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
import { api } from "~/trpc/react";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { ContextualHelpIcon } from "./ContextualHelpIcon";
|
||||
|
||||
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
interface VersionDisplayProps {
|
||||
onOpenReleaseNotes?: () => void;
|
||||
}
|
||||
|
||||
// Loading overlay component with log streaming
|
||||
function LoadingOverlay({
|
||||
isNetworkError = false,
|
||||
@@ -72,7 +77,7 @@ function LoadingOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
export function VersionDisplay() {
|
||||
export function VersionDisplay({ onOpenReleaseNotes }: VersionDisplayProps = {}) {
|
||||
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
@@ -137,7 +142,6 @@ export function VersionDisplay() {
|
||||
const noLogsForAWhile = timeSinceLastLog > 60000; // 60 seconds
|
||||
|
||||
if (hasBeenUpdatingLongEnough && noLogsForAWhile && isUpdating && !isNetworkError) {
|
||||
console.log('Fallback: Assuming server restart due to long silence');
|
||||
setIsNetworkError(true);
|
||||
setUpdateLogs(prev => [...prev, 'Server restarting... waiting for reconnection...']);
|
||||
|
||||
@@ -230,31 +234,16 @@ export function VersionDisplay() {
|
||||
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
|
||||
<Badge variant={isUpToDate ? "default" : "secondary"} className="text-xs">
|
||||
<Badge
|
||||
variant={isUpToDate ? "default" : "secondary"}
|
||||
className={`text-xs ${onOpenReleaseNotes ? 'cursor-pointer hover:opacity-80 transition-opacity' : ''}`}
|
||||
onClick={onOpenReleaseNotes}
|
||||
>
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
|
||||
{updateAvailable && releaseInfo && (
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-3">
|
||||
<div className="relative group">
|
||||
<Badge variant="destructive" className="animate-pulse cursor-help text-xs">
|
||||
Update Available
|
||||
</Badge>
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 mt-2 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10 hidden sm:block">
|
||||
<div className="text-center">
|
||||
<div className="font-semibold mb-1">How to update:</div>
|
||||
<div>Click the button to update, when installed via the helper script</div>
|
||||
<div>or update manually:</div>
|
||||
<div>cd $PVESCRIPTLOCAL_DIR</div>
|
||||
<div>git pull</div>
|
||||
<div>npm install</div>
|
||||
<div>npm run build</div>
|
||||
<div>npm start</div>
|
||||
</div>
|
||||
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-b-gray-900 dark:border-b-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleUpdate}
|
||||
@@ -278,6 +267,11 @@ export function VersionDisplay() {
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<ContextualHelpIcon section="update-system" tooltip="Help with updates" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">Release Notes:</span>
|
||||
<a
|
||||
href={releaseInfo.htmlUrl}
|
||||
target="_blank"
|
||||
|
||||
@@ -34,6 +34,12 @@ const buttonVariants = cva(
|
||||
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-left after:scale-x-100 hover:after:origin-bottom-right hover:after:scale-x-0 after:transition-transform after:ease-in-out after:duration-300",
|
||||
linkHover2:
|
||||
"relative after:absolute after:bg-primary after:bottom-2 after:h-[1px] after:w-2/3 after:origin-bottom-right after:scale-x-0 hover:after:origin-bottom-left hover:after:scale-x-100 after:transition-transform after:ease-in-out after:duration-300",
|
||||
// Dark theme action button variants
|
||||
edit: "bg-blue-900/20 hover:bg-blue-900/30 border border-blue-700/50 text-blue-300 hover:text-blue-200 hover:border-blue-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||
update: "bg-cyan-900/20 hover:bg-cyan-900/30 border border-cyan-700/50 text-cyan-300 hover:text-cyan-200 hover:border-cyan-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||
delete: "bg-red-900/20 hover:bg-red-900/30 border border-red-700/50 text-red-300 hover:text-red-200 hover:border-red-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100",
|
||||
save: "bg-green-900/20 hover:bg-green-900/30 border border-green-700/50 text-green-300 hover:text-green-200 hover:border-green-600/60 transition-all duration-200 hover:scale-105 hover:shadow-md disabled:hover:scale-100",
|
||||
cancel: "bg-gray-800/20 hover:bg-gray-800/30 border border-gray-600/50 text-gray-300 hover:text-gray-200 hover:border-gray-500/60 transition-all duration-200 hover:scale-105 hover:shadow-md",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
|
||||
117
src/app/page.tsx
117
src/app/page.tsx
@@ -1,7 +1,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { ScriptsGrid } from './_components/ScriptsGrid';
|
||||
import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
||||
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
||||
@@ -9,32 +9,103 @@ import { ResyncButton } from './_components/ResyncButton';
|
||||
import { Terminal } from './_components/Terminal';
|
||||
import { ServerSettingsButton } from './_components/ServerSettingsButton';
|
||||
import { SettingsButton } from './_components/SettingsButton';
|
||||
import { HelpButton } from './_components/HelpButton';
|
||||
import { VersionDisplay } from './_components/VersionDisplay';
|
||||
import { Button } from './_components/ui/button';
|
||||
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
||||
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
||||
import { Footer } from './_components/Footer';
|
||||
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { api } from '~/trpc/react';
|
||||
|
||||
export default function Home() {
|
||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>('scripts');
|
||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTab = localStorage.getItem('activeTab') as 'scripts' | 'downloaded' | 'installed';
|
||||
return savedTab || 'scripts';
|
||||
}
|
||||
return 'scripts';
|
||||
});
|
||||
const [releaseNotesOpen, setReleaseNotesOpen] = useState(false);
|
||||
const [highlightVersion, setHighlightVersion] = useState<string | undefined>(undefined);
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Fetch data for script counts
|
||||
const { data: scriptCardsData } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
const { data: localScriptsData } = api.scripts.getCtScripts.useQuery();
|
||||
const { data: localScriptsData } = api.scripts.getAllDownloadedScripts.useQuery();
|
||||
const { data: installedScriptsData } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||
const { data: versionData } = api.version.getCurrentVersion.useQuery();
|
||||
|
||||
// Save active tab to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('activeTab', activeTab);
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
// Auto-show release notes modal after update
|
||||
useEffect(() => {
|
||||
if (versionData?.success && versionData.version) {
|
||||
const currentVersion = versionData.version;
|
||||
const lastSeenVersion = getLastSeenVersion();
|
||||
|
||||
// If we have a current version and either no last seen version or versions don't match
|
||||
if (currentVersion && (!lastSeenVersion || currentVersion !== lastSeenVersion)) {
|
||||
setHighlightVersion(currentVersion);
|
||||
setReleaseNotesOpen(true);
|
||||
}
|
||||
}
|
||||
}, [versionData]);
|
||||
|
||||
const handleOpenReleaseNotes = () => {
|
||||
setHighlightVersion(undefined);
|
||||
setReleaseNotesOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseReleaseNotes = () => {
|
||||
setReleaseNotesOpen(false);
|
||||
setHighlightVersion(undefined);
|
||||
};
|
||||
|
||||
// Calculate script counts
|
||||
const scriptCounts = {
|
||||
available: scriptCardsData?.success ? scriptCardsData.cards?.length ?? 0 : 0,
|
||||
available: (() => {
|
||||
if (!scriptCardsData?.success) return 0;
|
||||
|
||||
// Deduplicate scripts using Map by slug (same logic as ScriptsGrid.tsx)
|
||||
const scriptMap = new Map<string, any>();
|
||||
|
||||
scriptCardsData.cards?.forEach(script => {
|
||||
if (script?.name && script?.slug) {
|
||||
// Use slug as unique identifier, only keep first occurrence
|
||||
if (!scriptMap.has(script.slug)) {
|
||||
scriptMap.set(script.slug, script);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return scriptMap.size;
|
||||
})(),
|
||||
downloaded: (() => {
|
||||
if (!scriptCardsData?.success || !localScriptsData?.scripts) return 0;
|
||||
|
||||
// Count scripts that are both in GitHub data and have local versions
|
||||
const githubScripts = scriptCardsData.cards ?? [];
|
||||
// First deduplicate GitHub scripts using Map by slug
|
||||
const scriptMap = new Map<string, any>();
|
||||
|
||||
scriptCardsData.cards?.forEach(script => {
|
||||
if (script?.name && script?.slug) {
|
||||
if (!scriptMap.has(script.slug)) {
|
||||
scriptMap.set(script.slug, script);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const deduplicatedGithubScripts = Array.from(scriptMap.values());
|
||||
const localScripts = localScriptsData.scripts ?? [];
|
||||
|
||||
return githubScripts.filter(script => {
|
||||
// Count scripts that are both in deduplicated GitHub data and have local versions
|
||||
return deduplicatedGithubScripts.filter(script => {
|
||||
if (!script?.name) return false;
|
||||
return localScripts.some(local => {
|
||||
if (!local?.name) return false;
|
||||
@@ -83,7 +154,7 @@ export default function Home() {
|
||||
Manage and execute Proxmox helper scripts locally with live output streaming
|
||||
</p>
|
||||
<div className="flex justify-center px-2">
|
||||
<VersionDisplay />
|
||||
<VersionDisplay onOpenReleaseNotes={handleOpenReleaseNotes} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -93,28 +164,30 @@ export default function Home() {
|
||||
<ServerSettingsButton />
|
||||
<SettingsButton />
|
||||
<ResyncButton />
|
||||
<HelpButton />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="mb-6 sm:mb-8">
|
||||
<div className="border-b border-border">
|
||||
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-2 lg:space-x-8">
|
||||
<nav className="-mb-px flex flex-col sm:flex-row space-y-2 sm:space-y-0 sm:space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="null"
|
||||
onClick={() => setActiveTab('scripts')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'scripts'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent hover:text-accent-foreground'
|
||||
}`}>
|
||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||
}`}>
|
||||
<Package className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Available Scripts</span>
|
||||
<span className="sm:hidden">Available</span>
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
{scriptCounts.available}
|
||||
</span>
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with Available Scripts" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -122,8 +195,8 @@ export default function Home() {
|
||||
onClick={() => setActiveTab('downloaded')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'downloaded'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent hover:text-accent-foreground'
|
||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||
}`}>
|
||||
<HardDrive className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Downloaded Scripts</span>
|
||||
@@ -131,6 +204,7 @@ export default function Home() {
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
{scriptCounts.downloaded}
|
||||
</span>
|
||||
<ContextualHelpIcon section="downloaded-scripts" tooltip="Help with Downloaded Scripts" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -138,8 +212,8 @@ export default function Home() {
|
||||
onClick={() => setActiveTab('installed')}
|
||||
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||
activeTab === 'installed'
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'hover:bg-accent hover:text-accent-foreground'
|
||||
? 'bg-accent text-accent-foreground rounded-t-md rounded-b-none'
|
||||
: 'hover:bg-accent hover:text-accent-foreground hover:rounded-t-md hover:rounded-b-none'
|
||||
}`}>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Installed Scripts</span>
|
||||
@@ -147,6 +221,7 @@ export default function Home() {
|
||||
<span className="ml-1 px-2 py-0.5 text-xs bg-muted text-muted-foreground rounded-full">
|
||||
{scriptCounts.installed}
|
||||
</span>
|
||||
<ContextualHelpIcon section="installed-scripts" tooltip="Help with Installed Scripts" />
|
||||
</Button>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -179,6 +254,16 @@ export default function Home() {
|
||||
<InstalledScriptsTab />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer onOpenReleaseNotes={handleOpenReleaseNotes} />
|
||||
|
||||
{/* Release Notes Modal */}
|
||||
<ReleaseNotesModal
|
||||
isOpen={releaseNotesOpen}
|
||||
onClose={handleCloseReleaseNotes}
|
||||
highlightVersion={highlightVersion}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* to ensure optimal readability based on luminance
|
||||
*/
|
||||
export function getContrastColor(hexColor: string): 'black' | 'white' {
|
||||
if (!hexColor || hexColor.length !== 7 || !hexColor.startsWith('#')) {
|
||||
if (!hexColor?.length || hexColor.length !== 7 || !hexColor.startsWith('#')) {
|
||||
return 'black'; // Default to black for invalid colors
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||
import { getDatabase } from "~/server/database";
|
||||
// Removed unused imports
|
||||
|
||||
|
||||
export const installedScriptsRouter = createTRPCRouter({
|
||||
// Get all installed scripts
|
||||
@@ -209,12 +211,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
autoDetectLXCContainers: publicProcedure
|
||||
.input(z.object({ serverId: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
console.log('=== AUTO-DETECT API ENDPOINT CALLED ===');
|
||||
console.log('Input received:', input);
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
|
||||
try {
|
||||
console.log('Starting auto-detect LXC containers for server ID:', input.serverId);
|
||||
|
||||
const db = getDatabase();
|
||||
const server = db.getServerById(input.serverId);
|
||||
@@ -228,7 +226,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
};
|
||||
}
|
||||
|
||||
console.log('Found server:', (server as any).name, 'at', (server as any).ip);
|
||||
|
||||
// Import SSH services
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
@@ -237,10 +234,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// Test SSH connection first
|
||||
console.log('Testing SSH connection...');
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||
console.log('SSH connection test result:', connectionTest);
|
||||
|
||||
if (!(connectionTest as any).success) {
|
||||
return {
|
||||
@@ -250,14 +245,11 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
};
|
||||
}
|
||||
|
||||
console.log('SSH connection successful, scanning for LXC containers...');
|
||||
|
||||
// Use the working approach - manual loop through all config files
|
||||
const command = `for file in /etc/pve/lxc/*.conf; do if [ -f "$file" ]; then if grep -q "community-script" "$file"; then echo "$file"; fi; fi; done`;
|
||||
let detectedContainers: any[] = [];
|
||||
|
||||
console.log('Executing manual loop command...');
|
||||
console.log('Command:', command);
|
||||
|
||||
let commandOutput = '';
|
||||
|
||||
@@ -273,8 +265,7 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
(error: string) => {
|
||||
console.error('Command error:', error);
|
||||
},
|
||||
(exitCode: number) => {
|
||||
console.log('Command exit code:', exitCode);
|
||||
(_exitCode: number) => {
|
||||
|
||||
// Parse the complete output to get config file paths that contain community-script tag
|
||||
const configFiles = commandOutput.split('\n')
|
||||
@@ -282,8 +273,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line.endsWith('.conf'));
|
||||
|
||||
console.log('Found config files with community-script tag:', configFiles.length);
|
||||
console.log('Config files:', configFiles);
|
||||
|
||||
// Process each config file to extract hostname
|
||||
const processPromises = configFiles.map(async (configPath: string) => {
|
||||
@@ -291,7 +280,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
const containerId = configPath.split('/').pop()?.replace('.conf', '');
|
||||
if (!containerId) return null;
|
||||
|
||||
console.log('Processing container:', containerId, 'from', configPath);
|
||||
|
||||
// Read the config file content
|
||||
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
||||
@@ -321,13 +309,11 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
containerId,
|
||||
hostname,
|
||||
configPath,
|
||||
serverId: (server as any).id,
|
||||
serverId: Number((server as any).id),
|
||||
serverName: (server as any).name
|
||||
};
|
||||
console.log('Adding container to detected list:', container);
|
||||
readResolve(container);
|
||||
} else {
|
||||
console.log('No hostname found for', containerId);
|
||||
readResolve(null);
|
||||
}
|
||||
},
|
||||
@@ -349,7 +335,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
// Wait for all config files to be processed
|
||||
void Promise.all(processPromises).then((results) => {
|
||||
detectedContainers = results.filter(result => result !== null);
|
||||
console.log('Final detected containers:', detectedContainers.length);
|
||||
resolve();
|
||||
}).catch((error) => {
|
||||
console.error('Error processing config files:', error);
|
||||
@@ -359,7 +344,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
);
|
||||
});
|
||||
|
||||
console.log('Detected containers:', detectedContainers.length);
|
||||
|
||||
// Get existing scripts to check for duplicates
|
||||
const existingScripts = db.getAllInstalledScripts();
|
||||
@@ -377,7 +361,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
);
|
||||
|
||||
if (duplicate) {
|
||||
console.log(`Skipping duplicate: ${container.hostname} (${container.containerId}) already exists`);
|
||||
skippedScripts.push({
|
||||
containerId: container.containerId,
|
||||
hostname: container.hostname,
|
||||
@@ -386,7 +369,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('Creating script record for:', container.hostname, container.containerId);
|
||||
const result = db.createInstalledScript({
|
||||
script_name: container.hostname,
|
||||
script_path: `detected/${container.hostname}`,
|
||||
@@ -403,7 +385,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
hostname: container.hostname,
|
||||
serverName: container.serverName
|
||||
});
|
||||
console.log('Created script record with ID:', result.lastInsertRowid);
|
||||
} catch (error) {
|
||||
console.error(`Error creating script record for ${container.hostname}:`, error);
|
||||
}
|
||||
@@ -433,15 +414,11 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
cleanupOrphanedScripts: publicProcedure
|
||||
.mutation(async () => {
|
||||
try {
|
||||
console.log('=== CLEANUP ORPHANED SCRIPTS API ENDPOINT CALLED ===');
|
||||
console.log('Timestamp:', new Date().toISOString());
|
||||
|
||||
const db = getDatabase();
|
||||
const allScripts = db.getAllInstalledScripts();
|
||||
const allServers = db.getAllServers();
|
||||
|
||||
console.log('Found scripts:', allScripts.length);
|
||||
console.log('Found servers:', allServers.length);
|
||||
|
||||
if (allScripts.length === 0) {
|
||||
return {
|
||||
@@ -465,26 +442,22 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
script.container_id
|
||||
);
|
||||
|
||||
console.log('Scripts to check for cleanup:', scriptsToCheck.length);
|
||||
|
||||
for (const script of scriptsToCheck) {
|
||||
try {
|
||||
const scriptData = script as any;
|
||||
const server = allServers.find((s: any) => s.id === scriptData.server_id);
|
||||
if (!server) {
|
||||
console.log(`Server not found for script ${scriptData.script_name}, marking for deletion`);
|
||||
db.deleteInstalledScript(Number(scriptData.id));
|
||||
deletedScripts.push(String(scriptData.script_name));
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Checking script ${scriptData.script_name} on server ${(server as any).name}`);
|
||||
|
||||
// Test SSH connection
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||
if (!(connectionTest as any).success) {
|
||||
console.log(`SSH connection failed for server ${(server as any).name}, skipping script ${scriptData.script_name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -511,11 +484,9 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
});
|
||||
|
||||
if (!containerExists) {
|
||||
console.log(`Container ${scriptData.container_id} not found on server ${(server as any).name}, deleting script ${scriptData.script_name}`);
|
||||
db.deleteInstalledScript(Number(scriptData.id));
|
||||
deletedScripts.push(String(scriptData.script_name));
|
||||
} else {
|
||||
console.log(`Container ${scriptData.container_id} still exists on server ${(server as any).name}, keeping script ${scriptData.script_name}`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@@ -523,7 +494,6 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Cleanup completed. Deleted scripts:', deletedScripts);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -540,5 +510,467 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
deletedScripts: []
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Get container running statuses
|
||||
getContainerStatuses: publicProcedure
|
||||
.input(z.object({
|
||||
serverIds: z.array(z.number()).optional() // Optional: check specific servers, or all if empty
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
|
||||
const db = getDatabase();
|
||||
const allServers = db.getAllServers();
|
||||
const statusMap: Record<string, 'running' | 'stopped' | 'unknown'> = {};
|
||||
|
||||
// Import SSH services
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// Determine which servers to check
|
||||
const serversToCheck = input.serverIds
|
||||
? allServers.filter((s: any) => input.serverIds!.includes(Number(s.id)))
|
||||
: allServers;
|
||||
|
||||
|
||||
// Check status for each server
|
||||
for (const server of serversToCheck) {
|
||||
try {
|
||||
|
||||
// Test SSH connection
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||
if (!(connectionTest as any).success) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Run pct list to get all container statuses at once
|
||||
const listCommand = 'pct list';
|
||||
let listOutput = '';
|
||||
|
||||
// Add timeout to prevent hanging connections
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('SSH command timeout after 30 seconds')), 30000);
|
||||
});
|
||||
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
listCommand,
|
||||
(data: string) => {
|
||||
listOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`pct list error on server ${(server as any).name}:`, error);
|
||||
reject(new Error(error));
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
}),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
// Parse pct list output
|
||||
const lines = listOutput.split('\n').filter(line => line.trim());
|
||||
for (const line of lines) {
|
||||
// pct list format: CTID Status Name
|
||||
// Example: "100 running my-container"
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 3) {
|
||||
const containerId = parts[0];
|
||||
const status = parts[1];
|
||||
|
||||
if (containerId && status) {
|
||||
// Map pct list status to our status
|
||||
let mappedStatus: 'running' | 'stopped' | 'unknown' = 'unknown';
|
||||
if (status === 'running') {
|
||||
mappedStatus = 'running';
|
||||
} else if (status === 'stopped') {
|
||||
mappedStatus = 'stopped';
|
||||
}
|
||||
|
||||
statusMap[containerId] = mappedStatus;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing server ${(server as any).name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
success: true,
|
||||
statusMap
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getContainerStatuses:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch container statuses',
|
||||
statusMap: {}
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Get container status (running/stopped)
|
||||
getContainerStatus: publicProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const script = db.getInstalledScriptById(input.id);
|
||||
|
||||
if (!script) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script not found',
|
||||
status: 'unknown' as const
|
||||
};
|
||||
}
|
||||
|
||||
const scriptData = script as any;
|
||||
|
||||
// Only check status for SSH scripts with container_id
|
||||
if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script is not an SSH script with container ID',
|
||||
status: 'unknown' as const
|
||||
};
|
||||
}
|
||||
|
||||
// Get server info
|
||||
const server = db.getServerById(Number(scriptData.server_id));
|
||||
if (!server) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server not found',
|
||||
status: 'unknown' as const
|
||||
};
|
||||
}
|
||||
|
||||
// Import SSH services
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// Test SSH connection first
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||
if (!(connectionTest as any).success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
|
||||
status: 'unknown' as const
|
||||
};
|
||||
}
|
||||
|
||||
// Check container status
|
||||
const statusCommand = `pct status ${scriptData.container_id}`;
|
||||
let statusOutput = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
statusCommand,
|
||||
(data: string) => {
|
||||
statusOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error('Status command error:', error);
|
||||
reject(new Error(error));
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Parse status from output
|
||||
let status: 'running' | 'stopped' | 'unknown' = 'unknown';
|
||||
if (statusOutput.includes('status: running')) {
|
||||
status = 'running';
|
||||
} else if (statusOutput.includes('status: stopped')) {
|
||||
status = 'stopped';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status,
|
||||
error: undefined
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in getContainerStatus:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get container status',
|
||||
status: 'unknown' as const
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Control container (start/stop)
|
||||
controlContainer: publicProcedure
|
||||
.input(z.object({
|
||||
id: z.number(),
|
||||
action: z.enum(['start', 'stop'])
|
||||
}))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const script = db.getInstalledScriptById(input.id);
|
||||
|
||||
if (!script) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script not found'
|
||||
};
|
||||
}
|
||||
|
||||
const scriptData = script as any;
|
||||
|
||||
// Only control SSH scripts with container_id
|
||||
if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script is not an SSH script with container ID'
|
||||
};
|
||||
}
|
||||
|
||||
// Get server info
|
||||
const server = db.getServerById(Number(scriptData.server_id));
|
||||
if (!server) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Import SSH services
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// Test SSH connection first
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||
if (!(connectionTest as any).success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
|
||||
// Execute control command
|
||||
const controlCommand = `pct ${input.action} ${scriptData.container_id}`;
|
||||
let commandOutput = '';
|
||||
let commandError = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
controlCommand,
|
||||
(data: string) => {
|
||||
commandOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
commandError += error;
|
||||
},
|
||||
(exitCode: number) => {
|
||||
if (exitCode !== 0) {
|
||||
const errorMessage = commandError || commandOutput || `Command failed with exit code ${exitCode}`;
|
||||
reject(new Error(errorMessage));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Container ${scriptData.container_id} ${input.action} command executed successfully`,
|
||||
containerId: scriptData.container_id
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in controlContainer:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to control container'
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Destroy container and delete DB record
|
||||
destroyContainer: publicProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const db = getDatabase();
|
||||
const script = db.getInstalledScriptById(input.id);
|
||||
|
||||
if (!script) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script not found'
|
||||
};
|
||||
}
|
||||
|
||||
const scriptData = script as any;
|
||||
|
||||
// Only destroy SSH scripts with container_id
|
||||
if (scriptData.execution_mode !== 'ssh' || !scriptData.server_id || !scriptData.container_id) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script is not an SSH script with container ID'
|
||||
};
|
||||
}
|
||||
|
||||
// Get server info
|
||||
const server = db.getServerById(Number(scriptData.server_id));
|
||||
if (!server) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Server not found'
|
||||
};
|
||||
}
|
||||
|
||||
// Import SSH services
|
||||
const { default: SSHService } = await import('~/server/ssh-service');
|
||||
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||
const sshService = new SSHService();
|
||||
const sshExecutionService = new SSHExecutionService();
|
||||
|
||||
// Test SSH connection first
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||
if (!(connectionTest as any).success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`
|
||||
};
|
||||
}
|
||||
|
||||
// First check if container is running and stop it if necessary
|
||||
const statusCommand = `pct status ${scriptData.container_id}`;
|
||||
let statusOutput = '';
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
statusCommand,
|
||||
(data: string) => {
|
||||
statusOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
reject(new Error(error));
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Check if container is running
|
||||
if (statusOutput.includes('status: running')) {
|
||||
// Stop the container first
|
||||
const stopCommand = `pct stop ${scriptData.container_id}`;
|
||||
let stopOutput = '';
|
||||
let stopError = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
stopCommand,
|
||||
(data: string) => {
|
||||
stopOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
stopError += error;
|
||||
},
|
||||
(exitCode: number) => {
|
||||
if (exitCode !== 0) {
|
||||
const errorMessage = stopError || stopOutput || `Stop command failed with exit code ${exitCode}`;
|
||||
reject(new Error(`Failed to stop container: ${errorMessage}`));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
|
||||
}
|
||||
|
||||
// Execute destroy command
|
||||
const destroyCommand = `pct destroy ${scriptData.container_id}`;
|
||||
let commandOutput = '';
|
||||
let commandError = '';
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
void sshExecutionService.executeCommand(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
server as any,
|
||||
destroyCommand,
|
||||
(data: string) => {
|
||||
commandOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
commandError += error;
|
||||
},
|
||||
(exitCode: number) => {
|
||||
if (exitCode !== 0) {
|
||||
const errorMessage = commandError || commandOutput || `Destroy command failed with exit code ${exitCode}`;
|
||||
reject(new Error(errorMessage));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// If destroy was successful, delete the database record
|
||||
const deleteResult = db.deleteInstalledScript(input.id);
|
||||
|
||||
if (deleteResult.changes === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Container destroyed but failed to delete database record'
|
||||
};
|
||||
}
|
||||
|
||||
// Determine if container was stopped first
|
||||
const wasStopped = statusOutput.includes('status: running');
|
||||
const message = wasStopped
|
||||
? `Container ${scriptData.container_id} stopped and destroyed successfully, database record deleted`
|
||||
: `Container ${scriptData.container_id} destroyed successfully, database record deleted`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in destroyContainer:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to destroy container'
|
||||
};
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
@@ -27,6 +27,16 @@ export const scriptsRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
// Get all downloaded scripts from all directories
|
||||
getAllDownloadedScripts: publicProcedure
|
||||
.query(async () => {
|
||||
const scripts = await scriptManager.getAllDownloadedScripts();
|
||||
return {
|
||||
scripts,
|
||||
directoryInfo: scriptManager.getScriptsDirectoryInfo()
|
||||
};
|
||||
}),
|
||||
|
||||
|
||||
// Get script content for viewing
|
||||
getScriptContent: publicProcedure
|
||||
@@ -244,6 +254,58 @@ export const scriptsRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
// Load multiple scripts from GitHub
|
||||
loadMultipleScripts: publicProcedure
|
||||
.input(z.object({ slugs: z.array(z.string()) }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
const successful = [];
|
||||
const failed = [];
|
||||
|
||||
for (const slug of input.slugs) {
|
||||
try {
|
||||
// Get the script details
|
||||
const script = await localScriptsService.getScriptBySlug(slug);
|
||||
if (!script) {
|
||||
failed.push({ slug, error: 'Script not found' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load the script files
|
||||
const result = await scriptDownloaderService.loadScript(script);
|
||||
if (result.success) {
|
||||
successful.push({ slug, files: result.files });
|
||||
} else {
|
||||
const error = 'error' in result ? result.error : 'Failed to load script';
|
||||
failed.push({ slug, error });
|
||||
}
|
||||
} catch (error) {
|
||||
failed.push({
|
||||
slug,
|
||||
error: error instanceof Error ? error.message : 'Failed to load script'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Downloaded ${successful.length} scripts successfully, ${failed.length} failed`,
|
||||
successful,
|
||||
failed,
|
||||
total: input.slugs.length
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error in loadMultipleScripts:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to load multiple scripts',
|
||||
successful: [],
|
||||
failed: [],
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Check if script files exist locally
|
||||
checkScriptFiles: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
|
||||
@@ -11,6 +11,7 @@ interface GitHubRelease {
|
||||
name: string;
|
||||
published_at: string;
|
||||
html_url: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
// Helper function to fetch from GitHub API with optional authentication
|
||||
@@ -127,6 +128,43 @@ export const versionRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
// Get all releases for release notes
|
||||
getAllReleases: publicProcedure
|
||||
.query(async () => {
|
||||
try {
|
||||
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases');
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
}
|
||||
|
||||
const releases: GitHubRelease[] = await response.json();
|
||||
|
||||
// Sort by published date (newest first)
|
||||
const sortedReleases = releases
|
||||
.filter(release => !release.tag_name.includes('beta') && !release.tag_name.includes('alpha'))
|
||||
.sort((a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
releases: sortedReleases.map(release => ({
|
||||
tagName: release.tag_name,
|
||||
name: release.name,
|
||||
publishedAt: release.published_at,
|
||||
htmlUrl: release.html_url,
|
||||
body: release.body
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching all releases:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch releases',
|
||||
releases: []
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Get update logs from the log file
|
||||
getUpdateLogs: publicProcedure
|
||||
.query(async () => {
|
||||
|
||||
@@ -180,6 +180,10 @@ class DatabaseService {
|
||||
s.ip as server_ip,
|
||||
s.user as server_user,
|
||||
s.password as server_password,
|
||||
s.auth_type as server_auth_type,
|
||||
s.ssh_key as server_ssh_key,
|
||||
s.ssh_key_passphrase as server_ssh_key_passphrase,
|
||||
s.ssh_port as server_ssh_port,
|
||||
s.color as server_color
|
||||
FROM installed_scripts inst
|
||||
LEFT JOIN servers s ON inst.server_id = s.id
|
||||
|
||||
@@ -141,6 +141,95 @@ export class ScriptManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all downloaded scripts from all directories (ct, tools, vm, vw)
|
||||
*/
|
||||
async getAllDownloadedScripts(): Promise<ScriptInfo[]> {
|
||||
this.initializeConfig();
|
||||
const allScripts: ScriptInfo[] = [];
|
||||
|
||||
// Define all script directories to scan
|
||||
const scriptDirs = ['ct', 'tools', 'vm', 'vw'];
|
||||
|
||||
for (const dirName of scriptDirs) {
|
||||
try {
|
||||
const dirPath = join(this.scriptsDir!, dirName);
|
||||
|
||||
// Check if directory exists
|
||||
try {
|
||||
await stat(dirPath);
|
||||
} catch {
|
||||
// Directory doesn't exist, skip it
|
||||
continue;
|
||||
}
|
||||
|
||||
const scripts = await this.getScriptsFromDirectory(dirPath);
|
||||
allScripts.push(...scripts);
|
||||
} catch (error) {
|
||||
console.error(`Error reading ${dirName} scripts directory:`, error);
|
||||
// Continue with other directories even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
return allScripts.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scripts from a specific directory (recursively)
|
||||
*/
|
||||
private async getScriptsFromDirectory(dirPath: string): Promise<ScriptInfo[]> {
|
||||
const scripts: ScriptInfo[] = [];
|
||||
|
||||
const scanDirectory = async (currentPath: string, relativePath = ''): Promise<void> => {
|
||||
const files = await readdir(currentPath);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = join(currentPath, file);
|
||||
const stats = await stat(filePath);
|
||||
|
||||
if (stats.isFile()) {
|
||||
const extension = extname(file);
|
||||
|
||||
// Check if file extension is allowed
|
||||
if (this.allowedExtensions!.includes(extension)) {
|
||||
// Check if file is executable
|
||||
const executable = await this.isExecutable(filePath);
|
||||
|
||||
// Extract slug from filename (remove .sh extension)
|
||||
const slug = file.replace(/\.sh$/, '');
|
||||
|
||||
// Try to get logo from JSON data
|
||||
let logo: string | undefined;
|
||||
try {
|
||||
const scriptData = await localScriptsService.getScriptBySlug(slug);
|
||||
logo = scriptData?.logo ?? undefined;
|
||||
} catch {
|
||||
// JSON file might not exist, that's okay
|
||||
}
|
||||
|
||||
scripts.push({
|
||||
name: file,
|
||||
path: filePath,
|
||||
extension,
|
||||
size: stats.size,
|
||||
lastModified: stats.mtime,
|
||||
executable,
|
||||
logo,
|
||||
slug
|
||||
});
|
||||
}
|
||||
} else if (stats.isDirectory()) {
|
||||
// Recursively scan subdirectories
|
||||
const subRelativePath = relativePath ? join(relativePath, file) : file;
|
||||
await scanDirectory(filePath, subRelativePath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await scanDirectory(dirPath);
|
||||
return scripts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is executable
|
||||
*/
|
||||
|
||||
@@ -32,7 +32,9 @@ class SSHExecutionService {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ssh-key-'));
|
||||
const tempKeyPath = join(tempDir, 'private_key');
|
||||
|
||||
writeFileSync(tempKeyPath, ssh_key);
|
||||
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||
const normalizedKey = ssh_key.trimEnd() + '\n';
|
||||
writeFileSync(tempKeyPath, normalizedKey);
|
||||
chmodSync(tempKeyPath, 0o600); // Set proper permissions
|
||||
|
||||
return tempKeyPath;
|
||||
@@ -295,7 +297,7 @@ class SSHExecutionService {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
unlinkSync(tempDir);
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
@@ -314,7 +316,7 @@ class SSHExecutionService {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
unlinkSync(tempDir);
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
@@ -328,7 +330,7 @@ class SSHExecutionService {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
unlinkSync(tempDir);
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
@@ -383,7 +385,7 @@ class SSHExecutionService {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
unlinkSync(tempDir);
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
@@ -414,7 +416,7 @@ class SSHExecutionService {
|
||||
try {
|
||||
unlinkSync(tempKeyPath);
|
||||
const tempDir = tempKeyPath.substring(0, tempKeyPath.lastIndexOf('/'));
|
||||
unlinkSync(tempDir);
|
||||
rmdirSync(tempDir);
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up temporary SSH key file:', cleanupError);
|
||||
}
|
||||
|
||||
@@ -557,7 +557,9 @@ expect {
|
||||
tempKeyPath = join(tempDir, 'private_key');
|
||||
|
||||
// Write the private key to temporary file
|
||||
writeFileSync(tempKeyPath, ssh_key);
|
||||
// Normalize the key: trim any trailing whitespace and ensure exactly one newline at the end
|
||||
const normalizedKey = ssh_key.trimEnd() + '\n';
|
||||
writeFileSync(tempKeyPath, normalizedKey);
|
||||
chmodSync(tempKeyPath, 0o600); // Set proper permissions
|
||||
|
||||
// Build SSH command
|
||||
|
||||
78
update.sh
78
update.sh
@@ -170,7 +170,7 @@ get_latest_release() {
|
||||
echo "$tag_name|$download_url"
|
||||
}
|
||||
|
||||
# Backup data directory and .env file
|
||||
# Backup data directory, .env file, and scripts directories
|
||||
backup_data() {
|
||||
log "Creating backup directory at $BACKUP_DIR..."
|
||||
|
||||
@@ -205,6 +205,23 @@ backup_data() {
|
||||
else
|
||||
log_warning ".env file not found, skipping backup"
|
||||
fi
|
||||
|
||||
# Backup scripts directories
|
||||
local scripts_dirs=("scripts/ct" "scripts/install" "scripts/tools" "scripts/vm")
|
||||
for scripts_dir in "${scripts_dirs[@]}"; do
|
||||
if [ -d "$scripts_dir" ]; then
|
||||
log "Backing up $scripts_dir directory..."
|
||||
local backup_name=$(basename "$scripts_dir")
|
||||
if ! cp -r "$scripts_dir" "$BACKUP_DIR/$backup_name"; then
|
||||
log_error "Failed to backup $scripts_dir directory"
|
||||
exit 1
|
||||
else
|
||||
log_success "$scripts_dir directory backed up successfully"
|
||||
fi
|
||||
else
|
||||
log_warning "$scripts_dir directory not found, skipping backup"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Download and extract latest release
|
||||
@@ -287,6 +304,7 @@ clear_original_directory() {
|
||||
"*.backup"
|
||||
"*.bak"
|
||||
".git"
|
||||
"scripts"
|
||||
)
|
||||
|
||||
# Remove all files except preserved ones
|
||||
@@ -328,7 +346,7 @@ clear_original_directory() {
|
||||
|
||||
# Restore backup files before building
|
||||
restore_backup_files() {
|
||||
log "Restoring .env and data directory from backup..."
|
||||
log "Restoring .env, data directory, and scripts directories from backup..."
|
||||
|
||||
if [ -d "$BACKUP_DIR" ]; then
|
||||
# Restore .env file
|
||||
@@ -360,6 +378,34 @@ restore_backup_files() {
|
||||
else
|
||||
log_warning "No data directory backup found"
|
||||
fi
|
||||
|
||||
# Restore scripts directories
|
||||
local scripts_dirs=("ct" "install" "tools" "vm")
|
||||
for backup_name in "${scripts_dirs[@]}"; do
|
||||
if [ -d "$BACKUP_DIR/$backup_name" ]; then
|
||||
local target_dir="scripts/$backup_name"
|
||||
log "Restoring $target_dir directory from backup..."
|
||||
|
||||
# Ensure scripts directory exists
|
||||
if [ ! -d "scripts" ]; then
|
||||
mkdir -p "scripts"
|
||||
fi
|
||||
|
||||
# Remove existing directory if it exists
|
||||
if [ -d "$target_dir" ]; then
|
||||
rm -rf "$target_dir"
|
||||
fi
|
||||
|
||||
if mv "$BACKUP_DIR/$backup_name" "$target_dir"; then
|
||||
log_success "$target_dir directory restored from backup"
|
||||
else
|
||||
log_error "Failed to restore $target_dir directory"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log_warning "No $backup_name directory backup found"
|
||||
fi
|
||||
done
|
||||
else
|
||||
log_error "No backup directory found for restoration"
|
||||
return 1
|
||||
@@ -448,6 +494,7 @@ update_files() {
|
||||
"update.log"
|
||||
"*.backup"
|
||||
"*.bak"
|
||||
"scripts"
|
||||
)
|
||||
|
||||
# Find the actual source directory (strip the top-level directory)
|
||||
@@ -666,6 +713,33 @@ rollback() {
|
||||
log_warning "No .env file backup found"
|
||||
fi
|
||||
|
||||
# Restore scripts directories
|
||||
local scripts_dirs=("ct" "install" "tools" "vm")
|
||||
for backup_name in "${scripts_dirs[@]}"; do
|
||||
if [ -d "$BACKUP_DIR/$backup_name" ]; then
|
||||
local target_dir="scripts/$backup_name"
|
||||
log "Restoring $target_dir directory from backup..."
|
||||
|
||||
# Ensure scripts directory exists
|
||||
if [ ! -d "scripts" ]; then
|
||||
mkdir -p "scripts"
|
||||
fi
|
||||
|
||||
# Remove existing directory if it exists
|
||||
if [ -d "$target_dir" ]; then
|
||||
rm -rf "$target_dir"
|
||||
fi
|
||||
|
||||
if mv "$BACKUP_DIR/$backup_name" "$target_dir"; then
|
||||
log_success "$target_dir directory restored from backup"
|
||||
else
|
||||
log_error "Failed to restore $target_dir directory"
|
||||
fi
|
||||
else
|
||||
log_warning "No $backup_name directory backup found"
|
||||
fi
|
||||
done
|
||||
|
||||
# Clean up backup directory
|
||||
log "Cleaning up backup directory..."
|
||||
rm -rf "$BACKUP_DIR"
|
||||
|
||||
Reference in New Issue
Block a user