Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
e73cb2af90 chore: add VERSION v0.2.0 2025-10-07 14:14:50 +00:00
30 changed files with 999 additions and 1886 deletions

View File

@@ -16,4 +16,3 @@ ALLOWED_SCRIPT_PATHS="scripts/"
# WebSocket Configuration
WEBSOCKET_PORT="3001"
GITHUB_TOKEN=your_github_token_here

1
.github/CODEOWNERS vendored
View File

@@ -11,6 +11,5 @@
# Set default reviewers
* @michelroegl-brunner
* @community-scripts/Contributor

View File

@@ -1,6 +1,6 @@
# Template for release drafts
name-template: 'v$NEXT_PATCH_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
tag-template: 'v$NEXT_PATCH_VERSION'
name-template: 'v$NEXT_MINOR_VERSION' # You can switch to $NEXT_MINOR_VERSION or $NEXT_MAJOR_VERSION
tag-template: 'v$NEXT_MINOR_VERSION'
# Exclude PRs with this label from release notes
exclude-labels:

View File

@@ -2,11 +2,6 @@
A modern web-based management interface for Proxmox VE (PVE) helper scripts. This tool provides a user-friendly way to discover, download, and execute community-sourced Proxmox scripts locally with real-time terminal output streaming. No more need for curl -> bash calls, it all happens in your enviroment.
<img width="1725" height="1088" alt="image" src="https://github.com/user-attachments/assets/75323765-7375-4346-a41e-08d219275248" />
## 🎯 Deployment Options
This application can be deployed in multiple ways to suit different environments:

View File

@@ -1 +1 @@
0.2.2
0.2.0

296
package-lock.json generated
View File

@@ -34,7 +34,7 @@
"superjson": "^2.2.1",
"tailwind-merge": "^3.3.1",
"ws": "^8.18.3",
"zod": "^4.1.12"
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
@@ -51,7 +51,7 @@
"@vitest/ui": "^3.2.4",
"eslint": "^9.23.0",
"eslint-config-next": "^15.5.4",
"jsdom": "^27.0.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
@@ -96,59 +96,25 @@
}
},
"node_modules/@asamuzakjp/css-color": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz",
"integrity": "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==",
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@csstools/css-calc": "^2.1.4",
"@csstools/css-color-parser": "^3.1.0",
"@csstools/css-parser-algorithms": "^3.0.5",
"@csstools/css-tokenizer": "^3.0.4",
"lru-cache": "^11.2.1"
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
"@csstools/css-parser-algorithms": "^3.0.4",
"@csstools/css-tokenizer": "^3.0.3",
"lru-cache": "^10.4.3"
}
},
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "6.6.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.6.1.tgz",
"integrity": "sha512-8QT9pokVe1fUt1C8IrJketaeFOdRfTOS96DL3EBjE8CRZm3eHnwMlQe2NPoOSEYPwJ5Q25uYoX1+m9044l3ysQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.1.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.2.2"
}
},
"node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
"license": "ISC"
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
@@ -546,29 +512,6 @@
"@csstools/css-tokenizer": "^3.0.4"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.14.tgz",
"integrity": "sha512-zSlIxa20WvMojjpCSy8WrNpcZ61RqfTfX3XTaOeVlGJrt/8HF3YbzgFZa01yTbT4GWQLwfTcC3EB8i3XnB647Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"postcss": "^8.4"
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
@@ -2656,66 +2599,6 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.5.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.5.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.0.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.5.0",
"@emnapi/runtime": "^1.5.0",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.14",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz",
@@ -4273,16 +4156,6 @@
"node": "20.x || 22.x || 23.x || 24.x"
}
},
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
@@ -4660,20 +4533,6 @@
"node": ">= 8"
}
},
"node_modules/css-tree": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
"integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.12.2",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@@ -4682,18 +4541,17 @@
"license": "MIT"
},
"node_modules/cssstyle": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz",
"integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==",
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^4.0.3",
"@csstools/css-syntax-patches-for-csstree": "^1.0.14",
"css-tree": "^3.1.0"
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
},
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/csstype": {
@@ -4710,17 +4568,17 @@
"license": "BSD-2-Clause"
},
"node_modules/data-urls": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
"integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^15.0.0"
"whatwg-url": "^14.0.0"
},
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/data-view-buffer": {
@@ -7080,35 +6938,35 @@
}
},
"node_modules/jsdom": {
"version": "27.0.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz",
"integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==",
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/dom-selector": "^6.5.4",
"cssstyle": "^5.3.0",
"data-urls": "^6.0.0",
"cssstyle": "^4.2.1",
"data-urls": "^5.0.0",
"decimal.js": "^10.5.0",
"html-encoding-sniffer": "^4.0.0",
"http-proxy-agent": "^7.0.2",
"https-proxy-agent": "^7.0.6",
"is-potential-custom-element-name": "^1.0.1",
"parse5": "^7.3.0",
"nwsapi": "^2.2.16",
"parse5": "^7.2.1",
"rrweb-cssom": "^0.8.0",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.0",
"tough-cookie": "^5.1.1",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.0",
"webidl-conversions": "^7.0.0",
"whatwg-encoding": "^3.1.1",
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^15.0.0",
"ws": "^8.18.2",
"whatwg-url": "^14.1.1",
"ws": "^8.18.0",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=20"
"node": ">=18"
},
"peerDependencies": {
"canvas": "^3.0.0"
@@ -7613,13 +7471,6 @@
"node": ">= 0.4"
}
},
"node_modules/mdn-data": {
"version": "2.12.2",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
"integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -7908,6 +7759,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/nwsapi": {
"version": "2.2.22",
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz",
"integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==",
"dev": true,
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -8890,16 +8748,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -10047,22 +9895,22 @@
}
},
"node_modules/tldts": {
"version": "7.0.16",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.16.tgz",
"integrity": "sha512-5bdPHSwbKTeHmXrgecID4Ljff8rQjv7g8zKQPkCozRo2HWWni+p310FSn5ImI+9kWw9kK4lzOB5q/a6iv0IJsw==",
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.16"
"tldts-core": "^6.1.86"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.16",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.16.tgz",
"integrity": "sha512-XHhPmHxphLi+LGbH0G/O7dmUH9V65OY20R7vH8gETHsp5AZCjBk9l8sqmRKLaGOxnETU7XNSDUPtewAy/K6jbA==",
"version": "6.1.86",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT"
},
@@ -10090,29 +9938,29 @@
}
},
"node_modules/tough-cookie": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
"tldts": "^6.1.32"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/ts-api-utils": {
@@ -10636,13 +10484,13 @@
}
},
"node_modules/webidl-conversions": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz",
"integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
"node": ">=12"
}
},
"node_modules/whatwg-encoding": {
@@ -10669,17 +10517,17 @@
}
},
"node_modules/whatwg-url": {
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
"integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
"version": "14.2.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.0"
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
},
"engines": {
"node": ">=20"
"node": ">=18"
}
},
"node_modules/which": {
@@ -10973,9 +10821,9 @@
}
},
"node_modules/zod": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"

View File

@@ -48,7 +48,7 @@
"superjson": "^2.2.1",
"tailwind-merge": "^3.3.1",
"ws": "^8.18.3",
"zod": "^4.1.12"
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
@@ -65,7 +65,7 @@
"@vitest/ui": "^3.2.4",
"eslint": "^9.23.0",
"eslint-config-next": "^15.5.4",
"jsdom": "^27.0.0",
"jsdom": "^26.1.0",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",

View File

@@ -196,7 +196,7 @@ export function CategorySidebar({
return (
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
isCollapsed ? 'w-16' : 'w-full lg:w-80'
isCollapsed ? 'w-16' : 'w-80'
}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
@@ -292,7 +292,7 @@ export function CategorySidebar({
{/* Collapsed state - show only icons with counters and tooltips */}
{isCollapsed && (
<div className="p-2 flex flex-row lg:flex-col space-x-2 lg:space-x-0 lg:space-y-2 overflow-x-auto lg:overflow-x-visible">
<div className="p-2 flex flex-col space-y-2">
{/* "All Categories" option */}
<div className="group relative">
<button
@@ -317,7 +317,7 @@ export function CategorySidebar({
</button>
{/* Tooltip */}
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
All Categories ({totalScripts})
</div>
</div>
@@ -350,7 +350,7 @@ export function CategorySidebar({
</button>
{/* Tooltip */}
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50 hidden lg:block">
<div className="absolute left-full ml-2 top-1/2 transform -translate-y-1/2 bg-gray-900 text-white text-sm px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-50">
{category} ({count})
</div>
</div>

View File

@@ -69,7 +69,7 @@ export function DiffViewer({ scriptSlug, filePath, isOpen, onClose }: DiffViewer
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
onClick={handleBackdropClick}
>
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden border border-border mx-4 sm:mx-0">
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden border border-border">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div>

View File

@@ -320,9 +320,9 @@ export function DownloadedScriptsTab() {
</div>
</div>
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
<div className="flex gap-6">
{/* Category Sidebar */}
<div className="flex-shrink-0 order-2 lg:order-1">
<div className="flex-shrink-0">
<CategorySidebar
categories={categories}
categoryCounts={categoryCounts}
@@ -333,7 +333,7 @@ export function DownloadedScriptsTab() {
</div>
{/* Main Content */}
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
<div className="flex-1 min-w-0" ref={gridRef}>
{/* Enhanced Filter Bar */}
<FilterBar
filters={filters}

View File

@@ -61,8 +61,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
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-md w-full border border-border">
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50">
<div className="bg-card rounded-lg shadow-xl max-w-md w-full mx-4 border border-border">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border">
<h2 className="text-xl font-bold text-foreground">Execution Mode</h2>

View File

@@ -2,7 +2,6 @@
import React, { useState } from "react";
import { Button } from "./ui/button";
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
export interface FilterState {
searchQuery: string;
@@ -21,10 +20,10 @@ interface FilterBarProps {
}
const SCRIPT_TYPES = [
{ value: "ct", label: "LXC Container", Icon: Package },
{ value: "vm", label: "Virtual Machine", Icon: Monitor },
{ value: "addon", label: "Add-on", Icon: Wrench },
{ value: "pve", label: "PVE Host", Icon: Server },
{ value: "ct", label: "LXC Container", icon: "📦" },
{ value: "vm", label: "Virtual Machine", icon: "💻" },
{ value: "addon", label: "Add-on", icon: "🔧" },
{ value: "pve", label: "PVE Host", icon: "🖥️" },
];
export function FilterBar({
@@ -35,7 +34,6 @@ export function FilterBar({
updatableCount = 0,
}: FilterBarProps) {
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
const updateFilters = (updates: Partial<FilterState>) => {
onFiltersChange({ ...filters, ...updates });
@@ -77,10 +75,10 @@ export function FilterBar({
};
return (
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
<div className="mb-6 rounded-lg border border-border bg-card p-6 shadow-sm">
{/* Search Bar */}
<div className="mb-4">
<div className="relative max-w-md w-full">
<div className="relative max-w-md">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg
className="h-5 w-5 text-muted-foreground"
@@ -129,7 +127,7 @@ export function FilterBar({
</div>
{/* Filter Buttons */}
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
<div className="mb-4 flex flex-wrap gap-3">
{/* Updateable Filter */}
<Button
onClick={() => {
@@ -143,31 +141,29 @@ export function FilterBar({
}}
variant="outline"
size="default"
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
filters.showUpdatable === null
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
: filters.showUpdatable === true
? "border border-green-500/20 bg-green-500/10 text-green-400"
: "border border-destructive/20 bg-destructive/10 text-destructive"
}`}
className={`${
filters.showUpdatable === null
? "bg-muted text-muted-foreground hover:bg-accent"
: filters.showUpdatable === true
? "border border-green-500/20 bg-green-500/10 text-green-400"
: "border border-destructive/20 bg-destructive/10 text-destructive"
}`}
>
<RefreshCw className="h-4 w-4" />
<span>{getUpdatableButtonText()}</span>
{getUpdatableButtonText()}
</Button>
{/* Type Dropdown */}
<div className="relative w-full sm:w-auto">
<div className="relative">
<Button
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
variant="outline"
size="default"
className={`w-full flex items-center justify-center space-x-2 ${
className={`flex items-center space-x-2 ${
filters.selectedTypes.length === 0
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
? "bg-muted text-muted-foreground hover:bg-accent"
: "border border-primary/20 bg-primary/10 text-primary"
}`}
>
<Filter className="h-4 w-4" />
<span>{getTypeButtonText()}</span>
<svg
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
@@ -187,41 +183,38 @@ export function FilterBar({
{isTypeDropdownOpen && (
<div className="absolute top-full left-0 z-10 mt-1 w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="p-2">
{SCRIPT_TYPES.map((type) => {
const IconComponent = type.Icon;
return (
<label
key={type.value}
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
>
<input
type="checkbox"
checked={filters.selectedTypes.includes(type.value)}
onChange={(e) => {
if (e.target.checked) {
updateFilters({
selectedTypes: [
...filters.selectedTypes,
type.value,
],
});
} else {
updateFilters({
selectedTypes: filters.selectedTypes.filter(
(t) => t !== type.value,
),
});
}
}}
className="rounded border-input text-primary focus:ring-primary"
/>
<IconComponent className="h-4 w-4" />
<span className="text-sm text-muted-foreground">
{type.label}
</span>
</label>
);
})}
{SCRIPT_TYPES.map((type) => (
<label
key={type.value}
className="flex cursor-pointer items-center space-x-3 rounded-md px-3 py-2 hover:bg-accent"
>
<input
type="checkbox"
checked={filters.selectedTypes.includes(type.value)}
onChange={(e) => {
if (e.target.checked) {
updateFilters({
selectedTypes: [
...filters.selectedTypes,
type.value,
],
});
} else {
updateFilters({
selectedTypes: filters.selectedTypes.filter(
(t) => t !== type.value,
),
});
}
}}
className="rounded border-input text-primary focus:ring-primary"
/>
<span className="text-lg">{type.icon}</span>
<span className="text-sm text-muted-foreground">
{type.label}
</span>
</label>
))}
</div>
<div className="border-t border-border p-2">
<Button
@@ -240,122 +233,76 @@ export function FilterBar({
)}
</div>
{/* Sort By Dropdown */}
<div className="relative w-full sm:w-auto">
{/* Sort Options */}
<div className="flex items-center space-x-2">
{/* Sort By Dropdown */}
<select
value={filters.sortBy}
onChange={(e) =>
updateFilters({ sortBy: e.target.value as "name" | "created" })
}
className="rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-primary focus:outline-none"
>
<option value="name">📝 By Name</option>
<option value="created">📅 By Created Date</option>
</select>
{/* Sort Order Button */}
<Button
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
onClick={() =>
updateFilters({
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
})
}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
className="flex items-center space-x-1 bg-muted text-muted-foreground hover:bg-accent"
>
{filters.sortBy === "name" ? (
<FileText className="h-4 w-4" />
{filters.sortOrder === "asc" ? (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 11l5-5m0 0l5 5m-5-5v12"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
</span>
</>
) : (
<Calendar className="h-4 w-4" />
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 13l-5 5m0 0l-5-5m5 5V6"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
</span>
</>
)}
<span>{filters.sortBy === "name" ? "By Name" : "By Created Date"}</span>
<svg
className={`h-4 w-4 transition-transform ${isSortDropdownOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
{isSortDropdownOpen && (
<div className="absolute top-full left-0 z-10 mt-1 w-full sm:w-48 rounded-lg border border-border bg-card shadow-lg">
<div className="p-2">
<button
onClick={() => {
updateFilters({ sortBy: "name" });
setIsSortDropdownOpen(false);
}}
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
filters.sortBy === "name" ? "bg-primary/10 text-primary" : "text-muted-foreground"
}`}
>
<FileText className="h-4 w-4" />
<span className="text-sm">By Name</span>
</button>
<button
onClick={() => {
updateFilters({ sortBy: "created" });
setIsSortDropdownOpen(false);
}}
className={`w-full flex items-center space-x-3 rounded-md px-3 py-2 text-left hover:bg-accent ${
filters.sortBy === "created" ? "bg-primary/10 text-primary" : "text-muted-foreground"
}`}
>
<Calendar className="h-4 w-4" />
<span className="text-sm">By Created Date</span>
</button>
</div>
</div>
)}
</div>
{/* Sort Order Button */}
<Button
onClick={() =>
updateFilters({
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
})
}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-1 bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
{filters.sortOrder === "asc" ? (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M7 11l5-5m0 0l5 5m-5-5v12"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Oldest First" : "A-Z"}
</span>
</>
) : (
<>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 13l-5 5m0 0l-5-5m5 5V6"
/>
</svg>
<span>
{filters.sortBy === "created" ? "Newest First" : "Z-A"}
</span>
</>
)}
</Button>
</div>
{/* Filter Summary and Clear All */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{filteredCount === totalScripts ? (
<span>Showing all {totalScripts} scripts</span>
@@ -376,7 +323,7 @@ export function FilterBar({
onClick={clearAllFilters}
variant="ghost"
size="sm"
className="flex items-center space-x-1 text-red-600 hover:bg-red-50 hover:text-red-800 w-full sm:w-auto justify-center sm:justify-start"
className="flex items-center space-x-1 text-red-600 hover:bg-red-50 hover:text-red-800"
>
<svg
className="h-4 w-4"
@@ -396,14 +343,11 @@ export function FilterBar({
)}
</div>
{/* Click outside to close dropdowns */}
{(isTypeDropdownOpen || isSortDropdownOpen) && (
{/* Click outside to close dropdown */}
{isTypeDropdownOpen && (
<div
className="fixed inset-0 z-0"
onClick={() => {
setIsTypeDropdownOpen(false);
setIsSortDropdownOpen(false);
}}
onClick={() => setIsTypeDropdownOpen(false)}
/>
)}
</div>

View File

@@ -5,7 +5,6 @@ import { api } from '~/trpc/react';
import { Terminal } from './Terminal';
import { StatusBadge } from './Badge';
import { Button } from './ui/button';
import { ScriptInstallationCard } from './ScriptInstallationCard';
interface InstalledScript {
id: number;
@@ -264,9 +263,9 @@ export function InstalledScriptsTab() {
{/* Add Script Form */}
{showAddForm && (
<div className="mb-6 p-4 sm:p-6 bg-card rounded-lg border border-border shadow-sm">
<h3 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Add Manual Script Entry</h3>
<div className="space-y-4 sm:space-y-6">
<div className="mb-6 p-6 bg-card rounded-lg border border-border shadow-sm">
<h3 className="text-lg font-semibold text-foreground mb-6">Add Manual Script Entry</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">
Script Name *
@@ -309,12 +308,11 @@ export function InstalledScriptsTab() {
</select>
</div>
</div>
<div className="flex flex-col sm:flex-row justify-end gap-3 mt-4 sm:mt-6">
<div className="flex justify-end space-x-3 mt-6">
<Button
onClick={handleCancelAdd}
variant="outline"
size="default"
className="w-full sm:w-auto"
>
Cancel
</Button>
@@ -323,7 +321,6 @@ export function InstalledScriptsTab() {
disabled={createScriptMutation.isPending}
variant="default"
size="default"
className="w-full sm:w-auto"
>
{createScriptMutation.isPending ? 'Adding...' : 'Add Script'}
</Button>
@@ -332,9 +329,8 @@ export function InstalledScriptsTab() {
)}
{/* Filters */}
<div className="space-y-4">
{/* Search Input - Full Width on Mobile */}
<div className="w-full">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-64">
<input
type="text"
placeholder="Search scripts, container IDs, or servers..."
@@ -344,195 +340,169 @@ export function InstalledScriptsTab() {
/>
</div>
{/* Filter Dropdowns - Responsive Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="all">All Status</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="in_progress">In Progress</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
className="px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="all">All Status</option>
<option value="success">Success</option>
<option value="failed">Failed</option>
<option value="in_progress">In Progress</option>
</select>
<select
value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)}
className="w-full px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="all">All Servers</option>
<option value="local">Local</option>
{uniqueServers.map(server => (
<option key={server} value={server}>{server}</option>
))}
</select>
</div>
<select
value={serverFilter}
onChange={(e) => setServerFilter(e.target.value)}
className="px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="all">All Servers</option>
<option value="local">Local</option>
{uniqueServers.map(server => (
<option key={server} value={server}>{server}</option>
))}
</select>
</div>
</div>
{/* Scripts Display - Mobile Cards / Desktop Table */}
{/* Scripts Table */}
<div className="bg-card rounded-lg shadow overflow-hidden">
{filteredScripts.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
</div>
) : (
<>
{/* Mobile Card Layout */}
<div className="block md:hidden p-4 space-y-4">
{filteredScripts.map((script) => (
<ScriptInstallationCard
key={script.id}
script={script}
isEditing={editingScriptId === script.id}
editFormData={editFormData}
onInputChange={handleInputChange}
onEdit={() => handleEditScript(script)}
onSave={handleSaveEdit}
onCancel={handleCancelEdit}
onUpdate={() => handleUpdateScript(script)}
onDelete={() => handleDeleteScript(Number(script.id))}
isUpdating={updateScriptMutation.isPending}
isDeleting={deleteScriptMutation.isPending}
/>
))}
</div>
{/* Desktop Table Layout */}
<div className="hidden md:block overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Script Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Container ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Server
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Installation Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-card divide-y divide-gray-200">
{filteredScripts.map((script) => (
<tr key={script.id} className="hover:bg-accent">
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="space-y-2">
<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"
placeholder="Script name"
/>
<div className="text-xs text-muted-foreground">{script.script_path}</div>
</div>
) : (
<div>
<div className="text-sm font-medium text-foreground">{script.script_name}</div>
<div className="text-sm text-muted-foreground">{script.script_path}</div>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-muted">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Script Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Container ID
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Server
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Installation Date
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-card divide-y divide-gray-200">
{filteredScripts.map((script) => (
<tr key={script.id} className="hover:bg-accent">
<td className="px-6 py-4 whitespace-nowrap">
{editingScriptId === script.id ? (
<div className="space-y-2">
<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"
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"
placeholder="Script name"
/>
) : (
script.container_id ? (
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
) : (
<span className="text-sm text-muted-foreground">-</span>
)
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-muted-foreground">
{script.server_name ?? 'Local'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={script.status}>
{script.status.replace('_', ' ').toUpperCase()}
</StatusBadge>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{formatDate(String(script.installation_date))}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
{editingScriptId === script.id ? (
<>
<Button
onClick={handleSaveEdit}
disabled={updateScriptMutation.isPending}
variant="default"
size="sm"
>
{updateScriptMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button
onClick={handleCancelEdit}
variant="outline"
size="sm"
>
Cancel
</Button>
</>
) : (
<>
<Button
onClick={() => handleEditScript(script)}
variant="default"
size="sm"
>
Edit
</Button>
{script.container_id && (
<Button
onClick={() => handleUpdateScript(script)}
variant="link"
size="sm"
>
Update
</Button>
)}
<Button
onClick={() => handleDeleteScript(Number(script.id))}
variant="destructive"
size="sm"
disabled={deleteScriptMutation.isPending}
>
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</>
)}
<div className="text-xs text-muted-foreground">{script.script_path}</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
<div>
<div className="text-sm font-medium text-foreground">{script.script_name}</div>
<div className="text-sm text-muted-foreground">{script.script_path}</div>
</div>
)}
</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"
/>
) : (
script.container_id ? (
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
) : (
<span className="text-sm text-muted-foreground">-</span>
)
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm text-muted-foreground">
{script.server_name ?? 'Local'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={script.status}>
{script.status.replace('_', ' ').toUpperCase()}
</StatusBadge>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
{formatDate(String(script.installation_date))}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
{editingScriptId === script.id ? (
<>
<Button
onClick={handleSaveEdit}
disabled={updateScriptMutation.isPending}
variant="default"
size="sm"
>
{updateScriptMutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button
onClick={handleCancelEdit}
variant="outline"
size="sm"
>
Cancel
</Button>
</>
) : (
<>
<Button
onClick={() => handleEditScript(script)}
variant="default"
size="sm"
>
Edit
</Button>
{script.container_id && (
<Button
onClick={() => handleUpdateScript(script)}
variant="link"
size="sm"
>
Update
</Button>
)}
<Button
onClick={() => handleDeleteScript(Number(script.id))}
variant="destructive"
size="sm"
disabled={deleteScriptMutation.isPending}
>
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
</Button>
</>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>

View File

@@ -63,20 +63,20 @@ export function ScriptDetailModal({
if (data.success) {
const message =
"message" in data ? data.message : "Script loaded successfully";
setLoadMessage(`[SUCCESS] ${message}`);
setLoadMessage(` ${message}`);
// Refetch script files status and comparison data to update the UI
void refetchScriptFiles();
void refetchComparison();
} else {
const error = "error" in data ? data.error : "Failed to load script";
setLoadMessage(`[ERROR] ${error}`);
setLoadMessage(` ${error}`);
}
// Clear message after 5 seconds
setTimeout(() => setLoadMessage(null), 5000);
},
onError: (error) => {
setIsLoading(false);
setLoadMessage(`[ERROR] ${error.message}`);
setLoadMessage(`❌ Error: ${error.message}`);
setTimeout(() => setLoadMessage(null), 5000);
},
});
@@ -136,63 +136,38 @@ export function ScriptDetailModal({
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
onClick={handleBackdropClick}
>
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border mx-2 sm:mx-4 lg:mx-0">
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[95vh] min-h-[80vh] overflow-y-auto border border-border">
{/* Header */}
<div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
<div className="flex items-center justify-between border-b border-border p-6">
<div className="flex items-center space-x-4">
{script.logo && !imageError ? (
<Image
src={script.logo}
alt={`${script.name} logo`}
width={64}
height={64}
className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
className="h-16 w-16 rounded-lg object-contain"
onError={handleImageError}
/>
) : (
<div className="flex h-12 w-12 sm:h-16 sm:w-16 items-center justify-center rounded-lg bg-muted flex-shrink-0">
<span className="text-lg sm:text-2xl font-semibold text-muted-foreground">
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-muted">
<span className="text-2xl font-semibold text-muted-foreground">
{script.name.charAt(0).toUpperCase()}
</span>
</div>
)}
<div className="min-w-0 flex-1">
<h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
<div>
<h2 className="text-2xl font-bold text-foreground">
{script.name}
</h2>
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
<div className="mt-1 flex items-center space-x-2">
<TypeBadge type={script.type} />
{script.updateable && <UpdateableBadge />}
{script.privileged && <PrivilegedBadge />}
</div>
</div>
</div>
{/* Close Button */}
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground flex-shrink-0 ml-4"
>
<svg
className="h-5 w-5 sm:h-6 sm:w-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>
{/* Action Buttons */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2 p-4 sm:p-6 border-b border-border">
<div className="flex items-center space-x-4">
{/* Install Button - only show if script files exist */}
{scriptFilesData?.success &&
scriptFilesData.ctExists &&
@@ -201,7 +176,7 @@ export function ScriptDetailModal({
onClick={handleInstallScript}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2"
className="flex items-center space-x-2"
>
<svg
className="h-4 w-4"
@@ -227,7 +202,7 @@ export function ScriptDetailModal({
onClick={handleViewScript}
variant="outline"
size="default"
className="w-full sm:w-auto flex items-center justify-center space-x-2"
className="flex items-center space-x-2 "
>
<svg
className="h-4 w-4"
@@ -360,18 +335,39 @@ export function ScriptDetailModal({
);
}
})()}
<Button
onClick={onClose}
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
>
<svg
className="h-6 w-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>
{/* Load Message */}
{loadMessage && (
<div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
<div className="mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
{loadMessage}
</div>
)}
{/* Script Files Status */}
{(scriptFilesLoading || comparisonLoading) && (
<div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
<div className="mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
<div className="flex items-center space-x-2">
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
<span>Loading script status...</span>
@@ -396,8 +392,8 @@ export function ScriptDetailModal({
}
return (
<div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
<div className="mx-6 mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<div
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-muted"}`}
@@ -437,7 +433,7 @@ export function ScriptDetailModal({
)}
</div>
{scriptFilesData.files.length > 0 && (
<div className="mt-2 text-xs text-muted-foreground break-words">
<div className="mt-2 text-xs text-muted-foreground">
Files: {scriptFilesData.files.join(", ")}
</div>
)}
@@ -446,21 +442,21 @@ export function ScriptDetailModal({
})()}
{/* Content */}
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
<div className="space-y-6 p-6">
{/* Description */}
<div>
<h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
<h3 className="mb-2 text-lg font-semibold text-foreground">
Description
</h3>
<p className="text-sm sm:text-base text-muted-foreground">
<p className="text-muted-foreground">
{script.description}
</p>
</div>
{/* Basic Information */}
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<h3 className="mb-3 text-lg font-semibold text-foreground">
Basic Information
</h3>
<dl className="space-y-2">
@@ -512,7 +508,7 @@ export function ScriptDetailModal({
</div>
<div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<h3 className="mb-3 text-lg font-semibold text-foreground">
Links
</h3>
<dl className="space-y-2">
@@ -559,24 +555,24 @@ export function ScriptDetailModal({
script.type !== "pve" &&
script.type !== "addon" && (
<div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<h3 className="mb-3 text-lg font-semibold text-foreground">
Install Methods
</h3>
<div className="space-y-4">
{script.install_methods.map((method, index) => (
<div
key={index}
className="rounded-lg border border-border bg-card p-3 sm:p-4"
className="rounded-lg border border-border bg-card p-4"
>
<div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between space-y-1 sm:space-y-0">
<h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
<div className="mb-3 flex items-center justify-between">
<h4 className="font-medium text-foreground capitalize">
{method.type}
</h4>
<span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
<span className="font-mono text-sm text-muted-foreground">
{method.script}
</span>
</div>
<div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div>
<dt className="font-medium text-muted-foreground">
CPU
@@ -620,7 +616,7 @@ export function ScriptDetailModal({
{(script.default_credentials.username ??
script.default_credentials.password) && (
<div>
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
<h3 className="mb-3 text-lg font-semibold text-foreground">
Default Credentials
</h3>
<dl className="space-y-2">

View File

@@ -1,175 +0,0 @@
'use client';
import { Button } from './ui/button';
import { StatusBadge } from './Badge';
interface InstalledScript {
id: number;
script_name: string;
script_path: string;
container_id: string | null;
server_id: number | null;
server_name: string | null;
server_ip: string | null;
server_user: string | null;
server_password: string | null;
installation_date: string;
status: 'in_progress' | 'success' | 'failed';
output_log: string | null;
}
interface ScriptInstallationCardProps {
script: InstalledScript;
isEditing: boolean;
editFormData: { script_name: string; container_id: string };
onInputChange: (field: 'script_name' | 'container_id', value: string) => void;
onEdit: () => void;
onSave: () => void;
onCancel: () => void;
onUpdate: () => void;
onDelete: () => void;
isUpdating: boolean;
isDeleting: boolean;
}
export function ScriptInstallationCard({
script,
isEditing,
editFormData,
onInputChange,
onEdit,
onSave,
onCancel,
onUpdate,
onDelete,
isUpdating,
isDeleting
}: ScriptInstallationCardProps) {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleString();
};
return (
<div className="bg-card border border-border rounded-lg p-4 shadow-sm hover:shadow-md transition-shadow">
{/* Header with Script Name and Status */}
<div className="flex items-start justify-between mb-3">
<div className="flex-1 min-w-0">
{isEditing ? (
<div className="space-y-2">
<input
type="text"
value={editFormData.script_name}
onChange={(e) => onInputChange('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"
placeholder="Script name"
/>
<div className="text-xs text-muted-foreground">{script.script_path}</div>
</div>
) : (
<div>
<div className="text-sm font-medium text-foreground truncate">{script.script_name}</div>
<div className="text-xs text-muted-foreground truncate">{script.script_path}</div>
</div>
)}
</div>
<div className="ml-2 flex-shrink-0">
<StatusBadge status={script.status}>
{script.status.replace('_', ' ').toUpperCase()}
</StatusBadge>
</div>
</div>
{/* Details Grid */}
<div className="grid grid-cols-1 gap-3 mb-4">
{/* Container ID */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Container ID</div>
{isEditing ? (
<input
type="text"
value={editFormData.container_id}
onChange={(e) => onInputChange('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="text-sm font-mono text-foreground break-all">
{script.container_id ?? '-'}
</div>
)}
</div>
{/* Server */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Server</div>
<div className="text-sm text-muted-foreground">
{script.server_name ?? 'Local'}
</div>
</div>
{/* Installation Date */}
<div>
<div className="text-xs font-medium text-muted-foreground mb-1">Installation Date</div>
<div className="text-sm text-muted-foreground">
{formatDate(String(script.installation_date))}
</div>
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-2">
{isEditing ? (
<>
<Button
onClick={onSave}
disabled={isUpdating}
variant="default"
size="sm"
className="flex-1 min-w-0"
>
{isUpdating ? 'Saving...' : 'Save'}
</Button>
<Button
onClick={onCancel}
variant="outline"
size="sm"
className="flex-1 min-w-0"
>
Cancel
</Button>
</>
) : (
<>
<Button
onClick={onEdit}
variant="default"
size="sm"
className="flex-1 min-w-0"
>
Edit
</Button>
{script.container_id && (
<Button
onClick={onUpdate}
variant="link"
size="sm"
className="flex-1 min-w-0"
>
Update
</Button>
)}
<Button
onClick={onDelete}
variant="destructive"
size="sm"
disabled={isDeleting}
className="flex-1 min-w-0"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</Button>
</>
)}
</div>
</div>
);
}

View File

@@ -316,9 +316,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
}
return (
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
<div className="flex gap-6">
{/* Category Sidebar */}
<div className="flex-shrink-0 order-2 lg:order-1">
<div className="flex-shrink-0">
<CategorySidebar
categories={categories}
categoryCounts={categoryCounts}
@@ -329,7 +329,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
</div>
{/* Main Content */}
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
<div className="flex-1 min-w-0" ref={gridRef}>
{/* Enhanced Filter Bar */}
<FilterBar
filters={filters}

View File

@@ -74,7 +74,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
Server Name *
@@ -144,14 +144,13 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
</div>
</div>
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
<div className="flex justify-end space-x-3 pt-4">
{isEditing && onCancel && (
<Button
type="button"
onClick={onCancel}
variant="outline"
size="default"
className="w-full sm:w-auto order-2 sm:order-1"
>
Cancel
</Button>
@@ -160,7 +159,6 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
type="submit"
variant="default"
size="default"
className="w-full sm:w-auto order-1 sm:order-2"
>
{isEditing ? 'Update Server' : 'Add Server'}
</Button>

View File

@@ -102,30 +102,30 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
/>
</div>
) : (
<div className="flex flex-col sm:flex-row sm:items-center justify-between space-y-4 sm:space-y-0">
<div className="flex-1 min-w-0">
<div className="flex items-start sm:items-center space-x-3">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 sm:w-6 sm:h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
</div>
<div className="flex-1 min-w-0">
<h3 className="text-base sm:text-lg font-medium text-foreground truncate">{server.name}</h3>
<div className="mt-1 flex flex-col sm:flex-row sm:items-center space-y-1 sm:space-y-0 sm:space-x-4 text-sm text-muted-foreground">
<h3 className="text-lg font-medium text-foreground truncate">{server.name}</h3>
<div className="mt-1 flex items-center space-x-4 text-sm text-muted-foreground">
<span className="flex items-center">
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9v-9m0-9v9" />
</svg>
<span className="truncate">{server.ip}</span>
{server.ip}
</span>
<span className="flex items-center">
<svg className="w-4 h-4 mr-1 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
<span className="truncate">{server.user}</span>
{server.user}
</span>
</div>
<div className="mt-1 text-xs text-muted-foreground">
@@ -162,58 +162,51 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
</div>
</div>
</div>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2">
<div className="flex items-center space-x-2">
<Button
onClick={() => handleTestConnection(server)}
disabled={testingConnections.has(server.id)}
variant="outline"
size="sm"
className="w-full sm:w-auto border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
className="border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
>
{testingConnections.has(server.id) ? (
<>
<svg className="w-4 h-4 mr-1 animate-spin" 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 className="hidden sm:inline">Testing...</span>
<span className="sm:hidden">Test...</span>
Testing...
</>
) : (
<>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="hidden sm:inline">Test Connection</span>
<span className="sm:hidden">Test</span>
Test Connection
</>
)}
</Button>
<div className="flex space-x-2">
<Button
onClick={() => handleEdit(server)}
variant="outline"
size="sm"
className="flex-1 sm:flex-none"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
<span className="hidden sm:inline">Edit</span>
<span className="sm:hidden"></span>
</Button>
<Button
onClick={() => handleDelete(server.id)}
variant="outline"
size="sm"
className="flex-1 sm:flex-none border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
<span className="hidden sm:inline">Delete</span>
<span className="sm:hidden">🗑</span>
</Button>
</div>
<Button
onClick={() => handleEdit(server)}
variant="outline"
size="sm"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
Edit
</Button>
<Button
onClick={() => handleDelete(server.id)}
variant="outline"
size="sm"
className="border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</Button>
</div>
</div>
)}

View File

@@ -99,18 +99,18 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
if (!isOpen) 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-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50">
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full mx-4 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 justify-between p-6 border-b border-border">
<h2 className="text-2xl font-bold text-card-foreground">Settings</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">
<svg className="w-6 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>
@@ -118,12 +118,12 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{/* Tabs */}
<div className="border-b border-gray-200">
<nav className="flex flex-col sm:flex-row space-y-1 sm:space-y-0 sm:space-x-8 px-4 sm:px-6">
<nav className="flex space-x-8 px-6">
<Button
onClick={() => setActiveTab('servers')}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'servers'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
@@ -135,7 +135,7 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
onClick={() => setActiveTab('general')}
variant="ghost"
size="null"
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
className={`py-4 px-1 border-b-2 font-medium text-sm ${
activeTab === 'general'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
@@ -147,32 +147,32 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
</div>
{/* Content */}
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
{error && (
<div className="mb-4 p-3 sm:p-4 bg-destructive/10 border border-destructive rounded-md">
<div className="mb-4 p-4 bg-destructive/10 border border-destructive rounded-md">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-4 w-4 sm:h-5 sm:w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<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>
</div>
<div className="ml-2 sm:ml-3 min-w-0 flex-1">
<h3 className="text-xs sm:text-sm font-medium text-red-800">Error</h3>
<div className="mt-1 sm:mt-2 text-xs sm:text-sm text-red-700 break-words">{error}</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800">Error</h3>
<div className="mt-2 text-sm text-red-700">{error}</div>
</div>
</div>
</div>
)}
{activeTab === 'servers' && (
<div className="space-y-4 sm:space-y-6">
<div className="space-y-6">
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Server Configurations</h3>
<h3 className="text-lg font-medium text-foreground mb-4">Server Configurations</h3>
<ServerForm onSubmit={handleCreateServer} />
</div>
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Saved Servers</h3>
<h3 className="text-lg font-medium text-foreground mb-4">Saved Servers</h3>
{loading ? (
<div className="text-center py-8 text-muted-foreground">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
@@ -191,8 +191,8 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
{activeTab === 'general' && (
<div>
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">General Settings</h3>
<p className="text-sm sm:text-base text-muted-foreground">General settings will be available in a future update.</p>
<h3 className="text-lg font-medium text-foreground mb-4">General Settings</h3>
<p className="text-muted-foreground">General settings will be available in a future update.</p>
</div>
)}
</div>

View File

@@ -1,9 +1,8 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import '@xterm/xterm/css/xterm.css';
import { Button } from './ui/button';
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
interface TerminalProps {
scriptPath: string;
@@ -24,11 +23,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const [isConnected, setIsConnected] = useState(false);
const [isRunning, setIsRunning] = useState(false);
const [isClient, setIsClient] = useState(false);
const [mobileInput, setMobileInput] = useState('');
const [showMobileInput, setShowMobileInput] = useState(false);
const [lastInputSent, setLastInputSent] = useState<string | null>(null);
const [inWhiptailSession, setInWhiptailSession] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null);
const xtermRef = useRef<any>(null);
const fitAddonRef = useRef<any>(null);
@@ -39,125 +33,31 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
const handleMessage = useCallback((message: TerminalMessage) => {
if (!xtermRef.current) return;
const timestamp = new Date(message.timestamp).toLocaleTimeString();
const prefix = `[${timestamp}] `;
switch (message.type) {
case 'start':
xtermRef.current.writeln(`${prefix}[START] ${message.data}`);
setIsRunning(true);
break;
case 'output':
// Write directly to terminal - xterm.js handles ANSI codes natively
// Detect whiptail sessions and clear immediately
if (message.data.includes('whiptail') || message.data.includes('dialog') || message.data.includes('Choose an option')) {
setInWhiptailSession(true);
// Clear terminal immediately when whiptail starts
xtermRef.current.clear();
xtermRef.current.write('\x1b[2J\x1b[H');
}
// Check for screen clearing sequences and handle them properly
if (message.data.includes('\x1b[2J') || message.data.includes('\x1b[H\x1b[2J')) {
// This is a clear screen sequence, ensure it's processed correctly
xtermRef.current.write(message.data);
} else if (message.data.includes('\x1b[') && message.data.includes('H')) {
// This is a cursor positioning sequence, often implies a redraw of the entire screen
if (inWhiptailSession) {
// In whiptail session, completely reset the terminal
// Completely clear everything
xtermRef.current.clear();
xtermRef.current.write('\x1b[2J\x1b[H\x1b[3J\x1b[2J');
// Reset the terminal state
xtermRef.current.reset();
// Write the new content after reset
setTimeout(() => {
xtermRef.current.write(message.data);
}, 100);
} else {
xtermRef.current.write(message.data);
}
} else {
xtermRef.current.write(message.data);
}
break;
case 'error':
// Check if this looks like ANSI terminal output (contains escape codes)
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
// This is likely terminal output sent to stderr, treat it as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('TERM environment variable not set')) {
// This is a common warning, treat as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
// This is a script error, show it with error prefix
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
} else {
// This is a real error, show it with error prefix
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
}
break;
case 'end':
// Reset whiptail session
setInWhiptailSession(false);
// Check if this is an LXC creation script
const isLxcCreation = scriptPath.includes('ct/') ||
scriptPath.includes('create_lxc') ||
(containerId != null) ||
scriptName.includes('lxc') ||
scriptName.includes('container');
if (isLxcCreation && message.data.includes('SSH script execution finished with code: 0')) {
// Display prominent LXC creation completion message
xtermRef.current.writeln('');
xtermRef.current.writeln('#########################################');
xtermRef.current.writeln('########## LXC CREATION FINISHED ########');
xtermRef.current.writeln('#########################################');
xtermRef.current.writeln('');
} else {
xtermRef.current.writeln(`${prefix}${message.data}`);
}
setIsRunning(false);
break;
}
}, [scriptPath, containerId, scriptName, inWhiptailSession]);
// Ensure we're on the client side
useEffect(() => {
setIsClient(true);
// Detect mobile on mount
setIsMobile(window.innerWidth < 768);
}, []);
useEffect(() => {
// Only initialize on client side
if (!isClient || !terminalRef.current || xtermRef.current) return;
// Store ref value to avoid stale closure
const terminalElement = terminalRef.current;
// Use setTimeout to ensure DOM is fully ready
const initTerminal = async () => {
if (!terminalElement || xtermRef.current) return;
if (!terminalRef.current || xtermRef.current) return;
// Dynamically import xterm modules to avoid SSR issues
const { Terminal: XTerm } = await import('@xterm/xterm');
const { FitAddon } = await import('@xterm/addon-fit');
const { WebLinksAddon } = await import('@xterm/addon-web-links');
// Use the mobile state
const terminal = new XTerm({
theme: {
background: '#000000',
foreground: '#00ff00',
cursor: '#00ff00',
},
fontSize: isMobile ? 7 : 14,
fontSize: 14,
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
cursorBlink: true,
cursorStyle: 'block',
@@ -169,12 +69,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
macOptionIsMeta: false,
rightClickSelectsWord: false,
wordSeparator: ' ()[]{}\'"`<>|',
// Better ANSI handling
allowProposedApi: true,
// Force proper terminal behavior for interactive applications
// Use smaller dimensions on mobile but ensure proper fit
cols: isMobile ? 45 : 80,
rows: isMobile ? 18 : 24,
});
// Add addons
@@ -182,41 +76,15 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
const webLinksAddon = new WebLinksAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
// Enable better ANSI handling
terminal.options.allowProposedApi = true;
// Open terminal
terminal.open(terminalElement);
terminal.open(terminalRef.current);
// Fit after a small delay to ensure proper sizing
setTimeout(() => {
fitAddon.fit();
// Force fit multiple times for mobile to ensure proper sizing
if (isMobile) {
setTimeout(() => {
fitAddon.fit();
setTimeout(() => {
fitAddon.fit();
}, 200);
}, 300);
}
}, 100);
// Add resize listener for mobile responsiveness
const handleResize = () => {
if (fitAddonRef.current) {
setTimeout(() => {
fitAddonRef.current.fit();
}, 50);
}
};
window.addEventListener('resize', handleResize);
// Store the handler for cleanup
(terminalElement as any).resizeHandler = handleResize;
// Store references
xtermRef.current = terminal;
fitAddonRef.current = fitAddon;
@@ -224,16 +92,25 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
// Handle terminal input
terminal.onData((data) => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const message = {
wsRef.current.send(JSON.stringify({
action: 'input',
executionId,
input: data
};
wsRef.current.send(JSON.stringify(message));
}));
}
});
// Handle terminal resize
const handleResize = () => {
if (fitAddonRef.current) {
fitAddonRef.current.fit();
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
terminal.dispose();
};
};
@@ -245,16 +122,13 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
return () => {
clearTimeout(timeoutId);
if (terminalElement && (terminalElement as any).resizeHandler) {
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
}
if (xtermRef.current) {
xtermRef.current.dispose();
xtermRef.current = null;
fitAddonRef.current = null;
}
};
}, [executionId, isClient, inWhiptailSession, isMobile]);
}, [executionId, isClient]);
useEffect(() => {
// Prevent multiple connections in React Strict Mode
@@ -300,7 +174,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data as string) as TerminalMessage;
console.log('WebSocket message received:', message);
handleMessage(message);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
@@ -332,7 +205,45 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
wsRef.current.close();
}
};
}, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile]);
}, [scriptPath, executionId, mode, server, isUpdate, containerId]);
const handleMessage = (message: TerminalMessage) => {
if (!xtermRef.current) return;
const timestamp = new Date(message.timestamp).toLocaleTimeString();
const prefix = `[${timestamp}] `;
switch (message.type) {
case 'start':
xtermRef.current.writeln(`${prefix}🚀 ${message.data}`);
setIsRunning(true);
break;
case 'output':
// Write directly to terminal - xterm.js handles ANSI codes natively
xtermRef.current.write(message.data);
break;
case 'error':
// Check if this looks like ANSI terminal output (contains escape codes)
if (message.data.includes('\x1B[') || message.data.includes('\u001b[')) {
// This is likely terminal output sent to stderr, treat it as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('TERM environment variable not set')) {
// This is a common warning, treat as normal output
xtermRef.current.write(message.data);
} else if (message.data.includes('exit code') && message.data.includes('clear')) {
// This is a script error, show it with error prefix
xtermRef.current.writeln(`${prefix}${message.data}`);
} else {
// This is a real error, show it with error prefix
xtermRef.current.writeln(`${prefix}${message.data}`);
}
break;
case 'end':
xtermRef.current.writeln(`${prefix}${message.data}`);
setIsRunning(false);
break;
}
};
const startScript = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
@@ -363,30 +274,6 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
}
};
const sendInput = (input: string) => {
setLastInputSent(input);
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
const message = {
action: 'input',
executionId,
input: input
};
wsRef.current.send(JSON.stringify(message));
// Clear the feedback after 2 seconds
setTimeout(() => setLastInputSent(null), 2000);
}
};
const handleMobileInput = (input: string) => {
sendInput(input);
setMobileInput('');
};
const handleEnterKey = () => {
sendInput('\r');
};
// Don't render on server side
if (!isClient) {
return (
@@ -413,21 +300,21 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
return (
<div className="bg-card rounded-lg border border-border overflow-hidden">
{/* Terminal Header */}
<div className="bg-muted px-2 sm:px-4 py-2 flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-2 min-w-0 flex-1">
<div className="flex space-x-1 flex-shrink-0">
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-red-500 rounded-full"></div>
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-yellow-500 rounded-full"></div>
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-green-500 rounded-full"></div>
<div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border">
<div className="flex items-center space-x-2">
<div className="flex space-x-1">
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
<div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
<div className="w-3 h-3 bg-green-500 rounded-full"></div>
</div>
<span className="text-foreground font-mono text-xs sm:text-sm ml-1 sm:ml-2 truncate">
<span className="text-foreground font-mono text-sm ml-2">
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
</span>
</div>
<div className="flex items-center space-x-1 sm:space-x-2 flex-shrink-0">
<div className="flex items-center space-x-2">
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
<span className="text-muted-foreground text-xs hidden sm:inline">
<span className="text-muted-foreground text-xs">
{isConnected ? 'Connected' : 'Disconnected'}
</span>
</div>
@@ -436,164 +323,21 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
{/* Terminal Output */}
<div
ref={terminalRef}
className={`h-[16rem] sm:h-[24rem] lg:h-[32rem] w-full max-w-4xl mx-auto ${isMobile ? 'mobile-terminal' : ''}`}
style={{
minHeight: '256px'
}}
className="h-[32rem] w-full max-w-4xl mx-auto"
style={{ minHeight: '512px' }}
/>
{/* Mobile Input Controls - Only show on mobile */}
<div className="block sm:hidden bg-muted/50 px-2 py-3 border-t border-border">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground">Mobile Input</span>
{lastInputSent && (
<span className="text-xs text-green-500 bg-green-500/10 px-2 py-1 rounded">
Sent: {lastInputSent === '\r' ? 'Enter' :
lastInputSent === ' ' ? 'Space' :
lastInputSent === '\b' ? 'Backspace' :
lastInputSent === '\x1b[A' ? 'Up' :
lastInputSent === '\x1b[B' ? 'Down' :
lastInputSent === '\x1b[C' ? 'Right' :
lastInputSent === '\x1b[D' ? 'Left' :
lastInputSent}
</span>
)}
</div>
<Button
onClick={() => setShowMobileInput(!showMobileInput)}
variant="ghost"
size="sm"
className="text-xs"
>
<Keyboard className="h-4 w-4 mr-1" />
{showMobileInput ? 'Hide' : 'Show'} Input
</Button>
</div>
{showMobileInput && (
<div className="space-y-3">
{/* Navigation Buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
onClick={() => sendInput('\x1b[A')}
variant="outline"
size="sm"
className="text-sm flex items-center justify-center gap-2"
disabled={!isConnected}
>
<ChevronUp className="h-4 w-4" />
Up
</Button>
<Button
onClick={() => sendInput('\x1b[B')}
variant="outline"
size="sm"
className="text-sm flex items-center justify-center gap-2"
disabled={!isConnected}
>
<ChevronDown className="h-4 w-4" />
Down
</Button>
</div>
{/* Left/Right Navigation Buttons */}
<div className="grid grid-cols-2 gap-2">
<Button
onClick={() => sendInput('\x1b[D')}
variant="outline"
size="sm"
className="text-sm flex items-center justify-center gap-2"
disabled={!isConnected}
>
<ChevronLeft className="h-4 w-4" />
Left
</Button>
<Button
onClick={() => sendInput('\x1b[C')}
variant="outline"
size="sm"
className="text-sm flex items-center justify-center gap-2"
disabled={!isConnected}
>
<ChevronRight className="h-4 w-4" />
Right
</Button>
</div>
{/* Action Buttons */}
<div className="grid grid-cols-3 gap-2">
<Button
onClick={handleEnterKey}
variant="outline"
size="sm"
className="text-sm"
disabled={!isConnected}
>
Enter
</Button>
<Button
onClick={() => sendInput(' ')}
variant="outline"
size="sm"
className="text-sm"
disabled={!isConnected}
>
Space
</Button>
<Button
onClick={() => sendInput('\b')}
variant="outline"
size="sm"
className="text-sm"
disabled={!isConnected}
>
Backspace
</Button>
</div>
{/* Custom Input */}
<div className="flex gap-2">
<input
type="text"
value={mobileInput}
onChange={(e) => setMobileInput(e.target.value)}
placeholder="Type command..."
className="flex-1 px-3 py-2 text-sm border border-border rounded-md bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary"
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleMobileInput(mobileInput);
}
}}
disabled={!isConnected}
/>
<Button
onClick={() => handleMobileInput(mobileInput)}
variant="default"
size="sm"
disabled={!isConnected || !mobileInput.trim()}
className="px-3"
>
<Send className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
{/* Terminal Controls */}
<div className="bg-muted px-2 sm:px-4 py-2 flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-2 border-t border-border">
<div className="flex flex-wrap gap-1 sm:gap-2">
<div className="bg-muted px-4 py-2 flex items-center justify-between border-t border-border">
<div className="flex space-x-2">
<Button
onClick={startScript}
disabled={!isConnected || isRunning}
variant="default"
size="sm"
className={`text-xs sm:text-sm ${isConnected && !isRunning ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
className={isConnected && !isRunning ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}
>
<Play className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
<span className="hidden sm:inline">Start</span>
<span className="sm:hidden"></span>
Start
</Button>
<Button
@@ -601,22 +345,18 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
disabled={!isRunning}
variant="default"
size="sm"
className={`text-xs sm:text-sm ${isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
className={isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}
>
<Square className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
<span className="hidden sm:inline">Stop</span>
<span className="sm:hidden"></span>
Stop
</Button>
<Button
onClick={clearOutput}
variant="secondary"
size="sm"
className="text-xs sm:text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
className="bg-secondary text-secondary-foreground hover:bg-secondary/80"
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
<span className="hidden sm:inline">Clear</span>
<span className="sm:hidden">🗑</span>
🗑 Clear
</Button>
</div>
@@ -624,10 +364,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
onClick={onClose}
variant="secondary"
size="sm"
className="text-xs sm:text-sm bg-gray-600 text-white hover:bg-gray-700 w-full sm:w-auto"
className="bg-gray-600 text-white hover:bg-gray-700"
>
<X className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
Close
Close
</Button>
</div>
</div>

View File

@@ -103,7 +103,7 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
onClick={handleBackdropClick}
>
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border mx-4 sm:mx-0">
<div className="bg-card rounded-lg shadow-xl max-w-6xl 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 space-x-4">

View File

@@ -3,68 +3,40 @@
import { api } from "~/trpc/react";
import { Badge } from "./ui/badge";
import { Button } from "./ui/button";
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
import { useState, useEffect, useRef } from "react";
// Loading overlay component with log streaming
function LoadingOverlay({
isNetworkError = false,
logs = []
}: {
isNetworkError?: boolean;
logs?: string[];
}) {
const logsEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom when new logs arrive
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
import { useState } from "react";
// Loading overlay component
function LoadingOverlay({ isNetworkError = false }: { isNetworkError?: boolean }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-card rounded-lg p-8 shadow-2xl border border-border max-w-2xl w-full mx-4 max-h-[80vh] flex flex-col">
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 shadow-2xl border border-gray-200 dark:border-gray-700 max-w-md mx-4">
<div className="flex flex-col items-center space-y-4">
<div className="relative">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
<Loader2 className="h-12 w-12 animate-spin text-blue-600 dark:text-blue-400" />
<div className="absolute inset-0 rounded-full border-2 border-blue-200 dark:border-blue-800 animate-pulse"></div>
</div>
<div className="text-center">
<h3 className="text-lg font-semibold text-card-foreground mb-2">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
{isNetworkError ? 'Server Restarting' : 'Updating Application'}
</h3>
<p className="text-sm text-muted-foreground">
<p className="text-sm text-gray-600 dark:text-gray-400">
{isNetworkError
? 'The server is restarting after the update...'
: 'Please stand by while we update your application...'
}
</p>
<p className="text-xs text-muted-foreground mt-2">
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">
{isNetworkError
? 'This may take a few moments. The page will reload automatically.'
? 'This may take a few moments. The page will reload automatically. You may see a blank page for up to a minute!.'
: 'The server will restart automatically when complete.'
}
</p>
</div>
{/* Log output */}
{logs.length > 0 && (
<div className="w-full mt-4 bg-card border border-border rounded-lg p-4 font-mono text-xs text-chart-2 max-h-60 overflow-y-auto terminal-output">
{logs.map((log, index) => (
<div key={index} className="mb-1 whitespace-pre-wrap break-words">
{log}
</div>
))}
<div ref={logsEndRef} />
</div>
)}
<div className="flex space-x-1">
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
</div>
</div>
@@ -76,126 +48,79 @@ export function VersionDisplay() {
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
const [isUpdating, setIsUpdating] = useState(false);
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
const [isNetworkError, setIsNetworkError] = useState(false);
const [updateLogs, setUpdateLogs] = useState<string[]>([]);
const [shouldSubscribe, setShouldSubscribe] = useState(false);
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
const lastLogTimeRef = useRef<number>(Date.now());
const reconnectIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [isNetworkError, setIsNetworkError] = useState(false);
const executeUpdate = api.version.executeUpdate.useMutation({
onSuccess: (result) => {
onSuccess: (result: any) => {
const now = Date.now();
const elapsed = updateStartTime ? now - updateStartTime : 0;
setUpdateResult({ success: result.success, message: result.message });
if (result.success) {
// Start subscribing to update logs
setShouldSubscribe(true);
setUpdateLogs(['Update started...']);
// The script now runs independently, so we show a longer overlay
// and wait for the server to restart
setIsNetworkError(true);
setUpdateResult({ success: true, message: 'Update in progress... Server will restart automatically.' });
// Wait longer for the update to complete and server to restart
setTimeout(() => {
setIsUpdating(false);
setIsNetworkError(false);
// Try to reload after the update completes
setTimeout(() => {
window.location.reload();
}, 10000); // 10 seconds to allow for update completion
}, 5000); // Show overlay for 5 seconds
} else {
setIsUpdating(false);
// For errors, show for at least 1 second
const remainingTime = Math.max(0, 1000 - elapsed);
setTimeout(() => {
setIsUpdating(false);
}, remainingTime);
}
},
onError: (error) => {
setUpdateResult({ success: false, message: error.message });
setIsUpdating(false);
}
});
// Poll for update logs
const { data: updateLogsData } = api.version.getUpdateLogs.useQuery(undefined, {
enabled: shouldSubscribe,
refetchInterval: 1000, // Poll every second
refetchIntervalInBackground: true,
});
// Update logs when data changes
useEffect(() => {
if (updateLogsData?.success && updateLogsData.logs) {
lastLogTimeRef.current = Date.now();
setUpdateLogs(updateLogsData.logs);
const now = Date.now();
const elapsed = updateStartTime ? now - updateStartTime : 0;
if (updateLogsData.isComplete) {
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
// Check if this is a network error (expected during server restart)
const isNetworkError = error.message.includes('Failed to fetch') ||
error.message.includes('NetworkError') ||
error.message.includes('fetch') ||
error.message.includes('network');
if (isNetworkError && elapsed < 60000) { // If it's a network error within 30 seconds, treat as success
setIsNetworkError(true);
// Start reconnection attempts when we know update is complete
startReconnectAttempts();
}
}
}, [updateLogsData]);
// Monitor for server connection loss and auto-reload (fallback only)
useEffect(() => {
if (!shouldSubscribe) return;
// Only use this as a fallback - the main trigger should be completion detection
const checkInterval = setInterval(() => {
const timeSinceLastLog = Date.now() - lastLogTimeRef.current;
// Only start reconnection if we've been updating for at least 3 minutes
// and no logs for 60 seconds (very conservative fallback)
const hasBeenUpdatingLongEnough = updateStartTime && (Date.now() - updateStartTime) > 180000; // 3 minutes
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...']);
setUpdateResult({ success: true, message: 'Update in progress... Server is restarting.' });
// Start trying to reconnect
startReconnectAttempts();
// Wait longer for server to come back up
setTimeout(() => {
setIsUpdating(false);
setIsNetworkError(false);
// Try to reload after a longer delay
setTimeout(() => {
window.location.reload();
}, 5000);
}, 3000);
} else {
// For real errors, show for at least 1 second
setUpdateResult({ success: false, message: error.message });
const remainingTime = Math.max(0, 1000 - elapsed);
setTimeout(() => {
setIsUpdating(false);
}, remainingTime);
}
}, 10000); // Check every 10 seconds
return () => clearInterval(checkInterval);
}, [shouldSubscribe, isUpdating, updateStartTime, isNetworkError]);
// Attempt to reconnect and reload page when server is back
const startReconnectAttempts = () => {
if (reconnectIntervalRef.current) return;
setUpdateLogs(prev => [...prev, 'Attempting to reconnect...']);
reconnectIntervalRef.current = setInterval(() => {
void (async () => {
try {
// Try to fetch the root path to check if server is back
const response = await fetch('/', { method: 'HEAD' });
if (response.ok || response.status === 200) {
setUpdateLogs(prev => [...prev, 'Server is back online! Reloading...']);
// Clear interval and reload
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
}
setTimeout(() => {
window.location.reload();
}, 1000);
}
} catch {
// Server still down, keep trying
}
})();
}, 2000);
};
// Cleanup reconnect interval on unmount
useEffect(() => {
return () => {
if (reconnectIntervalRef.current) {
clearInterval(reconnectIntervalRef.current);
}
};
}, []);
}
});
const handleUpdate = () => {
setIsUpdating(true);
setUpdateResult(null);
setIsNetworkError(false);
setUpdateLogs([]);
setShouldSubscribe(false);
setUpdateStartTime(Date.now());
lastLogTimeRef.current = Date.now();
executeUpdate.mutate();
};
@@ -227,23 +152,23 @@ export function VersionDisplay() {
return (
<>
{/* Loading overlay */}
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} />}
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
<Badge variant={isUpToDate ? "default" : "secondary"} className="text-xs">
<div className="flex items-center gap-2">
<Badge variant={isUpToDate ? "default" : "secondary"}>
v{currentVersion}
</Badge>
{updateAvailable && releaseInfo && (
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-3">
<div className="flex items-center gap-3">
<div className="relative group">
<Badge variant="destructive" className="animate-pulse cursor-help text-xs">
<Badge variant="destructive" className="animate-pulse cursor-help">
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="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">
<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>Click the button to update</div>
<div>or update manually:</div>
<div>cd $PVESCRIPTLOCAL_DIR</div>
<div>git pull</div>
@@ -255,45 +180,41 @@ export function VersionDisplay() {
</div>
</div>
<div className="flex items-center gap-2">
<Button
onClick={handleUpdate}
disabled={isUpdating}
size="sm"
variant="destructive"
className="text-xs h-6 px-2"
>
{isUpdating ? (
<>
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
<span className="hidden sm:inline">Updating...</span>
<span className="sm:hidden">...</span>
</>
) : (
<>
<Download className="h-3 w-3 mr-1" />
<span className="hidden sm:inline">Update Now</span>
<span className="sm:hidden">Update</span>
</>
)}
</Button>
<a
href={releaseInfo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="View latest release"
>
<ExternalLink className="h-3 w-3" />
</a>
</div>
<Button
onClick={handleUpdate}
disabled={isUpdating}
size="sm"
variant="destructive"
className="text-xs h-6 px-2"
>
{isUpdating ? (
<>
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
Updating...
</>
) : (
<>
<Download className="h-3 w-3 mr-1" />
Update Now
</>
)}
</Button>
<a
href={releaseInfo.htmlUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="View latest release"
>
<ExternalLink className="h-3 w-3" />
</a>
{updateResult && (
<div className={`text-xs px-2 py-1 rounded text-center ${
<div className={`text-xs px-2 py-1 rounded ${
updateResult.success
? 'bg-chart-2/20 text-chart-2 border border-chart-2/30'
: 'bg-destructive/20 text-destructive border border-destructive/30'
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
}`}>
{updateResult.message}
</div>
@@ -302,7 +223,7 @@ export function VersionDisplay() {
)}
{isUpToDate && (
<span className="text-xs text-chart-2">
<span className="text-xs text-green-600 dark:text-green-400">
Up to date
</span>
)}

View File

@@ -15,7 +15,7 @@ const handler = (req: NextRequest) =>
env.NODE_ENV === "development"
? ({ path, error }) => {
console.error(
`[ERROR] tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
` tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
);
}
: undefined,

View File

@@ -7,8 +7,7 @@ import { TRPCReactProvider } from "~/trpc/react";
export const metadata: Metadata = {
title: "PVE Scripts local",
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
description: "",
icons: [
{ rel: "icon", url: "/favicon.png", type: "image/png" },
{ rel: "icon", url: "/favicon.ico", sizes: "any" },

View File

@@ -10,7 +10,6 @@ import { Terminal } from './_components/Terminal';
import { SettingsButton } from './_components/SettingsButton';
import { VersionDisplay } from './_components/VersionDisplay';
import { Button } from './_components/ui/button';
import { Rocket, Package, HardDrive, FolderOpen } from 'lucide-react';
export default function Home() {
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
@@ -26,24 +25,23 @@ export default function Home() {
return (
<main className="min-h-screen bg-background">
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="text-center mb-6 sm:mb-8">
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground mb-2 flex items-center justify-center gap-2 sm:gap-3">
<Rocket className="h-6 w-6 sm:h-8 w-8 lg:h-9 lg:w-9" />
<span className="break-words">PVE Scripts Management</span>
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-foreground mb-2">
🚀 PVE Scripts Management
</h1>
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
<p className="text-muted-foreground mb-4">
Manage and execute Proxmox helper scripts locally with live output streaming
</p>
<div className="flex justify-center px-2">
<div className="flex justify-center">
<VersionDisplay />
</div>
</div>
{/* Controls */}
<div className="mb-6 sm:mb-8">
<div className="flex flex-col gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
<div className="mb-8">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-6 p-6 bg-card rounded-lg shadow-sm border border-border">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<SettingsButton />
</div>
@@ -54,47 +52,41 @@ export default function Home() {
</div>
{/* Tab Navigation */}
<div className="mb-6 sm:mb-8">
<div className="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 space-x-8">
<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 ${
className={`px-3 py-1 text-sm ${
activeTab === 'scripts'
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
}`}>
<Package className="h-4 w-4" />
<span className="hidden sm:inline">Available Scripts</span>
<span className="sm:hidden">Available</span>
📦 Available Scripts
</Button>
<Button
variant="ghost"
size="null"
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 ${
className={`px-3 py-1 text-sm ${
activeTab === 'downloaded'
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
}`}>
<HardDrive className="h-4 w-4" />
<span className="hidden sm:inline">Downloaded Scripts</span>
<span className="sm:hidden">Downloaded</span>
💾 Downloaded Scripts
</Button>
<Button
variant="ghost"
size="null"
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 ${
className={`px-3 py-1 text-sm ${
activeTab === 'installed'
? 'bg-accent text-accent-foreground'
: 'hover:bg-accent hover:text-accent-foreground'
}`}>
<FolderOpen className="h-4 w-4" />
<span className="hidden sm:inline">Installed Scripts</span>
<span className="sm:hidden">Installed</span>
🗂 Installed Scripts
</Button>
</nav>
</div>

View File

@@ -23,8 +23,6 @@ export const env = createEnv({
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
// WebSocket Configuration
WEBSOCKET_PORT: z.string().default("3001"),
// GitHub Configuration
GITHUB_TOKEN: z.string().optional(),
},
/**
@@ -54,8 +52,6 @@ export const env = createEnv({
ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS,
// WebSocket Configuration
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
// GitHub Configuration
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
},
/**

View File

@@ -1,10 +1,7 @@
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
import { readFile, writeFile } from "fs/promises";
import { readFile } from "fs/promises";
import { join } from "path";
import { spawn } from "child_process";
import { env } from "~/env";
import { existsSync, createWriteStream } from "fs";
import stripAnsi from "strip-ansi";
interface GitHubRelease {
tag_name: string;
@@ -13,21 +10,6 @@ interface GitHubRelease {
html_url: string;
}
// Helper function to fetch from GitHub API with optional authentication
async function fetchGitHubAPI(url: string) {
const headers: HeadersInit = {
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'ProxmoxVE-Local'
};
// Add authentication header if token is available
if (env.GITHUB_TOKEN) {
headers.Authorization = `token ${env.GITHUB_TOKEN}`;
}
return fetch(url, { headers });
}
export const versionRouter = createTRPCRouter({
// Get current local version
getCurrentVersion: publicProcedure
@@ -52,7 +34,7 @@ export const versionRouter = createTRPCRouter({
getLatestRelease: publicProcedure
.query(async () => {
try {
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
const response = await fetch('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
@@ -88,7 +70,7 @@ export const versionRouter = createTRPCRouter({
const currentVersion = (await readFile(versionPath, 'utf-8')).trim();
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
const response = await fetch('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status}`);
@@ -127,80 +109,21 @@ export const versionRouter = createTRPCRouter({
}
}),
// Get update logs from the log file
getUpdateLogs: publicProcedure
.query(async () => {
try {
const logPath = join(process.cwd(), 'update.log');
if (!existsSync(logPath)) {
return {
success: true,
logs: [],
isComplete: false
};
}
const logs = await readFile(logPath, 'utf-8');
const logLines = logs.split('\n')
.filter(line => line.trim())
.map(line => stripAnsi(line)); // Strip ANSI color codes
// Check if update is complete by looking for completion indicators
const isComplete = logLines.some(line =>
line.includes('Update complete') ||
line.includes('Server restarting') ||
line.includes('npm start') ||
line.includes('Restarting server') ||
line.includes('Server started') ||
line.includes('Ready on http') ||
line.includes('Application started') ||
line.includes('Service enabled and started successfully') ||
line.includes('Service is running') ||
line.includes('Update completed successfully')
);
return {
success: true,
logs: logLines,
isComplete
};
} catch (error) {
console.error('Error reading update logs:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to read update logs',
logs: [],
isComplete: false
};
}
}),
// Execute update script
executeUpdate: publicProcedure
.mutation(async () => {
try {
const updateScriptPath = join(process.cwd(), 'update.sh');
const logPath = join(process.cwd(), 'update.log');
// Clear/create the log file
await writeFile(logPath, '', 'utf-8');
// Spawn the update script as a detached process using nohup
// This allows it to run independently and kill the parent Node.js process
// Redirect output to log file
const child = spawn('bash', [updateScriptPath], {
const child = spawn('nohup', ['bash', updateScriptPath], {
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'pipe'],
stdio: ['ignore', 'ignore', 'ignore'],
shell: false,
detached: true
});
// Capture stdout and stderr to log file
const logStream = createWriteStream(logPath, { flags: 'a' });
child.stdout?.pipe(logStream);
child.stderr?.pipe(logStream);
// Unref the child process so it doesn't keep the parent alive
child.unref();

View File

@@ -24,12 +24,9 @@ export class ScriptExecutionHandler {
}
private handleConnection(ws: WebSocket, _request: IncomingMessage) {
ws.on('message', (data) => {
try {
const message = JSON.parse(data.toString()) as { action: string; scriptPath?: string; executionId?: string };
void this.handleMessage(ws, message);
} catch (error) {
@@ -43,20 +40,20 @@ export class ScriptExecutionHandler {
});
ws.on('close', () => {
// Clean up any active executions for this connection
this.cleanupActiveExecutions(ws);
});
ws.on('error', (_error) => {
ws.on('error', (error) => {
console.error('WebSocket error:', error);
this.cleanupActiveExecutions(ws);
});
}
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any; input?: string }) {
const { action, scriptPath, executionId, mode, server, input } = message;
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any }) {
const { action, scriptPath, executionId, mode, server } = message;
console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
switch (action) {
case 'start':
@@ -77,20 +74,6 @@ export class ScriptExecutionHandler {
}
break;
case 'input':
if (executionId && input !== undefined) {
this.sendInputToExecution(executionId, input);
} else {
this.sendMessage(ws, {
type: 'error',
data: 'Missing executionId or input data',
timestamp: Date.now()
});
}
break;
default:
this.sendMessage(ws, {
type: 'error',
@@ -101,7 +84,8 @@ export class ScriptExecutionHandler {
}
private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string, mode?: 'local' | 'ssh', server?: any) {
console.log('startScriptExecution called with:', { scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
try {
// Check if execution is already running
if (this.activeExecutions.has(executionId)) {
@@ -116,7 +100,10 @@ export class ScriptExecutionHandler {
let process: any;
if (mode === 'ssh' && server) {
// SSH execution
console.log('Starting SSH execution:', { scriptPath, server });
console.log('SSH execution mode detected, calling SSH service...');
console.log('Mode check: mode=', mode, 'server=', !!server);
this.sendMessage(ws, {
type: 'start',
data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`,
@@ -124,11 +111,13 @@ export class ScriptExecutionHandler {
});
const sshService = getSSHExecutionService();
console.log('SSH service obtained, calling executeScript...');
console.log('SSH service object:', typeof sshService, sshService.constructor.name);
try {
const result = await sshService.executeScript(server as Server, scriptPath,
(data: string) => {
console.log('SSH onData callback:', data.substring(0, 100) + '...');
this.sendMessage(ws, {
type: 'output',
data: data,
@@ -136,7 +125,7 @@ export class ScriptExecutionHandler {
});
},
(error: string) => {
console.log('SSH onError callback:', error);
this.sendMessage(ws, {
type: 'error',
data: error,
@@ -144,7 +133,7 @@ export class ScriptExecutionHandler {
});
},
(code: number) => {
console.log('SSH onExit callback, code:', code);
this.sendMessage(ws, {
type: 'end',
data: `SSH script execution finished with code: ${code}`,
@@ -153,10 +142,10 @@ export class ScriptExecutionHandler {
this.activeExecutions.delete(executionId);
}
);
console.log('SSH service executeScript completed, result:', result);
process = (result as any).process;
} catch (sshError) {
console.error('SSH service executeScript failed:', sshError);
this.sendMessage(ws, {
type: 'error',
data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`,
@@ -165,7 +154,10 @@ export class ScriptExecutionHandler {
return;
}
} else {
// Local execution
console.log('Starting local execution:', { scriptPath });
console.log('Local execution mode detected, calling local script manager...');
console.log('Mode check: mode=', mode, 'server=', !!server, 'condition result:', mode === 'ssh' && server);
// Validate script path
const validation = scriptManager.validateScriptPath(scriptPath);
@@ -257,59 +249,6 @@ export class ScriptExecutionHandler {
}
}
private sendInputToExecution(executionId: string, input: string) {
const execution = this.activeExecutions.get(executionId);
if (execution?.process) {
try {
// Check if it's a pty process (SSH) or regular process
if (typeof execution.process.write === 'function' && !execution.process.stdin) {
execution.process.write(input);
// Send confirmation back to client
this.sendMessage(execution.ws, {
type: 'output',
data: `[MOBILE INPUT SENT: ${JSON.stringify(input)}]`,
timestamp: Date.now()
});
} else if (execution.process.stdin && !execution.process.stdin.destroyed) {
execution.process.stdin.write(input);
this.sendMessage(execution.ws, {
type: 'output',
data: `[MOBILE INPUT SENT: ${JSON.stringify(input)}]`,
timestamp: Date.now()
});
} else {
this.sendMessage(execution.ws, {
type: 'error',
data: 'Process input not available',
timestamp: Date.now()
});
}
} catch (error) {
this.sendMessage(execution.ws, {
type: 'error',
data: `Failed to send input: ${error instanceof Error ? error.message : 'Unknown error'}`,
timestamp: Date.now()
});
}
} else {
// No active execution found - this case is already handled above
return;
}
}
private sendMessage(ws: WebSocket, message: ScriptExecutionMessage) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));

View File

@@ -65,7 +65,7 @@
/* Semantic color utility classes */
.bg-background { background-color: hsl(var(--background)); }
.text-foreground { color: hsl(var(--foreground)); }
.bg-card { background-color: hsl(var(--card)) !important; }
.bg-card { background-color: hsl(var(--card)); }
.text-card-foreground { color: hsl(var(--card-foreground)); }
.bg-popover { background-color: hsl(var(--popover)); }
.text-popover-foreground { color: hsl(var(--popover-foreground)); }
@@ -141,75 +141,3 @@
color: inherit;
background-color: inherit;
}
/* Mobile-specific improvements */
@media (max-width: 640px) {
/* Improve touch targets */
button, .cursor-pointer {
min-height: 44px;
min-width: 44px;
}
/* Better text sizing on mobile */
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
/* Improve form elements on mobile */
input, select, textarea {
font-size: 16px; /* Prevents zoom on iOS */
}
/* Better spacing for mobile */
.space-y-2 > * + * {
margin-top: 0.5rem;
}
.space-y-4 > * + * {
margin-top: 1rem;
}
/* Improve modal and overlay positioning */
.fixed.inset-0 {
padding: 1rem;
}
/* Better scroll behavior */
.overflow-x-auto {
-webkit-overflow-scrolling: touch;
}
}
/* Tablet improvements */
@media (min-width: 641px) and (max-width: 1024px) {
/* Better spacing for tablets */
.container {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
}
/* Ensure proper viewport handling */
html {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Mobile terminal centering - simple approach */
.mobile-terminal {
display: flex !important;
justify-content: center !important;
align-items: center !important;
}
.mobile-terminal .xterm {
margin: 0 auto !important;
width: 100% !important;
max-width: 100% !important;
}

489
update.sh
View File

@@ -16,13 +16,6 @@ BACKUP_DIR="/tmp/pve-scripts-backup-$(date +%Y%m%d-%H%M%S)"
DATA_DIR="./data"
LOG_FILE="/tmp/update.log"
# GitHub Personal Access Token for higher rate limits (optional)
# Set GITHUB_TOKEN environment variable or create .github_token file
GITHUB_TOKEN=""
# Global variable to track if service was running before update
SERVICE_WAS_RUNNING=false
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -30,44 +23,6 @@ YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Load GitHub token
load_github_token() {
# Try environment variable first
if [ -n "${GITHUB_TOKEN:-}" ]; then
log "Using GitHub token from environment variable"
return 0
fi
# Try .env file
if [ -f ".env" ]; then
local env_token
env_token=$(grep "^GITHUB_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' | tr -d "'" | tr -d '\n\r')
if [ -n "$env_token" ]; then
GITHUB_TOKEN="$env_token"
log "Using GitHub token from .env file"
return 0
fi
fi
# Try .github_token file
if [ -f ".github_token" ]; then
GITHUB_TOKEN=$(cat .github_token | tr -d '\n\r')
log "Using GitHub token from .github_token file"
return 0
fi
# Try ~/.github_token file
if [ -f "$HOME/.github_token" ]; then
GITHUB_TOKEN=$(cat "$HOME/.github_token" | tr -d '\n\r')
log "Using GitHub token from ~/.github_token file"
return 0
fi
log_warning "No GitHub token found. Using unauthenticated requests (lower rate limits)"
log_warning "To use a token, add GITHUB_TOKEN=your_token to .env file or set GITHUB_TOKEN environment variable"
return 1
}
# Initialize log file
init_log() {
# Clear/create log file
@@ -128,18 +83,8 @@ check_dependencies() {
get_latest_release() {
log "Fetching latest release information from GitHub..."
local curl_opts="-s --connect-timeout 15 --max-time 60 --retry 2 --retry-delay 3"
# Add authentication header if token is available
if [ -n "$GITHUB_TOKEN" ]; then
curl_opts="$curl_opts -H \"Authorization: token $GITHUB_TOKEN\""
log "Using authenticated GitHub API request"
else
log "Using unauthenticated GitHub API request (lower rate limits)"
fi
local release_info
if ! release_info=$(eval "curl $curl_opts \"$GITHUB_API/releases/latest\""); then
if ! release_info=$(curl -s --connect-timeout 15 --max-time 60 --retry 2 --retry-delay 3 "$GITHUB_API/releases/latest"); then
log_error "Failed to fetch release information from GitHub API (timeout or network error)"
exit 1
fi
@@ -225,12 +170,53 @@ download_release() {
fi
# Download release with timeout and progress
if ! curl -L --connect-timeout 30 --max-time 300 --retry 3 --retry-delay 5 -o "$archive_file" "$download_url" 2>/dev/null; then
log_error "Failed to download release from GitHub"
log "Downloading from: $download_url"
log "Target file: $archive_file"
log "Starting curl download..."
# Test if curl is working
log "Testing curl availability..."
if ! command -v curl >/dev/null 2>&1; then
log_error "curl command not found"
rm -rf "$temp_dir"
exit 1
fi
# Test basic connectivity
log "Testing basic connectivity..."
if ! curl -s --connect-timeout 10 --max-time 30 "https://api.github.com" >/dev/null 2>&1; then
log_error "Cannot reach GitHub API"
rm -rf "$temp_dir"
exit 1
fi
log_success "Connectivity test passed"
# Create a temporary file for curl output
local curl_log="/tmp/curl_log_$$.txt"
# Run curl with verbose output
if curl -L --connect-timeout 30 --max-time 300 --retry 3 --retry-delay 5 -v -o "$archive_file" "$download_url" > "$curl_log" 2>&1; then
log_success "Curl command completed successfully"
# Show some of the curl output for debugging
log "Curl output (first 10 lines):"
head -10 "$curl_log" | while read -r line; do
log "CURL: $line"
done
else
local curl_exit_code=$?
log_error "Curl command failed with exit code: $curl_exit_code"
log_error "Curl output:"
cat "$curl_log" | while read -r line; do
log_error "CURL: $line"
done
rm -f "$curl_log"
rm -rf "$temp_dir"
exit 1
fi
# Clean up curl log
rm -f "$curl_log"
# Verify download
if [ ! -f "$archive_file" ] || [ ! -s "$archive_file" ]; then
log_error "Downloaded file is empty or missing"
@@ -238,35 +224,52 @@ download_release() {
exit 1
fi
log_success "Downloaded release"
local file_size
file_size=$(stat -c%s "$archive_file" 2>/dev/null || echo "0")
log_success "Downloaded release ($file_size bytes)"
# Extract release
if ! tar -xzf "$archive_file" -C "$temp_dir" 2>/dev/null; then
log "Extracting release..."
if ! tar -xzf "$archive_file" -C "$temp_dir"; then
log_error "Failed to extract release"
rm -rf "$temp_dir"
exit 1
fi
# Debug: List contents after extraction
log "Contents after extraction:"
ls -la "$temp_dir" >&2 || true
# Find the extracted directory (GitHub tarballs have a root directory)
log "Looking for extracted directory with pattern: ${REPO_NAME}-*"
local extracted_dir
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d -name "community-scripts-ProxmoxVE-Local-*" 2>/dev/null | head -1)
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d -name "${REPO_NAME}-*" 2>/dev/null | head -1)
# Try alternative patterns if not found
# If not found with repo name, try alternative patterns
if [ -z "$extracted_dir" ]; then
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d -name "${REPO_NAME}-*" 2>/dev/null | head -1)
log "Trying pattern: community-scripts-ProxmoxVE-Local-*"
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d -name "community-scripts-ProxmoxVE-Local-*" 2>/dev/null | head -1)
fi
if [ -z "$extracted_dir" ]; then
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d ! -name "$temp_dir" 2>/dev/null | head -1)
log "Trying pattern: ProxmoxVE-Local-*"
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d -name "ProxmoxVE-Local-*" 2>/dev/null | head -1)
fi
if [ -z "$extracted_dir" ]; then
log "Trying any directory in temp folder"
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d ! -name "$temp_dir" 2>/dev/null | head -1)
fi
# If still not found, error out
if [ -z "$extracted_dir" ]; then
log_error "Could not find extracted directory"
rm -rf "$temp_dir"
exit 1
fi
log_success "Release extracted successfully"
log_success "Found extracted directory: $extracted_dir"
log_success "Release downloaded and extracted successfully"
echo "$extracted_dir"
}
@@ -274,10 +277,6 @@ download_release() {
clear_original_directory() {
log "Clearing original directory..."
# Remove old lock files and node_modules before update
rm -f package-lock.json 2>/dev/null
rm -rf node_modules 2>/dev/null
# List of files/directories to preserve (already backed up)
local preserve_patterns=(
"data"
@@ -286,6 +285,7 @@ clear_original_directory() {
"update.log"
"*.backup"
"*.bak"
"node_modules"
".git"
)
@@ -368,21 +368,148 @@ restore_backup_files() {
# Check if systemd service exists
check_service() {
# systemctl status returns 0-3 if service exists (running, exited, failed, etc.)
# and returns 4 if service unit is not found
systemctl status pvescriptslocal.service &>/dev/null
local exit_code=$?
if [ $exit_code -le 3 ]; then
if systemctl list-unit-files | grep -q "^pvescriptslocal.service"; then
return 0
else
return 1
fi
}
# Kill application processes directly
kill_processes() {
# Try to find and stop the Node.js process
local pids
pids=$(pgrep -f "node server.js" 2>/dev/null || true)
# Also check for npm start processes
local npm_pids
npm_pids=$(pgrep -f "npm start" 2>/dev/null || true)
# Combine all PIDs
if [ -n "$npm_pids" ]; then
pids="$pids $npm_pids"
fi
if [ -n "$pids" ]; then
log "Stopping application processes: $pids"
# Send TERM signal to each PID individually
for pid in $pids; do
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
log "Sending TERM signal to PID: $pid"
kill -TERM "$pid" 2>/dev/null || true
fi
done
# Wait for graceful shutdown with timeout
log "Waiting for graceful shutdown..."
local wait_count=0
local max_wait=10 # Maximum 10 seconds
while [ $wait_count -lt $max_wait ]; do
local still_running
still_running=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -z "$still_running" ]; then
log_success "Processes stopped gracefully"
break
fi
sleep 1
wait_count=$((wait_count + 1))
log "Waiting... ($wait_count/$max_wait)"
done
# Force kill any remaining processes
local remaining_pids
remaining_pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$remaining_pids" ]; then
log_warning "Force killing remaining processes: $remaining_pids"
pkill -9 -f "node server.js" 2>/dev/null || true
pkill -9 -f "npm start" 2>/dev/null || true
sleep 1
fi
# Final check
local final_check
final_check=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$final_check" ]; then
log_warning "Some processes may still be running: $final_check"
else
log_success "All application processes stopped"
fi
else
log "No running application processes found"
fi
}
# Kill application processes directly
kill_processes() {
# Try to find and stop the Node.js process
local pids
pids=$(pgrep -f "node server.js" 2>/dev/null || true)
# Also check for npm start processes
local npm_pids
npm_pids=$(pgrep -f "npm start" 2>/dev/null || true)
# Combine all PIDs
if [ -n "$npm_pids" ]; then
pids="$pids $npm_pids"
fi
if [ -n "$pids" ]; then
log "Stopping application processes: $pids"
# Send TERM signal to each PID individually
for pid in $pids; do
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
log "Sending TERM signal to PID: $pid"
kill -TERM "$pid" 2>/dev/null || true
fi
done
# Wait for graceful shutdown with timeout
log "Waiting for graceful shutdown..."
local wait_count=0
local max_wait=10 # Maximum 10 seconds
while [ $wait_count -lt $max_wait ]; do
local still_running
still_running=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -z "$still_running" ]; then
log_success "Processes stopped gracefully"
break
fi
sleep 1
wait_count=$((wait_count + 1))
log "Waiting... ($wait_count/$max_wait)"
done
# Force kill any remaining processes
local remaining_pids
remaining_pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$remaining_pids" ]; then
log_warning "Force killing remaining processes: $remaining_pids"
pkill -9 -f "node server.js" 2>/dev/null || true
pkill -9 -f "npm start" 2>/dev/null || true
sleep 1
fi
# Final check
local final_check
final_check=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$final_check" ]; then
log_warning "Some processes may still be running: $final_check"
else
log_success "All application processes stopped"
fi
else
log "No running application processes found"
fi
}
# Stop the application before updating
stop_application() {
log "Stopping application..."
# Change to the application directory if we're not already there
local app_dir
@@ -404,31 +531,23 @@ stop_application() {
log "Working from application directory: $(pwd)"
# Check if systemd service is running and disable it temporarily
if check_service && systemctl is-active --quiet pvescriptslocal.service; then
log "Disabling systemd service temporarily to prevent auto-restart..."
if systemctl disable pvescriptslocal.service; then
log_success "Service disabled successfully"
# Check if systemd service exists and is active
if check_service; then
if systemctl is-active --quiet pvescriptslocal.service; then
log "Stopping pvescriptslocal service..."
if systemctl stop pvescriptslocal.service; then
log_success "Service stopped successfully"
else
log_error "Failed to stop service, falling back to process kill"
kill_processes
fi
else
log_error "Failed to disable service"
return 1
log "Service exists but is not active, checking for running processes..."
kill_processes
fi
else
log "No running systemd service found"
fi
# Kill any remaining npm/node processes
log "Killing any remaining npm/node processes..."
local pids
pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$pids" ]; then
log "Found running processes: $pids"
pkill -9 -f "node server.js" 2>/dev/null || true
pkill -9 -f "npm start" 2>/dev/null || true
sleep 2
log_success "Processes killed"
else
log "No running processes found"
log "No systemd service found, stopping processes directly..."
kill_processes
fi
}
@@ -459,20 +578,26 @@ update_files() {
return 1
fi
# Verify critical files exist in source
if [ ! -f "$actual_source_dir/package.json" ]; then
log_error "package.json not found in source directory!"
return 1
fi
# Use process substitution instead of pipe to avoid subshell issues
local files_copied=0
local files_excluded=0
log "Starting file copy process from: $actual_source_dir"
# Create a temporary file list to avoid process substitution issues
local file_list="/tmp/file_list_$$.txt"
find "$actual_source_dir" -type f > "$file_list"
local total_files
total_files=$(wc -l < "$file_list")
log "Found $total_files files to process"
# Show first few files for debugging
log "First few files to process:"
head -5 "$file_list" | while read -r f; do
log " - $f"
done
while IFS= read -r file; do
local rel_path="${file#$actual_source_dir/}"
local should_exclude=false
@@ -490,97 +615,60 @@ update_files() {
if [ "$target_dir" != "." ]; then
mkdir -p "$target_dir"
fi
log "Copying: $file -> $rel_path"
if ! cp "$file" "$rel_path"; then
log_error "Failed to copy $rel_path"
rm -f "$file_list"
return 1
else
files_copied=$((files_copied + 1))
if [ $((files_copied % 10)) -eq 0 ]; then
log "Copied $files_copied files so far..."
fi
fi
files_copied=$((files_copied + 1))
else
files_excluded=$((files_excluded + 1))
log "Excluded: $rel_path"
fi
done < "$file_list"
# Clean up temporary file
rm -f "$file_list"
# Verify critical files were copied
if [ ! -f "package.json" ]; then
log_error "package.json was not copied to target directory!"
return 1
fi
log "Files processed: $files_copied copied, $files_excluded excluded"
if [ ! -f "package-lock.json" ]; then
log_warning "package-lock.json was not copied!"
fi
log_success "Application files updated successfully ($files_copied files)"
log_success "Application files updated successfully"
}
# Install dependencies and build
install_and_build() {
log "Installing dependencies..."
# Verify package.json exists
if [ ! -f "package.json" ]; then
log_error "package.json not found! Cannot install dependencies."
return 1
fi
if [ ! -f "package-lock.json" ]; then
log_warning "No package-lock.json found, npm will generate one"
fi
# Create temporary file for npm output
local npm_log="/tmp/npm_install_$$.log"
# Ensure NODE_ENV is not set to production during install (we need devDependencies for build)
local old_node_env="${NODE_ENV:-}"
export NODE_ENV=development
# Run npm install to get ALL dependencies including devDependencies
if ! npm install --include=dev > "$npm_log" 2>&1; then
if ! npm install; then
log_error "Failed to install dependencies"
log_error "npm install output (last 30 lines):"
tail -30 "$npm_log" | while read -r line; do
log_error "NPM: $line"
done
rm -f "$npm_log"
return 1
fi
# Restore NODE_ENV
if [ -n "$old_node_env" ]; then
export NODE_ENV="$old_node_env"
else
unset NODE_ENV
# Ensure no processes are running before build
log "Ensuring no conflicting processes are running..."
local pids
pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$pids" ]; then
log_warning "Found running processes, stopping them: $pids"
pkill -9 -f "node server.js" 2>/dev/null || true
pkill -9 -f "npm start" 2>/dev/null || true
sleep 2
fi
log_success "Dependencies installed successfully"
rm -f "$npm_log"
log "Building application..."
# Set NODE_ENV to production for build
export NODE_ENV=production
# Create temporary file for npm build output
local build_log="/tmp/npm_build_$$.log"
if ! npm run build > "$build_log" 2>&1; then
if ! npm run build; then
log_error "Failed to build application"
log_error "npm run build output:"
cat "$build_log" | while read -r line; do
log_error "BUILD: $line"
done
rm -f "$build_log"
return 1
fi
# Log success and clean up
log_success "Application built successfully"
rm -f "$build_log"
log_success "Dependencies installed and application built successfully"
}
@@ -588,11 +676,11 @@ install_and_build() {
start_application() {
log "Starting application..."
# Use the global variable to determine how to start
if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then
log "Service was running before update, re-enabling and starting systemd service..."
if systemctl enable --now pvescriptslocal.service; then
log_success "Service enabled and started successfully"
# Check if systemd service exists
if check_service; then
log "Starting pvescriptslocal service..."
if systemctl start pvescriptslocal.service; then
log_success "Service started successfully"
# Wait a moment and check if it's running
sleep 2
if systemctl is-active --quiet pvescriptslocal.service; then
@@ -601,11 +689,11 @@ start_application() {
log_warning "Service started but may not be running properly"
fi
else
log_error "Failed to enable/start service, falling back to npm start"
log_error "Failed to start service, falling back to npm start"
start_with_npm
fi
else
log "Service was not running before update or no service exists, starting with npm..."
log "No systemd service found, starting with npm..."
start_with_npm
fi
}
@@ -678,22 +766,25 @@ rollback() {
# Main update process
main() {
# Check if this is the relocated/detached version first
if [ "${1:-}" = "--relocated" ]; then
export PVE_UPDATE_RELOCATED=1
init_log
log "Running as detached process"
sleep 3
else
init_log
fi
init_log
# Check if we're running from the application directory and not already relocated
if [ -z "${PVE_UPDATE_RELOCATED:-}" ] && [ -f "package.json" ] && [ -f "server.js" ]; then
log "Detected running from application directory"
bash "$0" --relocated
exit $?
log "Copying update script to temporary location for safe execution..."
local temp_script="/tmp/pve-scripts-update-$$.sh"
if ! cp "$0" "$temp_script"; then
log_error "Failed to copy update script to temporary location"
exit 1
fi
chmod +x "$temp_script"
log "Executing update from temporary location: $temp_script"
# Set flag to prevent infinite loop and execute from temporary location
export PVE_UPDATE_RELOCATED=1
exec "$temp_script" "$@"
fi
# Ensure we're in the application directory
@@ -702,6 +793,7 @@ main() {
# First check if we're already in the right directory
if [ -f "package.json" ] && [ -f "server.js" ]; then
app_dir="$(pwd)"
log "Already in application directory: $app_dir"
else
# Try multiple common locations
for search_path in /opt /root /home /usr/local; do
@@ -718,8 +810,10 @@ main() {
log_error "Failed to change to application directory: $app_dir"
exit 1
}
log "Changed to application directory: $(pwd)"
else
log_error "Could not find application directory"
log "Searched in: /opt, /root, /home, /usr/local"
exit 1
fi
fi
@@ -727,16 +821,6 @@ main() {
# Check dependencies
check_dependencies
# Load GitHub token for higher rate limits
load_github_token
# Check if service was running before update
if check_service && systemctl is-active --quiet pvescriptslocal.service; then
SERVICE_WAS_RUNNING=true
else
SERVICE_WAS_RUNNING=false
fi
# Get latest release info
local release_info
release_info=$(get_latest_release)
@@ -744,35 +828,60 @@ main() {
# Backup data directory
backup_data
# Stop the application before updating
# Stop the application before updating (now running from /tmp/)
stop_application
# Double-check that no processes are running
local remaining_pids
remaining_pids=$(pgrep -f "node server.js\|npm start" 2>/dev/null || true)
if [ -n "$remaining_pids" ]; then
log_warning "Force killing remaining processes"
pkill -9 -f "node server.js" 2>/dev/null || true
pkill -9 -f "npm start" 2>/dev/null || true
sleep 2
fi
# Download and extract release
local source_dir
source_dir=$(download_release "$release_info")
log "Download completed, source_dir: $source_dir"
# Clear the original directory before updating
log "Clearing original directory..."
clear_original_directory
log "Original directory cleared successfully"
# Update files
log "Starting file update process..."
if ! update_files "$source_dir"; then
log_error "File update failed, rolling back..."
rollback
fi
log "File update completed successfully"
# Restore .env and data directory before building
log "Restoring backup files..."
restore_backup_files
log "Backup files restored successfully"
# Install dependencies and build
log "Starting install and build process..."
if ! install_and_build; then
log_error "Install and build failed, rolling back..."
rollback
fi
log "Install and build completed successfully"
# Cleanup
log "Cleaning up temporary files..."
rm -rf "$source_dir"
rm -rf "/tmp/pve-update-$$"
# Clean up temporary script if it exists
if [ -f "/tmp/pve-scripts-update-$$.sh" ]; then
rm -f "/tmp/pve-scripts-update-$$.sh"
fi
# Start the application
start_application