Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
172d6cdf9f | ||
|
|
8b630c9201 | ||
|
|
5eaafbde48 | ||
|
|
92f78c7008 | ||
|
|
d932f5a499 | ||
|
|
39a572a393 | ||
|
|
81fbd440ce | ||
|
|
6a84da5e85 | ||
|
|
0d40ced2f8 | ||
|
|
37d7aea258 | ||
|
|
e3f10b8b6e |
@@ -16,3 +16,8 @@ ALLOWED_SCRIPT_PATHS="scripts/"
|
|||||||
|
|
||||||
# WebSocket Configuration
|
# WebSocket Configuration
|
||||||
WEBSOCKET_PORT="3001"
|
WEBSOCKET_PORT="3001"
|
||||||
|
|
||||||
|
# User settings
|
||||||
|
GITHUB_TOKEN=
|
||||||
|
SAVE_FILTER=false
|
||||||
|
FILTERS=
|
||||||
|
|||||||
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -11,5 +11,6 @@
|
|||||||
|
|
||||||
|
|
||||||
# Set default reviewers
|
# Set default reviewers
|
||||||
|
* @michelroegl-brunner
|
||||||
* @community-scripts/Contributor
|
* @community-scripts/Contributor
|
||||||
|
|
||||||
|
|||||||
296
package-lock.json
generated
296
package-lock.json
generated
@@ -34,7 +34,7 @@
|
|||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"ws": "^8.18.3",
|
"ws": "^8.18.3",
|
||||||
"zod": "^3.24.2"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
"@vitest/ui": "^3.2.4",
|
"@vitest/ui": "^3.2.4",
|
||||||
"eslint": "^9.23.0",
|
"eslint": "^9.23.0",
|
||||||
"eslint-config-next": "^15.5.4",
|
"eslint-config-next": "^15.5.4",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^27.0.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
@@ -96,25 +96,59 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
"version": "3.2.0",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.0.5.tgz",
|
||||||
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
|
"integrity": "sha512-lMrXidNhPGsDjytDy11Vwlb6OIGrT3CmLg3VWNFyWkLWtijKl7xjvForlh8vuj0SHGjgl4qZEQzUmYTeQA2JFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@csstools/css-calc": "^2.1.3",
|
"@csstools/css-calc": "^2.1.4",
|
||||||
"@csstools/css-color-parser": "^3.0.9",
|
"@csstools/css-color-parser": "^3.1.0",
|
||||||
"@csstools/css-parser-algorithms": "^3.0.4",
|
"@csstools/css-parser-algorithms": "^3.0.5",
|
||||||
"@csstools/css-tokenizer": "^3.0.3",
|
"@csstools/css-tokenizer": "^3.0.4",
|
||||||
"lru-cache": "^10.4.3"
|
"lru-cache": "^11.2.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
"node_modules/@asamuzakjp/css-color/node_modules/lru-cache": {
|
||||||
"version": "10.4.3",
|
"version": "11.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
|
||||||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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"
|
||||||
},
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.27.1",
|
"version": "7.27.1",
|
||||||
@@ -512,6 +546,29 @@
|
|||||||
"@csstools/css-tokenizer": "^3.0.4"
|
"@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": {
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
"version": "3.0.4",
|
"version": "3.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz",
|
||||||
@@ -2599,6 +2656,66 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.14",
|
"version": "4.1.14",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz",
|
||||||
@@ -4156,6 +4273,16 @@
|
|||||||
"node": "20.x || 22.x || 23.x || 24.x"
|
"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": {
|
"node_modules/bindings": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
|
||||||
@@ -4533,6 +4660,20 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/css.escape": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
|
||||||
@@ -4541,17 +4682,18 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/cssstyle": {
|
"node_modules/cssstyle": {
|
||||||
"version": "4.6.0",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.1.tgz",
|
||||||
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
|
"integrity": "sha512-g5PC9Aiph9eiczFpcgUhd9S4UUO3F+LHGRIi5NUMZ+4xtoIYbHNZwZnWA2JsFGe8OU8nl4WyaEFiZuGuxlutJQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asamuzakjp/css-color": "^3.2.0",
|
"@asamuzakjp/css-color": "^4.0.3",
|
||||||
"rrweb-cssom": "^0.8.0"
|
"@csstools/css-syntax-patches-for-csstree": "^1.0.14",
|
||||||
|
"css-tree": "^3.1.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
@@ -4568,17 +4710,17 @@
|
|||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
"version": "5.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.0.tgz",
|
||||||
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
|
"integrity": "sha512-BnBS08aLUM+DKamupXs3w2tJJoqU+AkaE/+6vQxi/G/DPmIZFJJp9Dkb1kM03AZx8ADehDUZgsNxju3mPXZYIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"whatwg-mimetype": "^4.0.0",
|
"whatwg-mimetype": "^4.0.0",
|
||||||
"whatwg-url": "^14.0.0"
|
"whatwg-url": "^15.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/data-view-buffer": {
|
"node_modules/data-view-buffer": {
|
||||||
@@ -6938,35 +7080,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jsdom": {
|
"node_modules/jsdom": {
|
||||||
"version": "26.1.0",
|
"version": "27.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.0.tgz",
|
||||||
"integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==",
|
"integrity": "sha512-lIHeR1qlIRrIN5VMccd8tI2Sgw6ieYXSVktcSHaNe3Z5nE/tcPQYQWOq00wxMvYOsz+73eAkNenVvmPC6bba9A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cssstyle": "^4.2.1",
|
"@asamuzakjp/dom-selector": "^6.5.4",
|
||||||
"data-urls": "^5.0.0",
|
"cssstyle": "^5.3.0",
|
||||||
|
"data-urls": "^6.0.0",
|
||||||
"decimal.js": "^10.5.0",
|
"decimal.js": "^10.5.0",
|
||||||
"html-encoding-sniffer": "^4.0.0",
|
"html-encoding-sniffer": "^4.0.0",
|
||||||
"http-proxy-agent": "^7.0.2",
|
"http-proxy-agent": "^7.0.2",
|
||||||
"https-proxy-agent": "^7.0.6",
|
"https-proxy-agent": "^7.0.6",
|
||||||
"is-potential-custom-element-name": "^1.0.1",
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
"nwsapi": "^2.2.16",
|
"parse5": "^7.3.0",
|
||||||
"parse5": "^7.2.1",
|
|
||||||
"rrweb-cssom": "^0.8.0",
|
"rrweb-cssom": "^0.8.0",
|
||||||
"saxes": "^6.0.0",
|
"saxes": "^6.0.0",
|
||||||
"symbol-tree": "^3.2.4",
|
"symbol-tree": "^3.2.4",
|
||||||
"tough-cookie": "^5.1.1",
|
"tough-cookie": "^6.0.0",
|
||||||
"w3c-xmlserializer": "^5.0.0",
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
"webidl-conversions": "^7.0.0",
|
"webidl-conversions": "^8.0.0",
|
||||||
"whatwg-encoding": "^3.1.1",
|
"whatwg-encoding": "^3.1.1",
|
||||||
"whatwg-mimetype": "^4.0.0",
|
"whatwg-mimetype": "^4.0.0",
|
||||||
"whatwg-url": "^14.1.1",
|
"whatwg-url": "^15.0.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.2",
|
||||||
"xml-name-validator": "^5.0.0"
|
"xml-name-validator": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"canvas": "^3.0.0"
|
"canvas": "^3.0.0"
|
||||||
@@ -7471,6 +7613,13 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@@ -7759,13 +7908,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -8748,6 +8890,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.10",
|
"version": "1.22.10",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
|
||||||
@@ -9895,22 +10047,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tldts": {
|
"node_modules/tldts": {
|
||||||
"version": "6.1.86",
|
"version": "7.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz",
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.16.tgz",
|
||||||
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
|
"integrity": "sha512-5bdPHSwbKTeHmXrgecID4Ljff8rQjv7g8zKQPkCozRo2HWWni+p310FSn5ImI+9kWw9kK4lzOB5q/a6iv0IJsw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tldts-core": "^6.1.86"
|
"tldts-core": "^7.0.16"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"tldts": "bin/cli.js"
|
"tldts": "bin/cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tldts-core": {
|
"node_modules/tldts-core": {
|
||||||
"version": "6.1.86",
|
"version": "7.0.16",
|
||||||
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.16.tgz",
|
||||||
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
|
"integrity": "sha512-XHhPmHxphLi+LGbH0G/O7dmUH9V65OY20R7vH8gETHsp5AZCjBk9l8sqmRKLaGOxnETU7XNSDUPtewAy/K6jbA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -9938,29 +10090,29 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tough-cookie": {
|
"node_modules/tough-cookie": {
|
||||||
"version": "5.1.2",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz",
|
||||||
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
|
"integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tldts": "^6.1.32"
|
"tldts": "^7.0.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tr46": {
|
"node_modules/tr46": {
|
||||||
"version": "5.1.1",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"punycode": "^2.3.1"
|
"punycode": "^2.3.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
@@ -10484,13 +10636,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "7.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.0.tgz",
|
||||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
"integrity": "sha512-n4W4YFyz5JzOfQeA8oN7dUYpR+MBP3PIUsn2jLjWXwK5ASUzt0Jc/A5sAUZoCYFJRGF0FBKJ+1JjN43rNdsQzA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-2-Clause",
|
"license": "BSD-2-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/whatwg-encoding": {
|
"node_modules/whatwg-encoding": {
|
||||||
@@ -10517,17 +10669,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/whatwg-url": {
|
"node_modules/whatwg-url": {
|
||||||
"version": "14.2.0",
|
"version": "15.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz",
|
||||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
"integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tr46": "^5.1.0",
|
"tr46": "^6.0.0",
|
||||||
"webidl-conversions": "^7.0.0"
|
"webidl-conversions": "^8.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
@@ -10821,9 +10973,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/zod": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
"superjson": "^2.2.1",
|
"superjson": "^2.2.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"ws": "^8.18.3",
|
"ws": "^8.18.3",
|
||||||
"zod": "^3.24.2"
|
"zod": "^4.1.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
@@ -65,7 +65,7 @@
|
|||||||
"@vitest/ui": "^3.2.4",
|
"@vitest/ui": "^3.2.4",
|
||||||
"eslint": "^9.23.0",
|
"eslint": "^9.23.0",
|
||||||
"eslint-config-next": "^15.5.4",
|
"eslint-config-next": "^15.5.4",
|
||||||
"jsdom": "^26.1.0",
|
"jsdom": "^27.0.0",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"prettier": "^3.5.3",
|
"prettier": "^3.5.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 64 KiB |
29
server.log
29
server.log
@@ -1,29 +0,0 @@
|
|||||||
|
|
||||||
> pve-scripts-local@0.1.0 dev
|
|
||||||
> node server.js
|
|
||||||
|
|
||||||
Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
|
||||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
|
||||||
code: 'EADDRINUSE',
|
|
||||||
errno: -98,
|
|
||||||
syscall: 'listen',
|
|
||||||
address: '0.0.0.0',
|
|
||||||
port: 3000
|
|
||||||
}
|
|
||||||
⨯ uncaughtException: Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
|
||||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
|
||||||
code: 'EADDRINUSE',
|
|
||||||
errno: -98,
|
|
||||||
syscall: 'listen',
|
|
||||||
address: '0.0.0.0',
|
|
||||||
port: 3000
|
|
||||||
}
|
|
||||||
⨯ uncaughtException: Error: listen EADDRINUSE: address already in use 0.0.0.0:3000
|
|
||||||
at <unknown> (Error: listen EADDRINUSE: address already in use 0.0.0.0:3000) {
|
|
||||||
code: 'EADDRINUSE',
|
|
||||||
errno: -98,
|
|
||||||
syscall: 'listen',
|
|
||||||
address: '0.0.0.0',
|
|
||||||
port: 3000
|
|
||||||
}
|
|
||||||
Terminated
|
|
||||||
@@ -196,7 +196,7 @@ export function CategorySidebar({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
|
<div className={`bg-card rounded-lg shadow-md border border-border transition-all duration-300 ${
|
||||||
isCollapsed ? 'w-16' : 'w-80'
|
isCollapsed ? 'w-16' : 'w-full lg:w-80'
|
||||||
}`}>
|
}`}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
<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 */}
|
{/* Collapsed state - show only icons with counters and tooltips */}
|
||||||
{isCollapsed && (
|
{isCollapsed && (
|
||||||
<div className="p-2 flex flex-col space-y-2">
|
<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">
|
||||||
{/* "All Categories" option */}
|
{/* "All Categories" option */}
|
||||||
<div className="group relative">
|
<div className="group relative">
|
||||||
<button
|
<button
|
||||||
@@ -317,7 +317,7 @@ export function CategorySidebar({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Tooltip */}
|
{/* 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">
|
<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">
|
||||||
All Categories ({totalScripts})
|
All Categories ({totalScripts})
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -350,7 +350,7 @@ export function CategorySidebar({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Tooltip */}
|
{/* 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">
|
<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">
|
||||||
{category} ({count})
|
{category} ({count})
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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"
|
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
|
||||||
onClick={handleBackdropClick}
|
onClick={handleBackdropClick}
|
||||||
>
|
>
|
||||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] overflow-hidden border border-border">
|
<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">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export function DownloadedScriptsTab() {
|
|||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
sortOrder: 'asc',
|
sortOrder: 'asc',
|
||||||
});
|
});
|
||||||
|
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||||
|
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
@@ -29,6 +31,62 @@ export function DownloadedScriptsTab() {
|
|||||||
{ enabled: !!selectedSlug }
|
{ enabled: !!selectedSlug }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Load SAVE_FILTER setting and saved filters on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
// Load SAVE_FILTER setting
|
||||||
|
const saveFilterResponse = await fetch('/api/settings/save-filter');
|
||||||
|
let saveFilterEnabled = false;
|
||||||
|
if (saveFilterResponse.ok) {
|
||||||
|
const saveFilterData = await saveFilterResponse.json();
|
||||||
|
saveFilterEnabled = saveFilterData.enabled ?? false;
|
||||||
|
setSaveFiltersEnabled(saveFilterEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved filters if SAVE_FILTER is enabled
|
||||||
|
if (saveFilterEnabled) {
|
||||||
|
const filtersResponse = await fetch('/api/settings/filters');
|
||||||
|
if (filtersResponse.ok) {
|
||||||
|
const filtersData = await filtersResponse.json();
|
||||||
|
if (filtersData.filters) {
|
||||||
|
setFilters(filtersData.filters as FilterState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingFilters(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save filters when they change (if SAVE_FILTER is enabled)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!saveFiltersEnabled || isLoadingFilters) return;
|
||||||
|
|
||||||
|
const saveFilters = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings/filters', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filters }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving filters:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the save operation
|
||||||
|
const timeoutId = setTimeout(() => void saveFilters(), 500);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
||||||
|
|
||||||
// Extract categories from metadata
|
// Extract categories from metadata
|
||||||
const categories = React.useMemo((): string[] => {
|
const categories = React.useMemo((): string[] => {
|
||||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||||
@@ -320,9 +378,9 @@ export function DownloadedScriptsTab() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-6">
|
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||||
{/* Category Sidebar */}
|
{/* Category Sidebar */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 order-2 lg:order-1">
|
||||||
<CategorySidebar
|
<CategorySidebar
|
||||||
categories={categories}
|
categories={categories}
|
||||||
categoryCounts={categoryCounts}
|
categoryCounts={categoryCounts}
|
||||||
@@ -333,7 +391,7 @@ export function DownloadedScriptsTab() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 min-w-0" ref={gridRef}>
|
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
|
||||||
{/* Enhanced Filter Bar */}
|
{/* Enhanced Filter Bar */}
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@@ -341,6 +399,8 @@ export function DownloadedScriptsTab() {
|
|||||||
totalScripts={downloadedScripts.length}
|
totalScripts={downloadedScripts.length}
|
||||||
filteredCount={filteredScripts.length}
|
filteredCount={filteredScripts.length}
|
||||||
updatableCount={filterCounts.updatableCount}
|
updatableCount={filterCounts.updatableCount}
|
||||||
|
saveFiltersEnabled={saveFiltersEnabled}
|
||||||
|
isLoadingFilters={isLoadingFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Scripts Grid */}
|
{/* Scripts Grid */}
|
||||||
|
|||||||
@@ -61,8 +61,8 @@ export function ExecutionModeModal({ isOpen, onClose, onExecute, scriptName }: E
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50">
|
<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 mx-4 border border-border">
|
<div className="bg-card rounded-lg shadow-xl max-w-md w-full border border-border">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
<h2 className="text-xl font-bold text-foreground">Execution Mode</h2>
|
<h2 className="text-xl font-bold text-foreground">Execution Mode</h2>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { Package, Monitor, Wrench, Server, FileText, Calendar } from "lucide-react";
|
import { Package, Monitor, Wrench, Server, FileText, Calendar, RefreshCw, Filter } from "lucide-react";
|
||||||
|
|
||||||
export interface FilterState {
|
export interface FilterState {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
@@ -18,6 +18,8 @@ interface FilterBarProps {
|
|||||||
totalScripts: number;
|
totalScripts: number;
|
||||||
filteredCount: number;
|
filteredCount: number;
|
||||||
updatableCount?: number;
|
updatableCount?: number;
|
||||||
|
saveFiltersEnabled?: boolean;
|
||||||
|
isLoadingFilters?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCRIPT_TYPES = [
|
const SCRIPT_TYPES = [
|
||||||
@@ -33,8 +35,11 @@ export function FilterBar({
|
|||||||
totalScripts,
|
totalScripts,
|
||||||
filteredCount,
|
filteredCount,
|
||||||
updatableCount = 0,
|
updatableCount = 0,
|
||||||
|
saveFiltersEnabled = false,
|
||||||
|
isLoadingFilters = false,
|
||||||
}: FilterBarProps) {
|
}: FilterBarProps) {
|
||||||
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
||||||
|
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
||||||
|
|
||||||
const updateFilters = (updates: Partial<FilterState>) => {
|
const updateFilters = (updates: Partial<FilterState>) => {
|
||||||
onFiltersChange({ ...filters, ...updates });
|
onFiltersChange({ ...filters, ...updates });
|
||||||
@@ -76,10 +81,32 @@ export function FilterBar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-border bg-card p-6 shadow-sm">
|
<div className="mb-6 rounded-lg border border-border bg-card p-4 sm:p-6 shadow-sm">
|
||||||
|
{/* Loading State */}
|
||||||
|
{isLoadingFilters && (
|
||||||
|
<div className="mb-4 flex items-center justify-center py-2">
|
||||||
|
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
||||||
|
<span>Loading saved filters...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filter Persistence Status */}
|
||||||
|
{!isLoadingFilters && saveFiltersEnabled && (
|
||||||
|
<div className="mb-4 flex items-center justify-center py-1">
|
||||||
|
<div className="flex items-center space-x-2 text-xs text-green-600">
|
||||||
|
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
<span>Filters are being saved automatically</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Search Bar */}
|
{/* Search Bar */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="relative max-w-md">
|
<div className="relative max-w-md w-full">
|
||||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 text-muted-foreground"
|
className="h-5 w-5 text-muted-foreground"
|
||||||
@@ -128,7 +155,7 @@ export function FilterBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Buttons */}
|
{/* Filter Buttons */}
|
||||||
<div className="mb-4 flex flex-wrap gap-3">
|
<div className="mb-4 flex flex-col sm:flex-row flex-wrap gap-2 sm:gap-3">
|
||||||
{/* Updateable Filter */}
|
{/* Updateable Filter */}
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -142,29 +169,31 @@ export function FilterBar({
|
|||||||
}}
|
}}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className={`${
|
className={`w-full sm:w-auto flex items-center justify-center space-x-2 ${
|
||||||
filters.showUpdatable === null
|
filters.showUpdatable === null
|
||||||
? "bg-muted text-muted-foreground hover:bg-accent"
|
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
: filters.showUpdatable === true
|
: filters.showUpdatable === true
|
||||||
? "border border-green-500/20 bg-green-500/10 text-green-400"
|
? "border border-green-500/20 bg-green-500/10 text-green-400"
|
||||||
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
: "border border-destructive/20 bg-destructive/10 text-destructive"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getUpdatableButtonText()}
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
<span>{getUpdatableButtonText()}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Type Dropdown */}
|
{/* Type Dropdown */}
|
||||||
<div className="relative">
|
<div className="relative w-full sm:w-auto">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
onClick={() => setIsTypeDropdownOpen(!isTypeDropdownOpen)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className={`flex items-center space-x-2 ${
|
className={`w-full flex items-center justify-center space-x-2 ${
|
||||||
filters.selectedTypes.length === 0
|
filters.selectedTypes.length === 0
|
||||||
? "bg-muted text-muted-foreground hover:bg-accent"
|
? "bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||||
: "border border-primary/20 bg-primary/10 text-primary"
|
: "border border-primary/20 bg-primary/10 text-primary"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<Filter className="h-4 w-4" />
|
||||||
<span>{getTypeButtonText()}</span>
|
<span>{getTypeButtonText()}</span>
|
||||||
<svg
|
<svg
|
||||||
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
className={`h-4 w-4 transition-transform ${isTypeDropdownOpen ? "rotate-180" : ""}`}
|
||||||
@@ -237,85 +266,122 @@ export function FilterBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sort Options */}
|
{/* Sort By Dropdown */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="relative w-full sm:w-auto">
|
||||||
{/* Sort By Dropdown */}
|
|
||||||
<div className="relative inline-flex items-center">
|
|
||||||
<select
|
|
||||||
value={filters.sortBy}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateFilters({ sortBy: e.target.value as "name" | "created" })
|
|
||||||
}
|
|
||||||
className="rounded-lg border border-input bg-background pl-9 pr-3 py-2 text-sm text-foreground focus:ring-2 focus:ring-primary focus:outline-none appearance-none"
|
|
||||||
>
|
|
||||||
<option value="name">By Name</option>
|
|
||||||
<option value="created">By Created Date</option>
|
|
||||||
</select>
|
|
||||||
<div className="absolute left-2 pointer-events-none">
|
|
||||||
{filters.sortBy === "name" ? (
|
|
||||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Sort Order Button */}
|
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() => setIsSortDropdownOpen(!isSortDropdownOpen)}
|
||||||
updateFilters({
|
|
||||||
sortOrder: filters.sortOrder === "asc" ? "desc" : "asc",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="flex items-center space-x-1 bg-muted text-muted-foreground hover:bg-accent"
|
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"
|
||||||
>
|
>
|
||||||
{filters.sortOrder === "asc" ? (
|
{filters.sortBy === "name" ? (
|
||||||
<>
|
<FileText 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="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>
|
</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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Filter Summary and Clear All */}
|
{/* Filter Summary and Clear All */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{filteredCount === totalScripts ? (
|
{filteredCount === totalScripts ? (
|
||||||
<span>Showing all {totalScripts} scripts</span>
|
<span>Showing all {totalScripts} scripts</span>
|
||||||
@@ -336,7 +402,7 @@ export function FilterBar({
|
|||||||
onClick={clearAllFilters}
|
onClick={clearAllFilters}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex items-center space-x-1 text-red-600 hover:bg-red-50 hover:text-red-800"
|
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"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -356,11 +422,14 @@ export function FilterBar({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Click outside to close dropdown */}
|
{/* Click outside to close dropdowns */}
|
||||||
{isTypeDropdownOpen && (
|
{(isTypeDropdownOpen || isSortDropdownOpen) && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-0"
|
className="fixed inset-0 z-0"
|
||||||
onClick={() => setIsTypeDropdownOpen(false)}
|
onClick={() => {
|
||||||
|
setIsTypeDropdownOpen(false);
|
||||||
|
setIsSortDropdownOpen(false);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
308
src/app/_components/GeneralSettingsModal.tsx
Normal file
308
src/app/_components/GeneralSettingsModal.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
import { Input } from './ui/input';
|
||||||
|
import { Toggle } from './ui/toggle';
|
||||||
|
|
||||||
|
interface GeneralSettingsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<'general' | 'github'>('general');
|
||||||
|
const [githubToken, setGithubToken] = useState('');
|
||||||
|
const [saveFilter, setSaveFilter] = useState(false);
|
||||||
|
const [savedFilters, setSavedFilters] = useState<any>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Load existing settings when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
void loadGithubToken();
|
||||||
|
void loadSaveFilter();
|
||||||
|
void loadSavedFilters();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const loadGithubToken = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/github-token');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setGithubToken((data.token as string) ?? '');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading GitHub token:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSaveFilter = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/save-filter');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSaveFilter((data.enabled as boolean) ?? false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading save filter setting:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSaveFilter = async (enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/save-filter', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSaveFilter(enabled);
|
||||||
|
setMessage({ type: 'success', text: 'Save filter setting updated!' });
|
||||||
|
|
||||||
|
// If disabling save filters, clear saved filters
|
||||||
|
if (!enabled) {
|
||||||
|
await clearSavedFilters();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save setting' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save setting' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSavedFilters = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/filters');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setSavedFilters(data.filters);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading saved filters:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearSavedFilters = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/filters', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setSavedFilters(null);
|
||||||
|
setMessage({ type: 'success', text: 'Saved filters cleared!' });
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setMessage({ type: 'error', text: errorData.error ?? 'Failed to clear filters' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to clear filters' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveGithubToken = async () => {
|
||||||
|
setIsSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/github-token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ token: githubToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setMessage({ type: 'success', text: 'GitHub token saved successfully!' });
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setMessage({ type: 'error', text: errorData.error ?? 'Failed to save token' });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessage({ type: 'error', text: 'Failed to save token' });
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
{/* 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>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
<Button
|
||||||
|
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 ${
|
||||||
|
activeTab === 'general'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
General
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setActiveTab('github')}
|
||||||
|
variant="ghost"
|
||||||
|
size="null"
|
||||||
|
className={`py-3 sm:py-4 px-1 border-b-2 font-medium text-sm w-full sm:w-auto ${
|
||||||
|
activeTab === 'github'
|
||||||
|
? 'border-blue-500 text-blue-600'
|
||||||
|
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</Button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
||||||
|
{activeTab === 'general' && (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
|
||||||
|
<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 mb-4">
|
||||||
|
Configure general application preferences and behavior.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">Save Filters</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Save your configured script filters.</p>
|
||||||
|
<Toggle
|
||||||
|
checked={saveFilter}
|
||||||
|
onCheckedChange={saveSaveFilter}
|
||||||
|
label="Enable filter saving"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{saveFilter && (
|
||||||
|
<div className="mt-4 p-3 bg-muted rounded-lg">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-foreground">Saved Filters</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{savedFilters ? 'Filters are currently saved' : 'No filters saved yet'}
|
||||||
|
</p>
|
||||||
|
{savedFilters && (
|
||||||
|
<div className="mt-2 text-xs text-muted-foreground">
|
||||||
|
<div>Search: {savedFilters.searchQuery ?? 'None'}</div>
|
||||||
|
<div>Types: {savedFilters.selectedTypes?.length ?? 0} selected</div>
|
||||||
|
<div>Sort: {savedFilters.sortBy} ({savedFilters.sortOrder})</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{savedFilters && (
|
||||||
|
<Button
|
||||||
|
onClick={clearSavedFilters}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-red-600 hover:text-red-800"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'github' && (
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">GitHub Integration</h3>
|
||||||
|
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||||
|
Configure GitHub integration for script management and updates.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 border border-border rounded-lg">
|
||||||
|
<h4 className="font-medium text-foreground mb-2">GitHub Personal Access Token</h4>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">Save a GitHub Personal Access Token to circumvent GitHub API rate limits.</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="github-token" className="block text-sm font-medium text-foreground mb-1">
|
||||||
|
Token
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="github-token"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your GitHub Personal Access Token"
|
||||||
|
value={githubToken}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setGithubToken(e.target.value)}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`p-3 rounded-md text-sm ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'bg-green-50 text-green-800 border border-green-200'
|
||||||
|
: 'bg-red-50 text-red-800 border border-red-200'
|
||||||
|
}`}>
|
||||||
|
{message.text}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={saveGithubToken}
|
||||||
|
disabled={isSaving || isLoading || !githubToken.trim()}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving...' : 'Save Token'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={loadGithubToken}
|
||||||
|
disabled={isLoading || isSaving}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Loading...' : 'Refresh'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { api } from '~/trpc/react';
|
import { api } from '~/trpc/react';
|
||||||
import { Terminal } from './Terminal';
|
import { Terminal } from './Terminal';
|
||||||
import { StatusBadge } from './Badge';
|
import { StatusBadge } from './Badge';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { ScriptInstallationCard } from './ScriptInstallationCard';
|
||||||
|
|
||||||
interface InstalledScript {
|
interface InstalledScript {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -30,6 +31,11 @@ export function InstalledScriptsTab() {
|
|||||||
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
|
const [editFormData, setEditFormData] = useState<{ script_name: string; container_id: string }>({ script_name: '', container_id: '' });
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
const [addFormData, setAddFormData] = useState<{ script_name: string; container_id: string; server_id: string }>({ script_name: '', container_id: '', server_id: 'local' });
|
const [addFormData, setAddFormData] = useState<{ script_name: string; container_id: string; server_id: string }>({ script_name: '', container_id: '', server_id: 'local' });
|
||||||
|
const [showAutoDetectForm, setShowAutoDetectForm] = useState(false);
|
||||||
|
const [autoDetectServerId, setAutoDetectServerId] = useState<string>('');
|
||||||
|
const [autoDetectStatus, setAutoDetectStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
|
||||||
|
const [cleanupStatus, setCleanupStatus] = useState<{ type: 'success' | 'error' | null; message: string }>({ type: null, message: '' });
|
||||||
|
const cleanupRunRef = useRef(false);
|
||||||
|
|
||||||
// Fetch installed scripts
|
// Fetch installed scripts
|
||||||
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
|
const { data: scriptsData, refetch: refetchScripts, isLoading } = api.installedScripts.getAllInstalledScripts.useQuery();
|
||||||
@@ -67,10 +73,87 @@ export function InstalledScriptsTab() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-detect LXC containers mutation
|
||||||
|
const autoDetectMutation = api.installedScripts.autoDetectLXCContainers.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
console.log('Auto-detect success:', data);
|
||||||
|
void refetchScripts();
|
||||||
|
setShowAutoDetectForm(false);
|
||||||
|
setAutoDetectServerId('');
|
||||||
|
|
||||||
|
// Show detailed message about what was added/skipped
|
||||||
|
let statusMessage = data.message ?? 'Auto-detection completed successfully!';
|
||||||
|
if (data.skippedContainers && data.skippedContainers.length > 0) {
|
||||||
|
const skippedNames = data.skippedContainers.map((c: any) => String(c.hostname)).join(', ');
|
||||||
|
statusMessage += ` Skipped duplicates: ${skippedNames}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutoDetectStatus({
|
||||||
|
type: 'success',
|
||||||
|
message: statusMessage
|
||||||
|
});
|
||||||
|
// Clear status after 8 seconds (longer for detailed info)
|
||||||
|
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 8000);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Auto-detect mutation error:', error);
|
||||||
|
console.error('Error details:', {
|
||||||
|
message: error.message,
|
||||||
|
data: error.data
|
||||||
|
});
|
||||||
|
setAutoDetectStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: error.message ?? 'Auto-detection failed. Please try again.'
|
||||||
|
});
|
||||||
|
// Clear status after 5 seconds
|
||||||
|
setTimeout(() => setAutoDetectStatus({ type: null, message: '' }), 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup orphaned scripts mutation
|
||||||
|
const cleanupMutation = api.installedScripts.cleanupOrphanedScripts.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
console.log('Cleanup success:', data);
|
||||||
|
void refetchScripts();
|
||||||
|
|
||||||
|
if (data.deletedCount > 0) {
|
||||||
|
setCleanupStatus({
|
||||||
|
type: 'success',
|
||||||
|
message: `Cleanup completed! Removed ${data.deletedCount} orphaned script(s): ${data.deletedScripts.join(', ')}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setCleanupStatus({
|
||||||
|
type: 'success',
|
||||||
|
message: 'Cleanup completed! No orphaned scripts found.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Clear status after 8 seconds (longer for cleanup info)
|
||||||
|
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 8000);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Cleanup mutation error:', error);
|
||||||
|
setCleanupStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: error.message ?? 'Cleanup failed. Please try again.'
|
||||||
|
});
|
||||||
|
// Clear status after 5 seconds
|
||||||
|
setTimeout(() => setCleanupStatus({ type: null, message: '' }), 5000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? [];
|
const scripts: InstalledScript[] = (scriptsData?.scripts as InstalledScript[]) ?? [];
|
||||||
const stats = statsData?.stats;
|
const stats = statsData?.stats;
|
||||||
|
|
||||||
|
// Run cleanup when component mounts and scripts are loaded (only once)
|
||||||
|
useEffect(() => {
|
||||||
|
if (scripts.length > 0 && serversData?.servers && !cleanupMutation.isPending && !cleanupRunRef.current) {
|
||||||
|
console.log('Running automatic cleanup check...');
|
||||||
|
cleanupRunRef.current = true;
|
||||||
|
void cleanupMutation.mutate();
|
||||||
|
}
|
||||||
|
}, [scripts.length, serversData?.servers, cleanupMutation]);
|
||||||
|
|
||||||
// Filter scripts based on search and filters
|
// Filter scripts based on search and filters
|
||||||
const filteredScripts = scripts.filter((script: InstalledScript) => {
|
const filteredScripts = scripts.filter((script: InstalledScript) => {
|
||||||
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = script.script_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
@@ -196,6 +279,25 @@ export function InstalledScriptsTab() {
|
|||||||
setAddFormData({ script_name: '', container_id: '', server_id: 'local' });
|
setAddFormData({ script_name: '', container_id: '', server_id: 'local' });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAutoDetect = () => {
|
||||||
|
if (!autoDetectServerId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoDetectMutation.isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAutoDetectStatus({ type: null, message: '' });
|
||||||
|
console.log('Starting auto-detect for server ID:', autoDetectServerId);
|
||||||
|
autoDetectMutation.mutate({ serverId: Number(autoDetectServerId) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelAutoDetect = () => {
|
||||||
|
setShowAutoDetectForm(false);
|
||||||
|
setAutoDetectServerId('');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
const formatDate = (dateString: string) => {
|
||||||
return new Date(dateString).toLocaleString();
|
return new Date(dateString).toLocaleString();
|
||||||
@@ -250,8 +352,8 @@ export function InstalledScriptsTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Script Button */}
|
{/* Add Script and Auto-Detect Buttons */}
|
||||||
<div className="mb-4">
|
<div className="mb-4 flex flex-col sm:flex-row gap-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
variant={showAddForm ? "outline" : "default"}
|
variant={showAddForm ? "outline" : "default"}
|
||||||
@@ -259,13 +361,20 @@ export function InstalledScriptsTab() {
|
|||||||
>
|
>
|
||||||
{showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'}
|
{showAddForm ? 'Cancel Add Script' : '+ Add Manual Script Entry'}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => setShowAutoDetectForm(!showAutoDetectForm)}
|
||||||
|
variant={showAutoDetectForm ? "outline" : "secondary"}
|
||||||
|
size="default"
|
||||||
|
>
|
||||||
|
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Script Form */}
|
{/* Add Script Form */}
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<div className="mb-6 p-6 bg-card rounded-lg border border-border shadow-sm">
|
<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-6">Add Manual Script Entry</h3>
|
<h3 className="text-lg font-semibold text-foreground mb-4 sm:mb-6">Add Manual Script Entry</h3>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-medium text-foreground">
|
<label className="block text-sm font-medium text-foreground">
|
||||||
Script Name *
|
Script Name *
|
||||||
@@ -308,11 +417,12 @@ export function InstalledScriptsTab() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end space-x-3 mt-6">
|
<div className="flex flex-col sm:flex-row justify-end gap-3 mt-4 sm:mt-6">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCancelAdd}
|
onClick={handleCancelAdd}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -321,6 +431,7 @@ export function InstalledScriptsTab() {
|
|||||||
disabled={createScriptMutation.isPending}
|
disabled={createScriptMutation.isPending}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="default"
|
size="default"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{createScriptMutation.isPending ? 'Adding...' : 'Add Script'}
|
{createScriptMutation.isPending ? 'Adding...' : 'Add Script'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -328,9 +439,149 @@ export function InstalledScriptsTab() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Status Messages */}
|
||||||
|
{(autoDetectStatus.type ?? cleanupStatus.type) && (
|
||||||
|
<div className="mb-4 space-y-2">
|
||||||
|
{/* Auto-Detect Status Message */}
|
||||||
|
{autoDetectStatus.type && (
|
||||||
|
<div className={`p-4 rounded-lg border ${
|
||||||
|
autoDetectStatus.type === 'success'
|
||||||
|
? 'bg-green-50 dark:bg-green-950/20 border-green-200 dark:border-green-800'
|
||||||
|
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{autoDetectStatus.type === 'success' ? (
|
||||||
|
<svg className="h-5 w-5 text-green-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<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-3">
|
||||||
|
<p className={`text-sm font-medium ${
|
||||||
|
autoDetectStatus.type === 'success'
|
||||||
|
? 'text-green-800 dark:text-green-200'
|
||||||
|
: 'text-red-800 dark:text-red-200'
|
||||||
|
}`}>
|
||||||
|
{autoDetectStatus.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cleanup Status Message */}
|
||||||
|
{cleanupStatus.type && (
|
||||||
|
<div className={`p-4 rounded-lg border ${
|
||||||
|
cleanupStatus.type === 'success'
|
||||||
|
? 'bg-slate-50 dark:bg-slate-900/50 border-slate-200 dark:border-slate-700'
|
||||||
|
: 'bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{cleanupStatus.type === 'success' ? (
|
||||||
|
<svg className="h-5 w-5 text-slate-500 dark:text-slate-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<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-3">
|
||||||
|
<p className={`text-sm font-medium ${
|
||||||
|
cleanupStatus.type === 'success'
|
||||||
|
? 'text-slate-700 dark:text-slate-300'
|
||||||
|
: 'text-red-800 dark:text-red-200'
|
||||||
|
}`}>
|
||||||
|
{cleanupStatus.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Auto-Detect LXC Containers Form */}
|
||||||
|
{showAutoDetectForm && (
|
||||||
|
<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">Auto-Detect LXC Containers (Must contain a tag with "community-script")</h3>
|
||||||
|
<div className="space-y-4 sm:space-y-6">
|
||||||
|
<div className="bg-slate-50 dark:bg-slate-900/30 border border-slate-200 dark:border-slate-700 rounded-lg p-4">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-5 w-5 text-slate-500 dark:text-slate-400" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h4 className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
|
How it works
|
||||||
|
</h4>
|
||||||
|
<div className="mt-2 text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
<p>This feature will:</p>
|
||||||
|
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||||
|
<li>Connect to the selected server via SSH</li>
|
||||||
|
<li>Scan all LXC config files in /etc/pve/lxc/</li>
|
||||||
|
<li>Find containers with "community-script" in their tags</li>
|
||||||
|
<li>Extract the container ID and hostname</li>
|
||||||
|
<li>Add them as installed script entries</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-sm font-medium text-foreground">
|
||||||
|
Select Server *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={autoDetectServerId}
|
||||||
|
onChange={(e) => setAutoDetectServerId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-input rounded-md bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring"
|
||||||
|
>
|
||||||
|
<option value="">Choose a server...</option>
|
||||||
|
{serversData?.servers?.map((server: any) => (
|
||||||
|
<option key={server.id} value={server.id}>
|
||||||
|
{server.name} ({server.ip})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col sm:flex-row justify-end gap-3 mt-4 sm:mt-6">
|
||||||
|
<Button
|
||||||
|
onClick={handleCancelAutoDetect}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAutoDetect}
|
||||||
|
disabled={autoDetectMutation.isPending || !autoDetectServerId}
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{autoDetectMutation.isPending ? '🔍 Scanning...' : '🔍 Start Auto-Detection'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="space-y-4">
|
||||||
<div className="flex-1 min-w-64">
|
{/* Search Input - Full Width on Mobile */}
|
||||||
|
<div className="w-full">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search scripts, container IDs, or servers..."
|
placeholder="Search scripts, container IDs, or servers..."
|
||||||
@@ -340,169 +591,195 @@ export function InstalledScriptsTab() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select
|
{/* Filter Dropdowns - Responsive Grid */}
|
||||||
value={statusFilter}
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
|
<select
|
||||||
className="px-3 py-2 border border-border rounded-md bg-card text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
value={statusFilter}
|
||||||
>
|
onChange={(e) => setStatusFilter(e.target.value as 'all' | 'success' | 'failed' | 'in_progress')}
|
||||||
<option value="all">All Status</option>
|
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="success">Success</option>
|
>
|
||||||
<option value="failed">Failed</option>
|
<option value="all">All Status</option>
|
||||||
<option value="in_progress">In Progress</option>
|
<option value="success">Success</option>
|
||||||
</select>
|
<option value="failed">Failed</option>
|
||||||
|
<option value="in_progress">In Progress</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
value={serverFilter}
|
value={serverFilter}
|
||||||
onChange={(e) => setServerFilter(e.target.value)}
|
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"
|
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="all">All Servers</option>
|
||||||
<option value="local">Local</option>
|
<option value="local">Local</option>
|
||||||
{uniqueServers.map(server => (
|
{uniqueServers.map(server => (
|
||||||
<option key={server} value={server}>{server}</option>
|
<option key={server} value={server}>{server}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scripts Table */}
|
{/* Scripts Display - Mobile Cards / Desktop Table */}
|
||||||
<div className="bg-card rounded-lg shadow overflow-hidden">
|
<div className="bg-card rounded-lg shadow overflow-hidden">
|
||||||
{filteredScripts.length === 0 ? (
|
{filteredScripts.length === 0 ? (
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
|
{scripts.length === 0 ? 'No installed scripts found.' : 'No scripts match your filters.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<>
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
{/* Mobile Card Layout */}
|
||||||
<thead className="bg-muted">
|
<div className="block md:hidden p-4 space-y-4">
|
||||||
<tr>
|
{filteredScripts.map((script) => (
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
<ScriptInstallationCard
|
||||||
Script Name
|
key={script.id}
|
||||||
</th>
|
script={script}
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
isEditing={editingScriptId === script.id}
|
||||||
Container ID
|
editFormData={editFormData}
|
||||||
</th>
|
onInputChange={handleInputChange}
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
onEdit={() => handleEditScript(script)}
|
||||||
Server
|
onSave={handleSaveEdit}
|
||||||
</th>
|
onCancel={handleCancelEdit}
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
onUpdate={() => handleUpdateScript(script)}
|
||||||
Status
|
onDelete={() => handleDeleteScript(Number(script.id))}
|
||||||
</th>
|
isUpdating={updateScriptMutation.isPending}
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
isDeleting={deleteScriptMutation.isPending}
|
||||||
Installation Date
|
/>
|
||||||
</th>
|
))}
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
</div>
|
||||||
Actions
|
|
||||||
</th>
|
{/* Desktop Table Layout */}
|
||||||
</tr>
|
<div className="hidden md:block overflow-x-auto">
|
||||||
</thead>
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<tbody className="bg-card divide-y divide-gray-200">
|
<thead className="bg-muted">
|
||||||
{filteredScripts.map((script) => (
|
<tr>
|
||||||
<tr key={script.id} className="hover:bg-accent">
|
<th className="px-6 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
Script Name
|
||||||
{editingScriptId === script.id ? (
|
</th>
|
||||||
<div className="space-y-2">
|
<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 ? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editFormData.script_name}
|
value={editFormData.container_id}
|
||||||
onChange={(e) => handleInputChange('script_name', e.target.value)}
|
onChange={(e) => handleInputChange('container_id', e.target.value)}
|
||||||
className="w-full px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
className="w-full px-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="Script name"
|
placeholder="Container ID"
|
||||||
/>
|
/>
|
||||||
<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 ? (
|
|
||||||
<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>
|
script.container_id ? (
|
||||||
)
|
<span className="text-sm font-mono text-foreground">{String(script.container_id)}</span>
|
||||||
)}
|
) : (
|
||||||
</td>
|
<span className="text-sm text-muted-foreground">-</span>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
)
|
||||||
<span className="text-sm text-muted-foreground">
|
)}
|
||||||
{script.server_name ?? 'Local'}
|
</td>
|
||||||
</span>
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
</td>
|
<span className="text-sm text-muted-foreground">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
{script.server_name ?? 'Local'}
|
||||||
<StatusBadge status={script.status}>
|
</span>
|
||||||
{script.status.replace('_', ' ').toUpperCase()}
|
</td>
|
||||||
</StatusBadge>
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
</td>
|
<StatusBadge status={script.status}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
{script.status.replace('_', ' ').toUpperCase()}
|
||||||
{formatDate(String(script.installation_date))}
|
</StatusBadge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-muted-foreground">
|
||||||
<div className="flex space-x-2">
|
{formatDate(String(script.installation_date))}
|
||||||
{editingScriptId === script.id ? (
|
</td>
|
||||||
<>
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<Button
|
<div className="flex space-x-2">
|
||||||
onClick={handleSaveEdit}
|
{editingScriptId === script.id ? (
|
||||||
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
|
<Button
|
||||||
onClick={() => handleUpdateScript(script)}
|
onClick={handleSaveEdit}
|
||||||
variant="link"
|
disabled={updateScriptMutation.isPending}
|
||||||
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
Update
|
{updateScriptMutation.isPending ? 'Saving...' : 'Save'}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<Button
|
||||||
<Button
|
onClick={handleCancelEdit}
|
||||||
onClick={() => handleDeleteScript(Number(script.id))}
|
variant="outline"
|
||||||
variant="destructive"
|
size="sm"
|
||||||
size="sm"
|
>
|
||||||
disabled={deleteScriptMutation.isPending}
|
Cancel
|
||||||
>
|
</Button>
|
||||||
{deleteScriptMutation.isPending ? 'Deleting...' : 'Delete'}
|
</>
|
||||||
</Button>
|
) : (
|
||||||
</>
|
<>
|
||||||
)}
|
<Button
|
||||||
</div>
|
onClick={() => handleEditScript(script)}
|
||||||
</td>
|
variant="default"
|
||||||
</tr>
|
size="sm"
|
||||||
))}
|
>
|
||||||
</tbody>
|
Edit
|
||||||
</table>
|
</Button>
|
||||||
</div>
|
{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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -136,38 +136,63 @@ export function ScriptDetailModal({
|
|||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
|
||||||
onClick={handleBackdropClick}
|
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">
|
<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">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-border p-6">
|
<div className="flex items-center justify-between border-b border-border p-4 sm:p-6">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-3 sm:space-x-4 min-w-0 flex-1">
|
||||||
{script.logo && !imageError ? (
|
{script.logo && !imageError ? (
|
||||||
<Image
|
<Image
|
||||||
src={script.logo}
|
src={script.logo}
|
||||||
alt={`${script.name} logo`}
|
alt={`${script.name} logo`}
|
||||||
width={64}
|
width={64}
|
||||||
height={64}
|
height={64}
|
||||||
className="h-16 w-16 rounded-lg object-contain"
|
className="h-12 w-12 sm:h-16 sm:w-16 rounded-lg object-contain flex-shrink-0"
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-16 w-16 items-center justify-center rounded-lg bg-muted">
|
<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-2xl font-semibold text-muted-foreground">
|
<span className="text-lg sm:text-2xl font-semibold text-muted-foreground">
|
||||||
{script.name.charAt(0).toUpperCase()}
|
{script.name.charAt(0).toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="text-2xl font-bold text-foreground">
|
<h2 className="text-xl sm:text-2xl font-bold text-foreground truncate">
|
||||||
{script.name}
|
{script.name}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="mt-1 flex items-center space-x-2">
|
<div className="mt-1 flex flex-wrap items-center gap-1 sm:gap-2">
|
||||||
<TypeBadge type={script.type} />
|
<TypeBadge type={script.type} />
|
||||||
{script.updateable && <UpdateableBadge />}
|
{script.updateable && <UpdateableBadge />}
|
||||||
{script.privileged && <PrivilegedBadge />}
|
{script.privileged && <PrivilegedBadge />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
|
||||||
|
{/* 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">
|
||||||
{/* Install Button - only show if script files exist */}
|
{/* Install Button - only show if script files exist */}
|
||||||
{scriptFilesData?.success &&
|
{scriptFilesData?.success &&
|
||||||
scriptFilesData.ctExists &&
|
scriptFilesData.ctExists &&
|
||||||
@@ -176,7 +201,7 @@ export function ScriptDetailModal({
|
|||||||
onClick={handleInstallScript}
|
onClick={handleInstallScript}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="flex items-center space-x-2"
|
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -202,7 +227,7 @@ export function ScriptDetailModal({
|
|||||||
onClick={handleViewScript}
|
onClick={handleViewScript}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="flex items-center space-x-2 "
|
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
@@ -335,39 +360,18 @@ 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>
|
</div>
|
||||||
|
|
||||||
{/* Load Message */}
|
{/* Load Message */}
|
||||||
{loadMessage && (
|
{loadMessage && (
|
||||||
<div className="mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
<div className="mx-4 sm:mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
||||||
{loadMessage}
|
{loadMessage}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Script Files Status */}
|
{/* Script Files Status */}
|
||||||
{(scriptFilesLoading || comparisonLoading) && (
|
{(scriptFilesLoading || comparisonLoading) && (
|
||||||
<div className="mx-6 mb-4 rounded-lg bg-primary/10 p-3 text-sm text-primary">
|
<div className="mx-4 sm: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="flex items-center space-x-2">
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||||
<span>Loading script status...</span>
|
<span>Loading script status...</span>
|
||||||
@@ -392,8 +396,8 @@ export function ScriptDetailModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-6 mb-4 rounded-lg bg-muted p-3 text-sm text-muted-foreground">
|
<div className="mx-4 sm: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 flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div
|
<div
|
||||||
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-muted"}`}
|
className={`h-2 w-2 rounded-full ${scriptFilesData.ctExists ? "bg-green-500" : "bg-muted"}`}
|
||||||
@@ -433,7 +437,7 @@ export function ScriptDetailModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{scriptFilesData.files.length > 0 && (
|
{scriptFilesData.files.length > 0 && (
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
<div className="mt-2 text-xs text-muted-foreground break-words">
|
||||||
Files: {scriptFilesData.files.join(", ")}
|
Files: {scriptFilesData.files.join(", ")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -442,21 +446,21 @@ export function ScriptDetailModal({
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="space-y-6 p-6">
|
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
<h3 className="mb-2 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Description
|
Description
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-sm sm:text-base text-muted-foreground">
|
||||||
{script.description}
|
{script.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Basic Information */}
|
{/* Basic Information */}
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:gap-6 lg:grid-cols-2">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Basic Information
|
Basic Information
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
@@ -508,7 +512,7 @@ export function ScriptDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Links
|
Links
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
@@ -555,24 +559,24 @@ export function ScriptDetailModal({
|
|||||||
script.type !== "pve" &&
|
script.type !== "pve" &&
|
||||||
script.type !== "addon" && (
|
script.type !== "addon" && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Install Methods
|
Install Methods
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{script.install_methods.map((method, index) => (
|
{script.install_methods.map((method, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="rounded-lg border border-border bg-card p-4"
|
className="rounded-lg border border-border bg-card p-3 sm:p-4"
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex flex-col sm:flex-row sm:items-center justify-between space-y-1 sm:space-y-0">
|
||||||
<h4 className="font-medium text-foreground capitalize">
|
<h4 className="text-sm sm:text-base font-medium text-foreground capitalize">
|
||||||
{method.type}
|
{method.type}
|
||||||
</h4>
|
</h4>
|
||||||
<span className="font-mono text-sm text-muted-foreground">
|
<span className="font-mono text-xs sm:text-sm text-muted-foreground break-all">
|
||||||
{method.script}
|
{method.script}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-2 sm:gap-4 text-xs sm:text-sm lg:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-medium text-muted-foreground">
|
<dt className="font-medium text-muted-foreground">
|
||||||
CPU
|
CPU
|
||||||
@@ -616,7 +620,7 @@ export function ScriptDetailModal({
|
|||||||
{(script.default_credentials.username ??
|
{(script.default_credentials.username ??
|
||||||
script.default_credentials.password) && (
|
script.default_credentials.password) && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="mb-3 text-lg font-semibold text-foreground">
|
<h3 className="mb-3 text-base sm:text-lg font-semibold text-foreground">
|
||||||
Default Credentials
|
Default Credentials
|
||||||
</h3>
|
</h3>
|
||||||
<dl className="space-y-2">
|
<dl className="space-y-2">
|
||||||
|
|||||||
175
src/app/_components/ScriptInstallationCard.tsx
Normal file
175
src/app/_components/ScriptInstallationCard.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
sortBy: 'name',
|
sortBy: 'name',
|
||||||
sortOrder: 'asc',
|
sortOrder: 'asc',
|
||||||
});
|
});
|
||||||
|
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||||
|
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||||
const gridRef = useRef<HTMLDivElement>(null);
|
const gridRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||||
@@ -35,6 +37,62 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
{ enabled: !!selectedSlug }
|
{ enabled: !!selectedSlug }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Load SAVE_FILTER setting and saved filters on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
// Load SAVE_FILTER setting
|
||||||
|
const saveFilterResponse = await fetch('/api/settings/save-filter');
|
||||||
|
let saveFilterEnabled = false;
|
||||||
|
if (saveFilterResponse.ok) {
|
||||||
|
const saveFilterData = await saveFilterResponse.json();
|
||||||
|
saveFilterEnabled = saveFilterData.enabled ?? false;
|
||||||
|
setSaveFiltersEnabled(saveFilterEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved filters if SAVE_FILTER is enabled
|
||||||
|
if (saveFilterEnabled) {
|
||||||
|
const filtersResponse = await fetch('/api/settings/filters');
|
||||||
|
if (filtersResponse.ok) {
|
||||||
|
const filtersData = await filtersResponse.json();
|
||||||
|
if (filtersData.filters) {
|
||||||
|
setFilters(filtersData.filters as FilterState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoadingFilters(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Save filters when they change (if SAVE_FILTER is enabled)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!saveFiltersEnabled || isLoadingFilters) return;
|
||||||
|
|
||||||
|
const saveFilters = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/settings/filters', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filters }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving filters:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce the save operation
|
||||||
|
const timeoutId = setTimeout(() => void saveFilters(), 500);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [filters, saveFiltersEnabled, isLoadingFilters]);
|
||||||
|
|
||||||
// Extract categories from metadata
|
// Extract categories from metadata
|
||||||
const categories = React.useMemo((): string[] => {
|
const categories = React.useMemo((): string[] => {
|
||||||
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
if (!scriptCardsData?.success || !scriptCardsData.metadata?.categories) return [];
|
||||||
@@ -316,9 +374,9 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-6">
|
<div className="flex flex-col lg:flex-row gap-4 lg:gap-6">
|
||||||
{/* Category Sidebar */}
|
{/* Category Sidebar */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0 order-2 lg:order-1">
|
||||||
<CategorySidebar
|
<CategorySidebar
|
||||||
categories={categories}
|
categories={categories}
|
||||||
categoryCounts={categoryCounts}
|
categoryCounts={categoryCounts}
|
||||||
@@ -329,7 +387,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 min-w-0" ref={gridRef}>
|
<div className="flex-1 min-w-0 order-1 lg:order-2" ref={gridRef}>
|
||||||
{/* Enhanced Filter Bar */}
|
{/* Enhanced Filter Bar */}
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@@ -337,6 +395,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
|||||||
totalScripts={scriptsWithStatus.length}
|
totalScripts={scriptsWithStatus.length}
|
||||||
filteredCount={filteredScripts.length}
|
filteredCount={filteredScripts.length}
|
||||||
updatableCount={filterCounts.updatableCount}
|
updatableCount={filterCounts.updatableCount}
|
||||||
|
saveFiltersEnabled={saveFiltersEnabled}
|
||||||
|
isLoadingFilters={isLoadingFilters}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
{/* Legacy Search Bar (keeping for backward compatibility, but hidden) */}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
<label htmlFor="name" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||||
Server Name *
|
Server Name *
|
||||||
@@ -144,13 +144,14 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4">
|
<div className="flex flex-col sm:flex-row justify-end space-y-2 sm:space-y-0 sm:space-x-3 pt-4">
|
||||||
{isEditing && onCancel && (
|
{isEditing && onCancel && (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
|
className="w-full sm:w-auto order-2 sm:order-1"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -159,6 +160,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="default"
|
variant="default"
|
||||||
size="default"
|
size="default"
|
||||||
|
className="w-full sm:w-auto order-1 sm:order-2"
|
||||||
>
|
>
|
||||||
{isEditing ? 'Update Server' : 'Add Server'}
|
{isEditing ? 'Update Server' : 'Add Server'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -102,30 +102,30 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between space-y-4 sm:space-y-0">
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-start sm:items-center space-x-3">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
<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-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 sm:w-6 sm: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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-lg font-medium text-foreground truncate">{server.name}</h3>
|
<h3 className="text-base sm: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">
|
<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">
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-1 flex-shrink-0" 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" />
|
<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>
|
</svg>
|
||||||
{server.ip}
|
<span className="truncate">{server.ip}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center">
|
<span className="flex items-center">
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4 mr-1 flex-shrink-0" 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" />
|
<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>
|
</svg>
|
||||||
{server.user}
|
<span className="truncate">{server.user}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1 text-xs text-muted-foreground">
|
<div className="mt-1 text-xs text-muted-foreground">
|
||||||
@@ -162,51 +162,58 @@ export function ServerList({ servers, onUpdate, onDelete }: ServerListProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleTestConnection(server)}
|
onClick={() => handleTestConnection(server)}
|
||||||
disabled={testingConnections.has(server.id)}
|
disabled={testingConnections.has(server.id)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
|
className="w-full sm:w-auto border-green-500/20 text-green-400 bg-green-500/10 hover:bg-green-500/20"
|
||||||
>
|
>
|
||||||
{testingConnections.has(server.id) ? (
|
{testingConnections.has(server.id) ? (
|
||||||
<>
|
<>
|
||||||
<svg className="w-4 h-4 mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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" />
|
<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>
|
</svg>
|
||||||
Testing...
|
<span className="hidden sm:inline">Testing...</span>
|
||||||
|
<span className="sm:hidden">Test...</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<svg className="w-4 h-4 mr-1" 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<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>
|
</svg>
|
||||||
Test Connection
|
<span className="hidden sm:inline">Test Connection</span>
|
||||||
|
<span className="sm:hidden">Test</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<div className="flex space-x-2">
|
||||||
onClick={() => handleEdit(server)}
|
<Button
|
||||||
variant="outline"
|
onClick={() => handleEdit(server)}
|
||||||
size="sm"
|
variant="outline"
|
||||||
>
|
size="sm"
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
className="flex-1 sm:flex-none"
|
||||||
<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>
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
Edit
|
<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" />
|
||||||
</Button>
|
</svg>
|
||||||
<Button
|
<span className="hidden sm:inline">Edit</span>
|
||||||
onClick={() => handleDelete(server.id)}
|
<span className="sm:hidden">✏️</span>
|
||||||
variant="outline"
|
</Button>
|
||||||
size="sm"
|
<Button
|
||||||
className="border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
|
onClick={() => handleDelete(server.id)}
|
||||||
>
|
variant="outline"
|
||||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
size="sm"
|
||||||
<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" />
|
className="flex-1 sm:flex-none border-destructive/20 text-destructive bg-destructive/10 hover:bg-destructive/20"
|
||||||
</svg>
|
>
|
||||||
Delete
|
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</Button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
50
src/app/_components/ServerSettingsButton.tsx
Normal file
50
src/app/_components/ServerSettingsButton.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { SettingsModal } from './SettingsModal';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
export function ServerSettingsButton() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
|
<div className="text-sm text-muted-foreground font-medium">
|
||||||
|
Add and manage PVE Servers:
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="inline-flex items-center"
|
||||||
|
title="Add PVE Server"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 mr-2"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Manage PVE Servers
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { SettingsModal } from './SettingsModal';
|
import { GeneralSettingsModal } from './GeneralSettingsModal';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { Settings } from 'lucide-react';
|
||||||
|
|
||||||
export function SettingsButton() {
|
export function SettingsButton() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -11,41 +12,21 @@ export function SettingsButton() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||||
<div className="text-sm text-muted-foreground font-medium">
|
<div className="text-sm text-muted-foreground font-medium">
|
||||||
Add and manage PVE Servers:
|
Application Settings:
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => setIsOpen(true)}
|
onClick={() => setIsOpen(true)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="default"
|
size="default"
|
||||||
className="inline-flex items-center"
|
className="inline-flex items-center"
|
||||||
title="Add PVE Server"
|
title="Open Settings"
|
||||||
>
|
>
|
||||||
<svg
|
<Settings className="w-5 h-5 mr-2" />
|
||||||
className="w-5 h-5 mr-2"
|
Settings
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
||||||
/>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Manage PVE Servers
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
<GeneralSettingsModal isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
const [servers, setServers] = useState<Server[]>([]);
|
const [servers, setServers] = useState<Server[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<'servers' | 'general'>('servers');
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -99,102 +98,64 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50">
|
<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 mx-4 max-h-[90vh] overflow-hidden">
|
<div className="bg-card rounded-lg shadow-xl max-w-4xl w-full max-h-[95vh] sm:max-h-[90vh] overflow-hidden">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-border">
|
||||||
<h2 className="text-2xl font-bold text-card-foreground">Settings</h2>
|
<h2 className="text-xl sm:text-2xl font-bold text-card-foreground">Settings</h2>
|
||||||
<Button
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
>
|
>
|
||||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5 sm:w-6 sm:h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="border-b border-gray-200">
|
|
||||||
<nav className="flex space-x-8 px-6">
|
|
||||||
<Button
|
|
||||||
onClick={() => setActiveTab('servers')}
|
|
||||||
variant="ghost"
|
|
||||||
size="null"
|
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Server Settings
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => setActiveTab('general')}
|
|
||||||
variant="ghost"
|
|
||||||
size="null"
|
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
General
|
|
||||||
</Button>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
|
<div className="p-4 sm:p-6 overflow-y-auto max-h-[calc(95vh-180px)] sm:max-h-[calc(90vh-200px)]">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 p-4 bg-destructive/10 border border-destructive rounded-md">
|
<div className="mb-4 p-3 sm:p-4 bg-destructive/10 border border-destructive rounded-md">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
|
<svg className="h-4 w-4 sm:h-5 sm: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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3">
|
<div className="ml-2 sm:ml-3 min-w-0 flex-1">
|
||||||
<h3 className="text-sm font-medium text-red-800">Error</h3>
|
<h3 className="text-xs sm:text-sm font-medium text-red-800">Error</h3>
|
||||||
<div className="mt-2 text-sm text-red-700">{error}</div>
|
<div className="mt-1 sm:mt-2 text-xs sm:text-sm text-red-700 break-words">{error}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'servers' && (
|
<div className="space-y-4 sm:space-y-6">
|
||||||
<div className="space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">Server Configurations</h3>
|
|
||||||
<ServerForm onSubmit={handleCreateServer} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
<p className="mt-2 text-gray-600">Loading servers...</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ServerList
|
|
||||||
servers={servers}
|
|
||||||
onUpdate={handleUpdateServer}
|
|
||||||
onDelete={handleDeleteServer}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'general' && (
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium text-foreground mb-4">General Settings</h3>
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm:mb-4">Server Configurations</h3>
|
||||||
<p className="text-muted-foreground">General settings will be available in a future update.</p>
|
<ServerForm onSubmit={handleCreateServer} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base sm:text-lg font-medium text-foreground mb-3 sm: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>
|
||||||
|
<p className="mt-2 text-gray-600">Loading servers...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ServerList
|
||||||
|
servers={servers}
|
||||||
|
onUpdate={handleUpdateServer}
|
||||||
|
onDelete={handleDeleteServer}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import '@xterm/xterm/css/xterm.css';
|
import '@xterm/xterm/css/xterm.css';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Play, Square, Trash2, X } from 'lucide-react';
|
import { Play, Square, Trash2, X, Send, Keyboard, ChevronUp, ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
interface TerminalProps {
|
interface TerminalProps {
|
||||||
scriptPath: string;
|
scriptPath: string;
|
||||||
@@ -24,6 +24,11 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
const [isRunning, setIsRunning] = useState(false);
|
const [isRunning, setIsRunning] = useState(false);
|
||||||
const [isClient, setIsClient] = 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 terminalRef = useRef<HTMLDivElement>(null);
|
||||||
const xtermRef = useRef<any>(null);
|
const xtermRef = useRef<any>(null);
|
||||||
const fitAddonRef = useRef<any>(null);
|
const fitAddonRef = useRef<any>(null);
|
||||||
@@ -34,31 +39,125 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
|
|
||||||
const scriptName = scriptPath.split('/').pop() ?? scriptPath.split('\\').pop() ?? 'Unknown Script';
|
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
|
// Ensure we're on the client side
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsClient(true);
|
setIsClient(true);
|
||||||
|
// Detect mobile on mount
|
||||||
|
setIsMobile(window.innerWidth < 768);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only initialize on client side
|
// Only initialize on client side
|
||||||
if (!isClient || !terminalRef.current || xtermRef.current) return;
|
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
|
// Use setTimeout to ensure DOM is fully ready
|
||||||
const initTerminal = async () => {
|
const initTerminal = async () => {
|
||||||
if (!terminalRef.current || xtermRef.current) return;
|
if (!terminalElement || xtermRef.current) return;
|
||||||
|
|
||||||
// Dynamically import xterm modules to avoid SSR issues
|
// Dynamically import xterm modules to avoid SSR issues
|
||||||
const { Terminal: XTerm } = await import('@xterm/xterm');
|
const { Terminal: XTerm } = await import('@xterm/xterm');
|
||||||
const { FitAddon } = await import('@xterm/addon-fit');
|
const { FitAddon } = await import('@xterm/addon-fit');
|
||||||
const { WebLinksAddon } = await import('@xterm/addon-web-links');
|
const { WebLinksAddon } = await import('@xterm/addon-web-links');
|
||||||
|
|
||||||
|
// Use the mobile state
|
||||||
|
|
||||||
const terminal = new XTerm({
|
const terminal = new XTerm({
|
||||||
theme: {
|
theme: {
|
||||||
background: '#000000',
|
background: '#000000',
|
||||||
foreground: '#00ff00',
|
foreground: '#00ff00',
|
||||||
cursor: '#00ff00',
|
cursor: '#00ff00',
|
||||||
},
|
},
|
||||||
fontSize: 14,
|
fontSize: isMobile ? 7 : 14,
|
||||||
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
|
fontFamily: 'JetBrains Mono, Fira Code, Cascadia Code, Monaco, Menlo, Ubuntu Mono, monospace',
|
||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: 'block',
|
cursorStyle: 'block',
|
||||||
@@ -70,6 +169,12 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
macOptionIsMeta: false,
|
macOptionIsMeta: false,
|
||||||
rightClickSelectsWord: false,
|
rightClickSelectsWord: false,
|
||||||
wordSeparator: ' ()[]{}\'"`<>|',
|
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
|
// Add addons
|
||||||
@@ -77,15 +182,41 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
const webLinksAddon = new WebLinksAddon();
|
const webLinksAddon = new WebLinksAddon();
|
||||||
terminal.loadAddon(fitAddon);
|
terminal.loadAddon(fitAddon);
|
||||||
terminal.loadAddon(webLinksAddon);
|
terminal.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
|
// Enable better ANSI handling
|
||||||
|
terminal.options.allowProposedApi = true;
|
||||||
|
|
||||||
// Open terminal
|
// Open terminal
|
||||||
terminal.open(terminalRef.current);
|
terminal.open(terminalElement);
|
||||||
|
|
||||||
// Fit after a small delay to ensure proper sizing
|
// Fit after a small delay to ensure proper sizing
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
|
// Force fit multiple times for mobile to ensure proper sizing
|
||||||
|
if (isMobile) {
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
setTimeout(() => {
|
||||||
|
fitAddon.fit();
|
||||||
|
}, 200);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
}, 100);
|
}, 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
|
// Store references
|
||||||
xtermRef.current = terminal;
|
xtermRef.current = terminal;
|
||||||
fitAddonRef.current = fitAddon;
|
fitAddonRef.current = fitAddon;
|
||||||
@@ -93,25 +224,16 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
// Handle terminal input
|
// Handle terminal input
|
||||||
terminal.onData((data) => {
|
terminal.onData((data) => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
wsRef.current.send(JSON.stringify({
|
const message = {
|
||||||
action: 'input',
|
action: 'input',
|
||||||
executionId,
|
executionId,
|
||||||
input: data
|
input: data
|
||||||
}));
|
};
|
||||||
|
wsRef.current.send(JSON.stringify(message));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle terminal resize
|
|
||||||
const handleResize = () => {
|
|
||||||
if (fitAddonRef.current) {
|
|
||||||
fitAddonRef.current.fit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('resize', handleResize);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('resize', handleResize);
|
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -123,13 +245,16 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
|
if (terminalElement && (terminalElement as any).resizeHandler) {
|
||||||
|
window.removeEventListener('resize', (terminalElement as any).resizeHandler as (this: Window, ev: UIEvent) => any);
|
||||||
|
}
|
||||||
if (xtermRef.current) {
|
if (xtermRef.current) {
|
||||||
xtermRef.current.dispose();
|
xtermRef.current.dispose();
|
||||||
xtermRef.current = null;
|
xtermRef.current = null;
|
||||||
fitAddonRef.current = null;
|
fitAddonRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [executionId, isClient]);
|
}, [executionId, isClient, inWhiptailSession, isMobile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Prevent multiple connections in React Strict Mode
|
// Prevent multiple connections in React Strict Mode
|
||||||
@@ -175,6 +300,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const message = JSON.parse(event.data as string) as TerminalMessage;
|
const message = JSON.parse(event.data as string) as TerminalMessage;
|
||||||
|
console.log('WebSocket message received:', message);
|
||||||
handleMessage(message);
|
handleMessage(message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error parsing WebSocket message:', error);
|
console.error('Error parsing WebSocket message:', error);
|
||||||
@@ -206,45 +332,7 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
wsRef.current.close();
|
wsRef.current.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [scriptPath, executionId, mode, server, isUpdate, containerId]);
|
}, [scriptPath, executionId, mode, server, isUpdate, containerId, handleMessage, isMobile]);
|
||||||
|
|
||||||
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}[START] ${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}[ERROR] ${message.data}`);
|
|
||||||
} else {
|
|
||||||
// This is a real error, show it with error prefix
|
|
||||||
xtermRef.current.writeln(`${prefix}[ERROR] ${message.data}`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'end':
|
|
||||||
xtermRef.current.writeln(`${prefix}[SUCCESS] ${message.data}`);
|
|
||||||
setIsRunning(false);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startScript = () => {
|
const startScript = () => {
|
||||||
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
|
||||||
@@ -275,6 +363,30 @@ 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
|
// Don't render on server side
|
||||||
if (!isClient) {
|
if (!isClient) {
|
||||||
return (
|
return (
|
||||||
@@ -301,21 +413,21 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-lg border border-border overflow-hidden">
|
<div className="bg-card rounded-lg border border-border overflow-hidden">
|
||||||
{/* Terminal Header */}
|
{/* Terminal Header */}
|
||||||
<div className="bg-muted px-4 py-2 flex items-center justify-between border-b border-border">
|
<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">
|
<div className="flex items-center space-x-2 min-w-0 flex-1">
|
||||||
<div className="flex space-x-1">
|
<div className="flex space-x-1 flex-shrink-0">
|
||||||
<div className="w-3 h-3 bg-red-500 rounded-full"></div>
|
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-red-500 rounded-full"></div>
|
||||||
<div className="w-3 h-3 bg-yellow-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-3 h-3 bg-green-500 rounded-full"></div>
|
<div className="w-2 h-2 sm:w-3 sm:h-3 bg-green-500 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-foreground font-mono text-sm ml-2">
|
<span className="text-foreground font-mono text-xs sm:text-sm ml-1 sm:ml-2 truncate">
|
||||||
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
|
{scriptName} {mode === 'ssh' && server && `(SSH: ${server.name})`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-1 sm:space-x-2 flex-shrink-0">
|
||||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs hidden sm:inline">
|
||||||
{isConnected ? 'Connected' : 'Disconnected'}
|
{isConnected ? 'Connected' : 'Disconnected'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -324,22 +436,164 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
{/* Terminal Output */}
|
{/* Terminal Output */}
|
||||||
<div
|
<div
|
||||||
ref={terminalRef}
|
ref={terminalRef}
|
||||||
className="h-[32rem] w-full max-w-4xl mx-auto"
|
className={`h-[16rem] sm:h-[24rem] lg:h-[32rem] w-full max-w-4xl mx-auto ${isMobile ? 'mobile-terminal' : ''}`}
|
||||||
style={{ minHeight: '512px' }}
|
style={{
|
||||||
|
minHeight: '256px'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 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 */}
|
{/* Terminal Controls */}
|
||||||
<div className="bg-muted px-4 py-2 flex items-center justify-between border-t border-border">
|
<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 space-x-2">
|
<div className="flex flex-wrap gap-1 sm:gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={startScript}
|
onClick={startScript}
|
||||||
disabled={!isConnected || isRunning}
|
disabled={!isConnected || isRunning}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={isConnected && !isRunning ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}
|
className={`text-xs sm:text-sm ${isConnected && !isRunning ? 'bg-green-600 hover:bg-green-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
||||||
>
|
>
|
||||||
<Play className="h-4 w-4 mr-1" />
|
<Play className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
Start
|
<span className="hidden sm:inline">Start</span>
|
||||||
|
<span className="sm:hidden">▶</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
@@ -347,20 +601,22 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
disabled={!isRunning}
|
disabled={!isRunning}
|
||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
className={isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}
|
className={`text-xs sm:text-sm ${isRunning ? 'bg-red-600 hover:bg-red-700' : 'bg-muted text-muted-foreground cursor-not-allowed'}`}
|
||||||
>
|
>
|
||||||
<Square className="h-4 w-4 mr-1" />
|
<Square className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
Stop
|
<span className="hidden sm:inline">Stop</span>
|
||||||
|
<span className="sm:hidden">⏹</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={clearOutput}
|
onClick={clearOutput}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
className="text-xs sm:text-sm bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 mr-1" />
|
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
Clear
|
<span className="hidden sm:inline">Clear</span>
|
||||||
|
<span className="sm:hidden">🗑</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -368,9 +624,9 @@ export function Terminal({ scriptPath, onClose, mode = 'local', server, isUpdate
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="bg-gray-600 text-white hover:bg-gray-700"
|
className="text-xs sm:text-sm bg-gray-600 text-white hover:bg-gray-700 w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 mr-1" />
|
<X className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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"
|
className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center p-4 z-50"
|
||||||
onClick={handleBackdropClick}
|
onClick={handleBackdropClick}
|
||||||
>
|
>
|
||||||
<div className="bg-card rounded-lg shadow-xl max-w-6xl w-full max-h-[90vh] flex flex-col border border-border">
|
<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">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
|
|||||||
@@ -3,40 +3,68 @@
|
|||||||
import { api } from "~/trpc/react";
|
import { api } from "~/trpc/react";
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import { Button } from "./ui/button";
|
import { Button } from "./ui/button";
|
||||||
import { ExternalLink, Download, RefreshCw, Loader2, Check } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
// Loading overlay component
|
import { ExternalLink, Download, RefreshCw, Loader2 } from "lucide-react";
|
||||||
function LoadingOverlay({ isNetworkError = false }: { isNetworkError?: boolean }) {
|
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]);
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
<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="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="flex flex-col items-center space-y-4">
|
<div className="flex flex-col items-center space-y-4">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Loader2 className="h-12 w-12 animate-spin text-blue-600 dark:text-blue-400" />
|
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
||||||
<div className="absolute inset-0 rounded-full border-2 border-blue-200 dark:border-blue-800 animate-pulse"></div>
|
<div className="absolute inset-0 rounded-full border-2 border-primary/20 animate-pulse"></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
<h3 className="text-lg font-semibold text-card-foreground mb-2">
|
||||||
{isNetworkError ? 'Server Restarting' : 'Updating Application'}
|
{isNetworkError ? 'Server Restarting' : 'Updating Application'}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-muted-foreground">
|
||||||
{isNetworkError
|
{isNetworkError
|
||||||
? 'The server is restarting after the update...'
|
? 'The server is restarting after the update...'
|
||||||
: 'Please stand by while we update your application...'
|
: 'Please stand by while we update your application...'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2">
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
{isNetworkError
|
{isNetworkError
|
||||||
? 'This may take a few moments. The page will reload automatically. You may see a blank page for up to a minute!.'
|
? 'This may take a few moments. The page will reload automatically.'
|
||||||
: 'The server will restart automatically when complete.'
|
: 'The server will restart automatically when complete.'
|
||||||
}
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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="flex space-x-1">
|
||||||
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"></div>
|
<div className="w-2 h-2 bg-primary 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-primary 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 className="w-2 h-2 bg-primary rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -48,79 +76,126 @@ export function VersionDisplay() {
|
|||||||
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
|
const { data: versionStatus, isLoading, error } = api.version.getVersionStatus.useQuery();
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
|
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
const [updateStartTime, setUpdateStartTime] = useState<number | null>(null);
|
|
||||||
const [isNetworkError, setIsNetworkError] = useState(false);
|
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 executeUpdate = api.version.executeUpdate.useMutation({
|
const executeUpdate = api.version.executeUpdate.useMutation({
|
||||||
onSuccess: (result: any) => {
|
onSuccess: (result) => {
|
||||||
const now = Date.now();
|
|
||||||
const elapsed = updateStartTime ? now - updateStartTime : 0;
|
|
||||||
|
|
||||||
|
|
||||||
setUpdateResult({ success: result.success, message: result.message });
|
setUpdateResult({ success: result.success, message: result.message });
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// The script now runs independently, so we show a longer overlay
|
// Start subscribing to update logs
|
||||||
// and wait for the server to restart
|
setShouldSubscribe(true);
|
||||||
setIsNetworkError(true);
|
setUpdateLogs(['Update started...']);
|
||||||
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 {
|
} else {
|
||||||
// For errors, show for at least 1 second
|
setIsUpdating(false);
|
||||||
const remainingTime = Math.max(0, 1000 - elapsed);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsUpdating(false);
|
|
||||||
}, remainingTime);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const now = Date.now();
|
setUpdateResult({ success: false, message: error.message });
|
||||||
const elapsed = updateStartTime ? now - updateStartTime : 0;
|
setIsUpdating(false);
|
||||||
|
|
||||||
// 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);
|
|
||||||
setUpdateResult({ success: true, message: 'Update in progress... Server is restarting.' });
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
if (updateLogsData.isComplete) {
|
||||||
|
setUpdateLogs(prev => [...prev, 'Update complete! Server restarting...']);
|
||||||
|
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...']);
|
||||||
|
|
||||||
|
// Start trying to reconnect
|
||||||
|
startReconnectAttempts();
|
||||||
|
}
|
||||||
|
}, 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 = () => {
|
const handleUpdate = () => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
setUpdateResult(null);
|
setUpdateResult(null);
|
||||||
setIsNetworkError(false);
|
setIsNetworkError(false);
|
||||||
|
setUpdateLogs([]);
|
||||||
|
setShouldSubscribe(false);
|
||||||
setUpdateStartTime(Date.now());
|
setUpdateStartTime(Date.now());
|
||||||
|
lastLogTimeRef.current = Date.now();
|
||||||
executeUpdate.mutate();
|
executeUpdate.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,23 +227,23 @@ export function VersionDisplay() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Loading overlay */}
|
{/* Loading overlay */}
|
||||||
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} />}
|
{isUpdating && <LoadingOverlay isNetworkError={isNetworkError} logs={updateLogs} />}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-2">
|
||||||
<Badge variant={isUpToDate ? "default" : "secondary"}>
|
<Badge variant={isUpToDate ? "default" : "secondary"} className="text-xs">
|
||||||
v{currentVersion}
|
v{currentVersion}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{updateAvailable && releaseInfo && (
|
{updateAvailable && releaseInfo && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex flex-col sm:flex-row items-center gap-2 sm:gap-3">
|
||||||
<div className="relative group">
|
<div className="relative group">
|
||||||
<Badge variant="destructive" className="animate-pulse cursor-help">
|
<Badge variant="destructive" className="animate-pulse cursor-help text-xs">
|
||||||
Update Available
|
Update Available
|
||||||
</Badge>
|
</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">
|
<div className="absolute top-full left-1/2 transform -translate-x-1/2 mt-2 px-3 py-2 bg-gray-900 dark:bg-gray-700 text-white text-xs rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-10 hidden sm:block">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="font-semibold mb-1">How to update:</div>
|
<div className="font-semibold mb-1">How to update:</div>
|
||||||
<div>Click the button to update</div>
|
<div>Click the button to update, when installed via the helper script</div>
|
||||||
<div>or update manually:</div>
|
<div>or update manually:</div>
|
||||||
<div>cd $PVESCRIPTLOCAL_DIR</div>
|
<div>cd $PVESCRIPTLOCAL_DIR</div>
|
||||||
<div>git pull</div>
|
<div>git pull</div>
|
||||||
@@ -180,41 +255,45 @@ export function VersionDisplay() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<div className="flex items-center gap-2">
|
||||||
onClick={handleUpdate}
|
<Button
|
||||||
disabled={isUpdating}
|
onClick={handleUpdate}
|
||||||
size="sm"
|
disabled={isUpdating}
|
||||||
variant="destructive"
|
size="sm"
|
||||||
className="text-xs h-6 px-2"
|
variant="destructive"
|
||||||
>
|
className="text-xs h-6 px-2"
|
||||||
{isUpdating ? (
|
>
|
||||||
<>
|
{isUpdating ? (
|
||||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
<>
|
||||||
Updating...
|
<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" />
|
) : (
|
||||||
Update Now
|
<>
|
||||||
</>
|
<Download className="h-3 w-3 mr-1" />
|
||||||
)}
|
<span className="hidden sm:inline">Update Now</span>
|
||||||
</Button>
|
<span className="sm:hidden">Update</span>
|
||||||
|
</>
|
||||||
<a
|
)}
|
||||||
href={releaseInfo.htmlUrl}
|
</Button>
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
<a
|
||||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
href={releaseInfo.htmlUrl}
|
||||||
title="View latest release"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
<ExternalLink className="h-3 w-3" />
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
</a>
|
title="View latest release"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
{updateResult && (
|
{updateResult && (
|
||||||
<div className={`text-xs px-2 py-1 rounded ${
|
<div className={`text-xs px-2 py-1 rounded text-center ${
|
||||||
updateResult.success
|
updateResult.success
|
||||||
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'
|
? 'bg-chart-2/20 text-chart-2 border border-chart-2/30'
|
||||||
: 'bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'
|
: 'bg-destructive/20 text-destructive border border-destructive/30'
|
||||||
}`}>
|
}`}>
|
||||||
{updateResult.message}
|
{updateResult.message}
|
||||||
</div>
|
</div>
|
||||||
@@ -223,9 +302,8 @@ export function VersionDisplay() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{isUpToDate && (
|
{isUpToDate && (
|
||||||
<span className="text-xs text-green-600 dark:text-green-400 flex items-center gap-1">
|
<span className="text-xs text-chart-2">
|
||||||
<Check className="h-3 w-3" />
|
✓ Up to date
|
||||||
Up to date
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
23
src/app/_components/ui/input.tsx
Normal file
23
src/app/_components/ui/input.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "../../../lib/utils"
|
||||||
|
|
||||||
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = "Input"
|
||||||
|
|
||||||
|
export { Input }
|
||||||
41
src/app/_components/ui/toggle.tsx
Normal file
41
src/app/_components/ui/toggle.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cn } from "../../../lib/utils"
|
||||||
|
|
||||||
|
export interface ToggleProps
|
||||||
|
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
|
||||||
|
checked?: boolean;
|
||||||
|
onCheckedChange?: (checked: boolean) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Toggle = React.forwardRef<HTMLInputElement, ToggleProps>(
|
||||||
|
({ className, checked, onCheckedChange, label, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<label className="relative inline-flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
<div className={cn(
|
||||||
|
"w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-transform after:duration-300 after:ease-in-out peer-checked:bg-blue-600 transition-colors duration-300 ease-in-out",
|
||||||
|
checked && "bg-blue-600 after:translate-x-full",
|
||||||
|
className
|
||||||
|
)} />
|
||||||
|
</label>
|
||||||
|
{label && (
|
||||||
|
<span className="text-sm font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Toggle.displayName = "Toggle"
|
||||||
|
|
||||||
|
export { Toggle }
|
||||||
141
src/app/api/settings/filters/route.ts
Normal file
141
src/app/api/settings/filters/route.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { filters } = await request.json();
|
||||||
|
|
||||||
|
if (!filters || typeof filters !== 'object') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Filters object is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate filter structure
|
||||||
|
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!(field in filters)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Missing required field: ${field}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serialize filters to JSON string
|
||||||
|
const filtersJson = JSON.stringify(filters);
|
||||||
|
|
||||||
|
// Check if FILTERS already exists
|
||||||
|
const filtersRegex = /^FILTERS=.*$/m;
|
||||||
|
const filtersMatch = filtersRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (filtersMatch) {
|
||||||
|
// Replace existing FILTERS
|
||||||
|
envContent = envContent.replace(filtersRegex, `FILTERS=${filtersJson}`);
|
||||||
|
} else {
|
||||||
|
// Add new FILTERS
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `FILTERS=${filtersJson}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Filters saved successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving filters:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to save filters' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return NextResponse.json({ filters: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read .env file and extract FILTERS
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
const filtersRegex = /^FILTERS=(.*)$/m;
|
||||||
|
const filtersMatch = filtersRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (!filtersMatch) {
|
||||||
|
return NextResponse.json({ filters: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filters = JSON.parse(filtersMatch[1]!);
|
||||||
|
|
||||||
|
// Validate the parsed filters
|
||||||
|
const requiredFields = ['searchQuery', 'showUpdatable', 'selectedTypes', 'sortBy', 'sortOrder'];
|
||||||
|
const isValid = requiredFields.every(field => field in filters);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return NextResponse.json({ filters: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ filters });
|
||||||
|
} catch (parseError) {
|
||||||
|
console.error('Error parsing saved filters:', parseError);
|
||||||
|
return NextResponse.json({ filters: null });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading filters:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to read filters' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE() {
|
||||||
|
try {
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return NextResponse.json({ success: true, message: 'No filters to clear' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
|
||||||
|
// Remove FILTERS line
|
||||||
|
const filtersRegex = /^FILTERS=.*$/m;
|
||||||
|
const filtersMatch = filtersRegex.exec(envContent);
|
||||||
|
if (filtersMatch) {
|
||||||
|
envContent = envContent.replace(filtersRegex, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up extra newlines
|
||||||
|
envContent = envContent.replace(/\n\n+/g, '\n');
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Filters cleared successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing filters:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to clear filters' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/settings/github-token/route.ts
Normal file
75
src/app/api/settings/github-token/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { token } = await request.json();
|
||||||
|
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Token is required and must be a string' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if GITHUB_TOKEN already exists
|
||||||
|
const githubTokenRegex = /^GITHUB_TOKEN=.*$/m;
|
||||||
|
const githubTokenMatch = githubTokenRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (githubTokenMatch) {
|
||||||
|
// Replace existing GITHUB_TOKEN
|
||||||
|
envContent = envContent.replace(githubTokenRegex, `GITHUB_TOKEN=${token}`);
|
||||||
|
} else {
|
||||||
|
// Add new GITHUB_TOKEN
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `GITHUB_TOKEN=${token}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'GitHub token saved successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving GitHub token:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to save GitHub token' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return NextResponse.json({ token: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read .env file and extract GITHUB_TOKEN
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
const githubTokenRegex = /^GITHUB_TOKEN=(.*)$/m;
|
||||||
|
const githubTokenMatch = githubTokenRegex.exec(envContent);
|
||||||
|
|
||||||
|
const token = githubTokenMatch ? githubTokenMatch[1] : null;
|
||||||
|
|
||||||
|
return NextResponse.json({ token });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading GitHub token:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to read GitHub token' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/app/api/settings/save-filter/route.ts
Normal file
75
src/app/api/settings/save-filter/route.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { NextRequest } from 'next/server';
|
||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { enabled } = await request.json();
|
||||||
|
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Enabled value must be a boolean' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
// Read existing .env file
|
||||||
|
let envContent = '';
|
||||||
|
if (fs.existsSync(envPath)) {
|
||||||
|
envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SAVE_FILTER already exists
|
||||||
|
const saveFilterRegex = /^SAVE_FILTER=.*$/m;
|
||||||
|
const saveFilterMatch = saveFilterRegex.exec(envContent);
|
||||||
|
|
||||||
|
if (saveFilterMatch) {
|
||||||
|
// Replace existing SAVE_FILTER
|
||||||
|
envContent = envContent.replace(saveFilterRegex, `SAVE_FILTER=${enabled}`);
|
||||||
|
} else {
|
||||||
|
// Add new SAVE_FILTER
|
||||||
|
envContent += (envContent.endsWith('\n') ? '' : '\n') + `SAVE_FILTER=${enabled}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back to .env file
|
||||||
|
fs.writeFileSync(envPath, envContent);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: 'Save filter setting saved successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving save filter setting:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to save save filter setting' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
// Path to the .env file
|
||||||
|
const envPath = path.join(process.cwd(), '.env');
|
||||||
|
|
||||||
|
if (!fs.existsSync(envPath)) {
|
||||||
|
return NextResponse.json({ enabled: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read .env file and extract SAVE_FILTER
|
||||||
|
const envContent = fs.readFileSync(envPath, 'utf8');
|
||||||
|
const saveFilterRegex = /^SAVE_FILTER=(.*)$/m;
|
||||||
|
const saveFilterMatch = saveFilterRegex.exec(envContent);
|
||||||
|
|
||||||
|
const enabled = saveFilterMatch ? saveFilterMatch[1] === 'true' : false;
|
||||||
|
|
||||||
|
return NextResponse.json({ enabled });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading save filter setting:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to read save filter setting' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ import { TRPCReactProvider } from "~/trpc/react";
|
|||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "PVE Scripts local",
|
title: "PVE Scripts local",
|
||||||
description: "",
|
description: "Manage and execute Proxmox helper scripts locally with live output streaming",
|
||||||
|
viewport: "width=device-width, initial-scale=1, maximum-scale=1",
|
||||||
icons: [
|
icons: [
|
||||||
{ rel: "icon", url: "/favicon.png", type: "image/png" },
|
{ rel: "icon", url: "/favicon.png", type: "image/png" },
|
||||||
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
|
{ rel: "icon", url: "/favicon.ico", sizes: "any" },
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DownloadedScriptsTab } from './_components/DownloadedScriptsTab';
|
|||||||
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
import { InstalledScriptsTab } from './_components/InstalledScriptsTab';
|
||||||
import { ResyncButton } from './_components/ResyncButton';
|
import { ResyncButton } from './_components/ResyncButton';
|
||||||
import { Terminal } from './_components/Terminal';
|
import { Terminal } from './_components/Terminal';
|
||||||
|
import { ServerSettingsButton } from './_components/ServerSettingsButton';
|
||||||
import { SettingsButton } from './_components/SettingsButton';
|
import { SettingsButton } from './_components/SettingsButton';
|
||||||
import { VersionDisplay } from './_components/VersionDisplay';
|
import { VersionDisplay } from './_components/VersionDisplay';
|
||||||
import { Button } from './_components/ui/button';
|
import { Button } from './_components/ui/button';
|
||||||
@@ -26,72 +27,72 @@ export default function Home() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-background">
|
<main className="min-h-screen bg-background">
|
||||||
<div className="container mx-auto px-4 py-8">
|
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="text-center mb-8">
|
<div className="text-center mb-6 sm:mb-8">
|
||||||
<h1 className="text-4xl font-bold text-foreground mb-2 flex items-center justify-center gap-3">
|
<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-9 w-9" />
|
<Rocket className="h-6 w-6 sm:h-8 w-8 lg:h-9 lg:w-9" />
|
||||||
PVE Scripts Management
|
<span className="break-words">PVE Scripts Management</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mb-4">
|
<p className="text-sm sm:text-base text-muted-foreground mb-4 px-2">
|
||||||
Manage and execute Proxmox helper scripts locally with live output streaming
|
Manage and execute Proxmox helper scripts locally with live output streaming
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-center">
|
<div className="flex justify-center px-2">
|
||||||
<VersionDisplay />
|
<VersionDisplay />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="mb-8">
|
<div className="mb-6 sm: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:flex-wrap sm:items-center gap-4 p-4 sm:p-6 bg-card rounded-lg shadow-sm border border-border">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
<ServerSettingsButton />
|
||||||
<SettingsButton />
|
<SettingsButton />
|
||||||
</div>
|
<ResyncButton />
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
|
|
||||||
<ResyncButton />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
<div className="mb-8">
|
<div className="mb-6 sm:mb-8">
|
||||||
<div className="border-b border-border">
|
<div className="border-b border-border">
|
||||||
<nav className="-mb-px flex space-x-8">
|
<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">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="null"
|
size="null"
|
||||||
onClick={() => setActiveTab('scripts')}
|
onClick={() => setActiveTab('scripts')}
|
||||||
className={`px-3 py-1 text-sm flex items-center gap-2 ${
|
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||||
activeTab === 'scripts'
|
activeTab === 'scripts'
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'hover:bg-accent hover:text-accent-foreground'
|
: 'hover:bg-accent hover:text-accent-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
<Package className="h-4 w-4" />
|
<Package className="h-4 w-4" />
|
||||||
Available Scripts
|
<span className="hidden sm:inline">Available Scripts</span>
|
||||||
|
<span className="sm:hidden">Available</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="null"
|
size="null"
|
||||||
onClick={() => setActiveTab('downloaded')}
|
onClick={() => setActiveTab('downloaded')}
|
||||||
className={`px-3 py-1 text-sm flex items-center gap-2 ${
|
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||||
activeTab === 'downloaded'
|
activeTab === 'downloaded'
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'hover:bg-accent hover:text-accent-foreground'
|
: 'hover:bg-accent hover:text-accent-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
<HardDrive className="h-4 w-4" />
|
<HardDrive className="h-4 w-4" />
|
||||||
Downloaded Scripts
|
<span className="hidden sm:inline">Downloaded Scripts</span>
|
||||||
|
<span className="sm:hidden">Downloaded</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="null"
|
size="null"
|
||||||
onClick={() => setActiveTab('installed')}
|
onClick={() => setActiveTab('installed')}
|
||||||
className={`px-3 py-1 text-sm flex items-center gap-2 ${
|
className={`px-3 py-2 text-sm flex items-center justify-center sm:justify-start gap-2 w-full sm:w-auto ${
|
||||||
activeTab === 'installed'
|
activeTab === 'installed'
|
||||||
? 'bg-accent text-accent-foreground'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'hover:bg-accent hover:text-accent-foreground'
|
: 'hover:bg-accent hover:text-accent-foreground'
|
||||||
}`}>
|
}`}>
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
Installed Scripts
|
<span className="hidden sm:inline">Installed Scripts</span>
|
||||||
|
<span className="sm:hidden">Installed</span>
|
||||||
</Button>
|
</Button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export const env = createEnv({
|
|||||||
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
|
ALLOWED_SCRIPT_PATHS: z.string().default("scripts/"),
|
||||||
// WebSocket Configuration
|
// WebSocket Configuration
|
||||||
WEBSOCKET_PORT: z.string().default("3001"),
|
WEBSOCKET_PORT: z.string().default("3001"),
|
||||||
|
// GitHub Configuration
|
||||||
|
GITHUB_TOKEN: z.string().optional(),
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,6 +54,8 @@ export const env = createEnv({
|
|||||||
ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS,
|
ALLOWED_SCRIPT_PATHS: process.env.ALLOWED_SCRIPT_PATHS,
|
||||||
// WebSocket Configuration
|
// WebSocket Configuration
|
||||||
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
|
WEBSOCKET_PORT: process.env.WEBSOCKET_PORT,
|
||||||
|
// GitHub Configuration
|
||||||
|
GITHUB_TOKEN: process.env.GITHUB_TOKEN,
|
||||||
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
// NEXT_PUBLIC_CLIENTVAR: process.env.NEXT_PUBLIC_CLIENTVAR,
|
||||||
},
|
},
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -203,5 +203,349 @@ export const installedScriptsRouter = createTRPCRouter({
|
|||||||
stats: null
|
stats: null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Auto-detect LXC containers with community-script tag
|
||||||
|
autoDetectLXCContainers: publicProcedure
|
||||||
|
.input(z.object({ serverId: z.number() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
console.log('=== AUTO-DETECT API ENDPOINT CALLED ===');
|
||||||
|
console.log('Input received:', input);
|
||||||
|
console.log('Timestamp:', new Date().toISOString());
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Starting auto-detect LXC containers for server ID:', input.serverId);
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const server = db.getServerById(input.serverId);
|
||||||
|
|
||||||
|
if (!server) {
|
||||||
|
console.error('Server not found for ID:', input.serverId);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: 'Server not found',
|
||||||
|
detectedContainers: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Found server:', (server as any).name, 'at', (server as any).ip);
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
// Test SSH connection first
|
||||||
|
console.log('Testing SSH connection...');
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||||
|
console.log('SSH connection test result:', connectionTest);
|
||||||
|
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `SSH connection failed: ${(connectionTest as any).error ?? 'Unknown error'}`,
|
||||||
|
detectedContainers: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('SSH connection successful, scanning for LXC containers...');
|
||||||
|
|
||||||
|
// Use the working approach - manual loop through all config files
|
||||||
|
const command = `for file in /etc/pve/lxc/*.conf; do if [ -f "$file" ]; then if grep -q "community-script" "$file"; then echo "$file"; fi; fi; done`;
|
||||||
|
let detectedContainers: any[] = [];
|
||||||
|
|
||||||
|
console.log('Executing manual loop command...');
|
||||||
|
console.log('Command:', command);
|
||||||
|
|
||||||
|
let commandOutput = '';
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
command,
|
||||||
|
(data: string) => {
|
||||||
|
console.log('Command output chunk:', data);
|
||||||
|
commandOutput += data;
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
console.error('Command error:', error);
|
||||||
|
},
|
||||||
|
(exitCode: number) => {
|
||||||
|
console.log('Command exit code:', exitCode);
|
||||||
|
console.log('Full command output:', commandOutput);
|
||||||
|
|
||||||
|
// Parse the complete output to get config file paths that contain community-script tag
|
||||||
|
const configFiles = commandOutput.split('\n')
|
||||||
|
.filter((line: string) => line.trim())
|
||||||
|
.map((line: string) => line.trim())
|
||||||
|
.filter((line: string) => line.endsWith('.conf'));
|
||||||
|
|
||||||
|
console.log('Found config files with community-script tag:', configFiles.length);
|
||||||
|
console.log('Config files:', configFiles);
|
||||||
|
|
||||||
|
// Process each config file to extract hostname
|
||||||
|
const processPromises = configFiles.map(async (configPath: string) => {
|
||||||
|
try {
|
||||||
|
const containerId = configPath.split('/').pop()?.replace('.conf', '');
|
||||||
|
if (!containerId) return null;
|
||||||
|
|
||||||
|
console.log('Processing container:', containerId, 'from', configPath);
|
||||||
|
|
||||||
|
// Read the config file content
|
||||||
|
const readCommand = `cat "${configPath}" 2>/dev/null`;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||||
|
return new Promise<any>((readResolve) => {
|
||||||
|
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
readCommand,
|
||||||
|
(configData: string) => {
|
||||||
|
console.log('Config data for', containerId, ':', configData.substring(0, 300) + '...');
|
||||||
|
|
||||||
|
// Parse config file for hostname
|
||||||
|
const lines = configData.split('\n');
|
||||||
|
let hostname = '';
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const trimmedLine = line.trim();
|
||||||
|
if (trimmedLine.startsWith('hostname:')) {
|
||||||
|
hostname = trimmedLine.substring(9).trim();
|
||||||
|
console.log('Found hostname for', containerId, ':', hostname);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hostname) {
|
||||||
|
const container = {
|
||||||
|
containerId,
|
||||||
|
hostname,
|
||||||
|
configPath,
|
||||||
|
serverId: (server as any).id,
|
||||||
|
serverName: (server as any).name
|
||||||
|
};
|
||||||
|
console.log('Adding container to detected list:', container);
|
||||||
|
readResolve(container);
|
||||||
|
} else {
|
||||||
|
console.log('No hostname found for', containerId);
|
||||||
|
readResolve(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
(readError: string) => {
|
||||||
|
console.error(`Error reading config file ${configPath}:`, readError);
|
||||||
|
readResolve(null);
|
||||||
|
},
|
||||||
|
(_exitCode: number) => {
|
||||||
|
readResolve(null);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error processing config file ${configPath}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for all config files to be processed
|
||||||
|
void Promise.all(processPromises).then((results) => {
|
||||||
|
detectedContainers = results.filter(result => result !== null);
|
||||||
|
console.log('Final detected containers:', detectedContainers.length);
|
||||||
|
resolve();
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error processing config files:', error);
|
||||||
|
reject(new Error(`Error processing config files: ${error}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Detected containers:', detectedContainers.length);
|
||||||
|
|
||||||
|
// Get existing scripts to check for duplicates
|
||||||
|
const existingScripts = db.getAllInstalledScripts();
|
||||||
|
console.log('Existing scripts in database:', existingScripts.length);
|
||||||
|
|
||||||
|
// Create installed script records for detected containers (skip duplicates)
|
||||||
|
const createdScripts = [];
|
||||||
|
const skippedScripts = [];
|
||||||
|
|
||||||
|
for (const container of detectedContainers) {
|
||||||
|
try {
|
||||||
|
// Check if a script with this container_id and server_id already exists
|
||||||
|
const duplicate = existingScripts.find((script: any) =>
|
||||||
|
script.container_id === container.containerId &&
|
||||||
|
script.server_id === container.serverId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (duplicate) {
|
||||||
|
console.log(`Skipping duplicate: ${container.hostname} (${container.containerId}) already exists`);
|
||||||
|
skippedScripts.push({
|
||||||
|
containerId: container.containerId,
|
||||||
|
hostname: container.hostname,
|
||||||
|
serverName: container.serverName
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Creating script record for:', container.hostname, container.containerId);
|
||||||
|
const result = db.createInstalledScript({
|
||||||
|
script_name: container.hostname,
|
||||||
|
script_path: `detected/${container.hostname}`,
|
||||||
|
container_id: container.containerId,
|
||||||
|
server_id: container.serverId,
|
||||||
|
execution_mode: 'ssh',
|
||||||
|
status: 'success',
|
||||||
|
output_log: `Auto-detected from LXC config: ${container.configPath}`
|
||||||
|
});
|
||||||
|
|
||||||
|
createdScripts.push({
|
||||||
|
id: result.lastInsertRowid,
|
||||||
|
containerId: container.containerId,
|
||||||
|
hostname: container.hostname,
|
||||||
|
serverName: container.serverName
|
||||||
|
});
|
||||||
|
console.log('Created script record with ID:', result.lastInsertRowid);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error creating script record for ${container.hostname}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = skippedScripts.length > 0
|
||||||
|
? `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts, skipped ${skippedScripts.length} duplicates.`
|
||||||
|
: `Auto-detection completed. Found ${detectedContainers.length} containers with community-script tag. Added ${createdScripts.length} new scripts.`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: message,
|
||||||
|
detectedContainers: createdScripts,
|
||||||
|
skippedContainers: skippedScripts
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in autoDetectLXCContainers:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to auto-detect LXC containers',
|
||||||
|
detectedContainers: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Cleanup orphaned scripts (check if LXC containers still exist on servers)
|
||||||
|
cleanupOrphanedScripts: publicProcedure
|
||||||
|
.mutation(async () => {
|
||||||
|
try {
|
||||||
|
console.log('=== CLEANUP ORPHANED SCRIPTS API ENDPOINT CALLED ===');
|
||||||
|
console.log('Timestamp:', new Date().toISOString());
|
||||||
|
|
||||||
|
const db = getDatabase();
|
||||||
|
const allScripts = db.getAllInstalledScripts();
|
||||||
|
const allServers = db.getAllServers();
|
||||||
|
|
||||||
|
console.log('Found scripts:', allScripts.length);
|
||||||
|
console.log('Found servers:', allServers.length);
|
||||||
|
|
||||||
|
if (allScripts.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: 'No scripts to check',
|
||||||
|
deletedCount: 0,
|
||||||
|
deletedScripts: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import SSH services
|
||||||
|
const { default: SSHService } = await import('~/server/ssh-service');
|
||||||
|
const { default: SSHExecutionService } = await import('~/server/ssh-execution-service');
|
||||||
|
const sshService = new SSHService();
|
||||||
|
const sshExecutionService = new SSHExecutionService();
|
||||||
|
|
||||||
|
const deletedScripts: string[] = [];
|
||||||
|
const scriptsToCheck = allScripts.filter((script: any) =>
|
||||||
|
script.execution_mode === 'ssh' &&
|
||||||
|
script.server_id &&
|
||||||
|
script.container_id
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Scripts to check for cleanup:', scriptsToCheck.length);
|
||||||
|
|
||||||
|
for (const script of scriptsToCheck) {
|
||||||
|
try {
|
||||||
|
const scriptData = script as any;
|
||||||
|
const server = allServers.find((s: any) => s.id === scriptData.server_id);
|
||||||
|
if (!server) {
|
||||||
|
console.log(`Server not found for script ${scriptData.script_name}, marking for deletion`);
|
||||||
|
db.deleteInstalledScript(Number(scriptData.id));
|
||||||
|
deletedScripts.push(String(scriptData.script_name));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Checking script ${scriptData.script_name} on server ${(server as any).name}`);
|
||||||
|
|
||||||
|
// Test SSH connection
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
const connectionTest = await sshService.testSSHConnection(server as any);
|
||||||
|
if (!(connectionTest as any).success) {
|
||||||
|
console.log(`SSH connection failed for server ${(server as any).name}, skipping script ${scriptData.script_name}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the container config file still exists
|
||||||
|
const checkCommand = `test -f "/etc/pve/lxc/${scriptData.container_id}.conf" && echo "exists" || echo "not_found"`;
|
||||||
|
|
||||||
|
const containerExists = await new Promise<boolean>((resolve) => {
|
||||||
|
|
||||||
|
void sshExecutionService.executeCommand(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||||
|
server as any,
|
||||||
|
checkCommand,
|
||||||
|
(data: string) => {
|
||||||
|
console.log(`Container check result for ${scriptData.script_name}:`, data.trim());
|
||||||
|
resolve(data.trim() === 'exists');
|
||||||
|
},
|
||||||
|
(error: string) => {
|
||||||
|
console.error(`Error checking container ${scriptData.script_name}:`, error);
|
||||||
|
resolve(false);
|
||||||
|
},
|
||||||
|
(_exitCode: number) => {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!containerExists) {
|
||||||
|
console.log(`Container ${scriptData.container_id} not found on server ${(server as any).name}, deleting script ${scriptData.script_name}`);
|
||||||
|
db.deleteInstalledScript(Number(scriptData.id));
|
||||||
|
deletedScripts.push(String(scriptData.script_name));
|
||||||
|
} else {
|
||||||
|
console.log(`Container ${scriptData.container_id} still exists on server ${(server as any).name}, keeping script ${scriptData.script_name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error checking script ${(script as any).script_name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Cleanup completed. Deleted scripts:', deletedScripts);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `Cleanup completed. ${deletedScripts.length} orphaned script(s) removed.`,
|
||||||
|
deletedCount: deletedScripts.length,
|
||||||
|
deletedScripts: deletedScripts
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in cleanupOrphanedScripts:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error instanceof Error ? error.message : 'Failed to cleanup orphaned scripts',
|
||||||
|
deletedCount: 0,
|
||||||
|
deletedScripts: []
|
||||||
|
};
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
import { createTRPCRouter, publicProcedure } from "~/server/api/trpc";
|
||||||
import { readFile } from "fs/promises";
|
import { readFile, writeFile } from "fs/promises";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import { spawn } from "child_process";
|
import { spawn } from "child_process";
|
||||||
|
import { env } from "~/env";
|
||||||
|
import { existsSync, createWriteStream } from "fs";
|
||||||
|
import stripAnsi from "strip-ansi";
|
||||||
|
|
||||||
interface GitHubRelease {
|
interface GitHubRelease {
|
||||||
tag_name: string;
|
tag_name: string;
|
||||||
@@ -10,6 +13,21 @@ interface GitHubRelease {
|
|||||||
html_url: string;
|
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({
|
export const versionRouter = createTRPCRouter({
|
||||||
// Get current local version
|
// Get current local version
|
||||||
getCurrentVersion: publicProcedure
|
getCurrentVersion: publicProcedure
|
||||||
@@ -34,7 +52,7 @@ export const versionRouter = createTRPCRouter({
|
|||||||
getLatestRelease: publicProcedure
|
getLatestRelease: publicProcedure
|
||||||
.query(async () => {
|
.query(async () => {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
|
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`GitHub API error: ${response.status}`);
|
throw new Error(`GitHub API error: ${response.status}`);
|
||||||
@@ -70,7 +88,7 @@ export const versionRouter = createTRPCRouter({
|
|||||||
const currentVersion = (await readFile(versionPath, 'utf-8')).trim();
|
const currentVersion = (await readFile(versionPath, 'utf-8')).trim();
|
||||||
|
|
||||||
|
|
||||||
const response = await fetch('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
|
const response = await fetchGitHubAPI('https://api.github.com/repos/community-scripts/ProxmoxVE-Local/releases/latest');
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`GitHub API error: ${response.status}`);
|
throw new Error(`GitHub API error: ${response.status}`);
|
||||||
@@ -109,21 +127,80 @@ 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
|
// Execute update script
|
||||||
executeUpdate: publicProcedure
|
executeUpdate: publicProcedure
|
||||||
.mutation(async () => {
|
.mutation(async () => {
|
||||||
try {
|
try {
|
||||||
const updateScriptPath = join(process.cwd(), 'update.sh');
|
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
|
// Spawn the update script as a detached process using nohup
|
||||||
// This allows it to run independently and kill the parent Node.js process
|
// This allows it to run independently and kill the parent Node.js process
|
||||||
const child = spawn('nohup', ['bash', updateScriptPath], {
|
// Redirect output to log file
|
||||||
|
const child = spawn('bash', [updateScriptPath], {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
stdio: ['ignore', 'ignore', 'ignore'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
shell: false,
|
shell: false,
|
||||||
detached: true
|
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
|
// Unref the child process so it doesn't keep the parent alive
|
||||||
child.unref();
|
child.unref();
|
||||||
|
|
||||||
|
|||||||
@@ -24,9 +24,12 @@ export class ScriptExecutionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleConnection(ws: WebSocket, _request: IncomingMessage) {
|
private handleConnection(ws: WebSocket, _request: IncomingMessage) {
|
||||||
|
|
||||||
|
|
||||||
ws.on('message', (data) => {
|
ws.on('message', (data) => {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
|
||||||
const message = JSON.parse(data.toString()) as { action: string; scriptPath?: string; executionId?: string };
|
const message = JSON.parse(data.toString()) as { action: string; scriptPath?: string; executionId?: string };
|
||||||
void this.handleMessage(ws, message);
|
void this.handleMessage(ws, message);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -40,20 +43,20 @@ export class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
|
|
||||||
// Clean up any active executions for this connection
|
// Clean up any active executions for this connection
|
||||||
this.cleanupActiveExecutions(ws);
|
this.cleanupActiveExecutions(ws);
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('error', (error) => {
|
ws.on('error', (_error) => {
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
this.cleanupActiveExecutions(ws);
|
this.cleanupActiveExecutions(ws);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleMessage(ws: WebSocket, message: { action: string; scriptPath?: string; executionId?: string; mode?: 'local' | 'ssh'; server?: any }) {
|
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 } = message;
|
const { action, scriptPath, executionId, mode, server, input } = message;
|
||||||
|
|
||||||
console.log('WebSocket message received:', { action, scriptPath, executionId, mode, server: server ? { name: server.name, ip: server.ip } : null });
|
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'start':
|
case 'start':
|
||||||
@@ -74,6 +77,20 @@ export class ScriptExecutionHandler {
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@@ -84,8 +101,7 @@ export class ScriptExecutionHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async startScriptExecution(ws: WebSocket, scriptPath: string, executionId: string, mode?: 'local' | 'ssh', server?: any) {
|
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 {
|
try {
|
||||||
// Check if execution is already running
|
// Check if execution is already running
|
||||||
if (this.activeExecutions.has(executionId)) {
|
if (this.activeExecutions.has(executionId)) {
|
||||||
@@ -100,10 +116,7 @@ export class ScriptExecutionHandler {
|
|||||||
let process: any;
|
let process: any;
|
||||||
|
|
||||||
if (mode === 'ssh' && server) {
|
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, {
|
this.sendMessage(ws, {
|
||||||
type: 'start',
|
type: 'start',
|
||||||
data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`,
|
data: `Starting SSH execution of ${scriptPath} on ${server.name ?? server.ip}`,
|
||||||
@@ -111,13 +124,11 @@ export class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sshService = getSSHExecutionService();
|
const sshService = getSSHExecutionService();
|
||||||
console.log('SSH service obtained, calling executeScript...');
|
|
||||||
console.log('SSH service object:', typeof sshService, sshService.constructor.name);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await sshService.executeScript(server as Server, scriptPath,
|
const result = await sshService.executeScript(server as Server, scriptPath,
|
||||||
(data: string) => {
|
(data: string) => {
|
||||||
console.log('SSH onData callback:', data.substring(0, 100) + '...');
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'output',
|
type: 'output',
|
||||||
data: data,
|
data: data,
|
||||||
@@ -125,7 +136,7 @@ export class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
(error: string) => {
|
(error: string) => {
|
||||||
console.log('SSH onError callback:', error);
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
data: error,
|
data: error,
|
||||||
@@ -133,7 +144,7 @@ export class ScriptExecutionHandler {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
(code: number) => {
|
(code: number) => {
|
||||||
console.log('SSH onExit callback, code:', code);
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'end',
|
type: 'end',
|
||||||
data: `SSH script execution finished with code: ${code}`,
|
data: `SSH script execution finished with code: ${code}`,
|
||||||
@@ -142,10 +153,10 @@ export class ScriptExecutionHandler {
|
|||||||
this.activeExecutions.delete(executionId);
|
this.activeExecutions.delete(executionId);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
console.log('SSH service executeScript completed, result:', result);
|
|
||||||
process = (result as any).process;
|
process = (result as any).process;
|
||||||
} catch (sshError) {
|
} catch (sshError) {
|
||||||
console.error('SSH service executeScript failed:', sshError);
|
|
||||||
this.sendMessage(ws, {
|
this.sendMessage(ws, {
|
||||||
type: 'error',
|
type: 'error',
|
||||||
data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`,
|
data: `SSH execution failed: ${sshError instanceof Error ? sshError.message : String(sshError)}`,
|
||||||
@@ -154,10 +165,7 @@ export class ScriptExecutionHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} 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
|
// Validate script path
|
||||||
const validation = scriptManager.validateScriptPath(scriptPath);
|
const validation = scriptManager.validateScriptPath(scriptPath);
|
||||||
@@ -249,6 +257,59 @@ 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) {
|
private sendMessage(ws: WebSocket, message: ScriptExecutionMessage) {
|
||||||
if (ws.readyState === WebSocket.OPEN) {
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(JSON.stringify(message));
|
ws.send(JSON.stringify(message));
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
/* Semantic color utility classes */
|
/* Semantic color utility classes */
|
||||||
.bg-background { background-color: hsl(var(--background)); }
|
.bg-background { background-color: hsl(var(--background)); }
|
||||||
.text-foreground { color: hsl(var(--foreground)); }
|
.text-foreground { color: hsl(var(--foreground)); }
|
||||||
.bg-card { background-color: hsl(var(--card)); }
|
.bg-card { background-color: hsl(var(--card)) !important; }
|
||||||
.text-card-foreground { color: hsl(var(--card-foreground)); }
|
.text-card-foreground { color: hsl(var(--card-foreground)); }
|
||||||
.bg-popover { background-color: hsl(var(--popover)); }
|
.bg-popover { background-color: hsl(var(--popover)); }
|
||||||
.text-popover-foreground { color: hsl(var(--popover-foreground)); }
|
.text-popover-foreground { color: hsl(var(--popover-foreground)); }
|
||||||
@@ -141,3 +141,75 @@
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
background-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;
|
||||||
|
}
|
||||||
|
|||||||
491
update.sh
491
update.sh
@@ -16,6 +16,13 @@ BACKUP_DIR="/tmp/pve-scripts-backup-$(date +%Y%m%d-%H%M%S)"
|
|||||||
DATA_DIR="./data"
|
DATA_DIR="./data"
|
||||||
LOG_FILE="/tmp/update.log"
|
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
|
# Colors for output
|
||||||
RED='\033[0;31m'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
@@ -23,6 +30,44 @@ YELLOW='\033[1;33m'
|
|||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
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
|
# Initialize log file
|
||||||
init_log() {
|
init_log() {
|
||||||
# Clear/create log file
|
# Clear/create log file
|
||||||
@@ -83,8 +128,18 @@ check_dependencies() {
|
|||||||
get_latest_release() {
|
get_latest_release() {
|
||||||
log "Fetching latest release information from GitHub..."
|
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
|
local release_info
|
||||||
if ! release_info=$(curl -s --connect-timeout 15 --max-time 60 --retry 2 --retry-delay 3 "$GITHUB_API/releases/latest"); then
|
if ! release_info=$(eval "curl $curl_opts \"$GITHUB_API/releases/latest\""); then
|
||||||
log_error "Failed to fetch release information from GitHub API (timeout or network error)"
|
log_error "Failed to fetch release information from GitHub API (timeout or network error)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@@ -170,53 +225,12 @@ download_release() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Download release with timeout and progress
|
# Download release with timeout and progress
|
||||||
log "Downloading from: $download_url"
|
if ! curl -L --connect-timeout 30 --max-time 300 --retry 3 --retry-delay 5 -o "$archive_file" "$download_url" 2>/dev/null; then
|
||||||
log "Target file: $archive_file"
|
log_error "Failed to download release from GitHub"
|
||||||
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"
|
rm -rf "$temp_dir"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
# Verify download
|
||||||
if [ ! -f "$archive_file" ] || [ ! -s "$archive_file" ]; then
|
if [ ! -f "$archive_file" ] || [ ! -s "$archive_file" ]; then
|
||||||
log_error "Downloaded file is empty or missing"
|
log_error "Downloaded file is empty or missing"
|
||||||
@@ -224,52 +238,35 @@ download_release() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
local file_size
|
log_success "Downloaded release"
|
||||||
file_size=$(stat -c%s "$archive_file" 2>/dev/null || echo "0")
|
|
||||||
log_success "Downloaded release ($file_size bytes)"
|
|
||||||
|
|
||||||
# Extract release
|
# Extract release
|
||||||
log "Extracting release..."
|
if ! tar -xzf "$archive_file" -C "$temp_dir" 2>/dev/null; then
|
||||||
if ! tar -xzf "$archive_file" -C "$temp_dir"; then
|
|
||||||
log_error "Failed to extract release"
|
log_error "Failed to extract release"
|
||||||
rm -rf "$temp_dir"
|
rm -rf "$temp_dir"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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)
|
# Find the extracted directory (GitHub tarballs have a root directory)
|
||||||
log "Looking for extracted directory with pattern: ${REPO_NAME}-*"
|
|
||||||
local extracted_dir
|
local extracted_dir
|
||||||
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d -name "${REPO_NAME}-*" 2>/dev/null | head -1)
|
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d -name "community-scripts-ProxmoxVE-Local-*" 2>/dev/null | head -1)
|
||||||
|
|
||||||
# If not found with repo name, try alternative patterns
|
# Try alternative patterns if not found
|
||||||
if [ -z "$extracted_dir" ]; then
|
if [ -z "$extracted_dir" ]; then
|
||||||
log "Trying pattern: community-scripts-ProxmoxVE-Local-*"
|
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d -name "${REPO_NAME}-*" 2>/dev/null | head -1)
|
||||||
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d -name "community-scripts-ProxmoxVE-Local-*" 2>/dev/null | head -1)
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$extracted_dir" ]; then
|
if [ -z "$extracted_dir" ]; then
|
||||||
log "Trying pattern: ProxmoxVE-Local-*"
|
extracted_dir=$(find "$temp_dir" -maxdepth 1 -type d ! -name "$temp_dir" 2>/dev/null | head -1)
|
||||||
extracted_dir=$(timeout 10 find "$temp_dir" -maxdepth 1 -type d -name "ProxmoxVE-Local-*" 2>/dev/null | head -1)
|
|
||||||
fi
|
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
|
if [ -z "$extracted_dir" ]; then
|
||||||
log_error "Could not find extracted directory"
|
log_error "Could not find extracted directory"
|
||||||
rm -rf "$temp_dir"
|
rm -rf "$temp_dir"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log_success "Found extracted directory: $extracted_dir"
|
log_success "Release extracted successfully"
|
||||||
log_success "Release downloaded and extracted successfully"
|
|
||||||
echo "$extracted_dir"
|
echo "$extracted_dir"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,6 +274,10 @@ download_release() {
|
|||||||
clear_original_directory() {
|
clear_original_directory() {
|
||||||
log "Clearing 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)
|
# List of files/directories to preserve (already backed up)
|
||||||
local preserve_patterns=(
|
local preserve_patterns=(
|
||||||
"data"
|
"data"
|
||||||
@@ -285,7 +286,6 @@ clear_original_directory() {
|
|||||||
"update.log"
|
"update.log"
|
||||||
"*.backup"
|
"*.backup"
|
||||||
"*.bak"
|
"*.bak"
|
||||||
"node_modules"
|
|
||||||
".git"
|
".git"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -368,148 +368,21 @@ restore_backup_files() {
|
|||||||
|
|
||||||
# Check if systemd service exists
|
# Check if systemd service exists
|
||||||
check_service() {
|
check_service() {
|
||||||
if systemctl list-unit-files | grep -q "^pvescriptslocal.service"; then
|
# 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
|
||||||
return 0
|
return 0
|
||||||
else
|
else
|
||||||
return 1
|
return 1
|
||||||
fi
|
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 the application before updating
|
||||||
stop_application() {
|
stop_application() {
|
||||||
log "Stopping application..."
|
|
||||||
|
|
||||||
# Change to the application directory if we're not already there
|
# Change to the application directory if we're not already there
|
||||||
local app_dir
|
local app_dir
|
||||||
@@ -531,23 +404,31 @@ stop_application() {
|
|||||||
|
|
||||||
log "Working from application directory: $(pwd)"
|
log "Working from application directory: $(pwd)"
|
||||||
|
|
||||||
# Check if systemd service exists and is active
|
# Check if systemd service is running and disable it temporarily
|
||||||
if check_service; then
|
if check_service && systemctl is-active --quiet pvescriptslocal.service; then
|
||||||
if systemctl is-active --quiet pvescriptslocal.service; then
|
log "Disabling systemd service temporarily to prevent auto-restart..."
|
||||||
log "Stopping pvescriptslocal service..."
|
if systemctl disable pvescriptslocal.service; then
|
||||||
if systemctl stop pvescriptslocal.service; then
|
log_success "Service disabled successfully"
|
||||||
log_success "Service stopped successfully"
|
|
||||||
else
|
|
||||||
log_error "Failed to stop service, falling back to process kill"
|
|
||||||
kill_processes
|
|
||||||
fi
|
|
||||||
else
|
else
|
||||||
log "Service exists but is not active, checking for running processes..."
|
log_error "Failed to disable service"
|
||||||
kill_processes
|
return 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log "No systemd service found, stopping processes directly..."
|
log "No running systemd service found"
|
||||||
kill_processes
|
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"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -578,26 +459,20 @@ update_files() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
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
|
# Use process substitution instead of pipe to avoid subshell issues
|
||||||
local files_copied=0
|
local files_copied=0
|
||||||
local files_excluded=0
|
local files_excluded=0
|
||||||
|
|
||||||
log "Starting file copy process from: $actual_source_dir"
|
|
||||||
|
|
||||||
# Create a temporary file list to avoid process substitution issues
|
# Create a temporary file list to avoid process substitution issues
|
||||||
local file_list="/tmp/file_list_$$.txt"
|
local file_list="/tmp/file_list_$$.txt"
|
||||||
find "$actual_source_dir" -type f > "$file_list"
|
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
|
while IFS= read -r file; do
|
||||||
local rel_path="${file#$actual_source_dir/}"
|
local rel_path="${file#$actual_source_dir/}"
|
||||||
local should_exclude=false
|
local should_exclude=false
|
||||||
@@ -615,60 +490,97 @@ update_files() {
|
|||||||
if [ "$target_dir" != "." ]; then
|
if [ "$target_dir" != "." ]; then
|
||||||
mkdir -p "$target_dir"
|
mkdir -p "$target_dir"
|
||||||
fi
|
fi
|
||||||
log "Copying: $file -> $rel_path"
|
|
||||||
if ! cp "$file" "$rel_path"; then
|
if ! cp "$file" "$rel_path"; then
|
||||||
log_error "Failed to copy $rel_path"
|
log_error "Failed to copy $rel_path"
|
||||||
rm -f "$file_list"
|
rm -f "$file_list"
|
||||||
return 1
|
return 1
|
||||||
else
|
|
||||||
files_copied=$((files_copied + 1))
|
|
||||||
if [ $((files_copied % 10)) -eq 0 ]; then
|
|
||||||
log "Copied $files_copied files so far..."
|
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
files_copied=$((files_copied + 1))
|
||||||
else
|
else
|
||||||
files_excluded=$((files_excluded + 1))
|
files_excluded=$((files_excluded + 1))
|
||||||
log "Excluded: $rel_path"
|
|
||||||
fi
|
fi
|
||||||
done < "$file_list"
|
done < "$file_list"
|
||||||
|
|
||||||
# Clean up temporary file
|
# Clean up temporary file
|
||||||
rm -f "$file_list"
|
rm -f "$file_list"
|
||||||
|
|
||||||
log "Files processed: $files_copied copied, $files_excluded excluded"
|
# Verify critical files were copied
|
||||||
|
if [ ! -f "package.json" ]; then
|
||||||
|
log_error "package.json was not copied to target directory!"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
log_success "Application files updated successfully"
|
if [ ! -f "package-lock.json" ]; then
|
||||||
|
log_warning "package-lock.json was not copied!"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Application files updated successfully ($files_copied files)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Install dependencies and build
|
# Install dependencies and build
|
||||||
install_and_build() {
|
install_and_build() {
|
||||||
log "Installing dependencies..."
|
log "Installing dependencies..."
|
||||||
|
|
||||||
if ! npm install; then
|
# Verify package.json exists
|
||||||
log_error "Failed to install dependencies"
|
if [ ! -f "package.json" ]; then
|
||||||
|
log_error "package.json not found! Cannot install dependencies."
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Ensure no processes are running before build
|
if [ ! -f "package-lock.json" ]; then
|
||||||
log "Ensuring no conflicting processes are running..."
|
log_warning "No package-lock.json found, npm will generate one"
|
||||||
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
|
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
|
||||||
|
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
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_success "Dependencies installed successfully"
|
||||||
|
rm -f "$npm_log"
|
||||||
|
|
||||||
log "Building application..."
|
log "Building application..."
|
||||||
# Set NODE_ENV to production for build
|
# Set NODE_ENV to production for build
|
||||||
export NODE_ENV=production
|
export NODE_ENV=production
|
||||||
|
|
||||||
if ! npm run build; then
|
# Create temporary file for npm build output
|
||||||
|
local build_log="/tmp/npm_build_$$.log"
|
||||||
|
|
||||||
|
if ! npm run build > "$build_log" 2>&1; then
|
||||||
log_error "Failed to build application"
|
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
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Log success and clean up
|
||||||
|
log_success "Application built successfully"
|
||||||
|
rm -f "$build_log"
|
||||||
|
|
||||||
log_success "Dependencies installed and application built successfully"
|
log_success "Dependencies installed and application built successfully"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,11 +588,11 @@ install_and_build() {
|
|||||||
start_application() {
|
start_application() {
|
||||||
log "Starting application..."
|
log "Starting application..."
|
||||||
|
|
||||||
# Check if systemd service exists
|
# Use the global variable to determine how to start
|
||||||
if check_service; then
|
if [ "$SERVICE_WAS_RUNNING" = true ] && check_service; then
|
||||||
log "Starting pvescriptslocal service..."
|
log "Service was running before update, re-enabling and starting systemd service..."
|
||||||
if systemctl start pvescriptslocal.service; then
|
if systemctl enable --now pvescriptslocal.service; then
|
||||||
log_success "Service started successfully"
|
log_success "Service enabled and started successfully"
|
||||||
# Wait a moment and check if it's running
|
# Wait a moment and check if it's running
|
||||||
sleep 2
|
sleep 2
|
||||||
if systemctl is-active --quiet pvescriptslocal.service; then
|
if systemctl is-active --quiet pvescriptslocal.service; then
|
||||||
@@ -689,11 +601,11 @@ start_application() {
|
|||||||
log_warning "Service started but may not be running properly"
|
log_warning "Service started but may not be running properly"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log_error "Failed to start service, falling back to npm start"
|
log_error "Failed to enable/start service, falling back to npm start"
|
||||||
start_with_npm
|
start_with_npm
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
log "No systemd service found, starting with npm..."
|
log "Service was not running before update or no service exists, starting with npm..."
|
||||||
start_with_npm
|
start_with_npm
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
@@ -766,25 +678,22 @@ rollback() {
|
|||||||
|
|
||||||
# Main update process
|
# Main update process
|
||||||
main() {
|
main() {
|
||||||
init_log
|
# 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
|
||||||
|
|
||||||
# Check if we're running from the application directory and not already relocated
|
# 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
|
if [ -z "${PVE_UPDATE_RELOCATED:-}" ] && [ -f "package.json" ] && [ -f "server.js" ]; then
|
||||||
log "Detected running from application directory"
|
log "Detected running from application directory"
|
||||||
log "Copying update script to temporary location for safe execution..."
|
bash "$0" --relocated
|
||||||
|
exit $?
|
||||||
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
|
fi
|
||||||
|
|
||||||
# Ensure we're in the application directory
|
# Ensure we're in the application directory
|
||||||
@@ -793,7 +702,6 @@ main() {
|
|||||||
# First check if we're already in the right directory
|
# First check if we're already in the right directory
|
||||||
if [ -f "package.json" ] && [ -f "server.js" ]; then
|
if [ -f "package.json" ] && [ -f "server.js" ]; then
|
||||||
app_dir="$(pwd)"
|
app_dir="$(pwd)"
|
||||||
log "Already in application directory: $app_dir"
|
|
||||||
else
|
else
|
||||||
# Try multiple common locations
|
# Try multiple common locations
|
||||||
for search_path in /opt /root /home /usr/local; do
|
for search_path in /opt /root /home /usr/local; do
|
||||||
@@ -810,10 +718,8 @@ main() {
|
|||||||
log_error "Failed to change to application directory: $app_dir"
|
log_error "Failed to change to application directory: $app_dir"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
log "Changed to application directory: $(pwd)"
|
|
||||||
else
|
else
|
||||||
log_error "Could not find application directory"
|
log_error "Could not find application directory"
|
||||||
log "Searched in: /opt, /root, /home, /usr/local"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -821,6 +727,16 @@ main() {
|
|||||||
# Check dependencies
|
# Check dependencies
|
||||||
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
|
# Get latest release info
|
||||||
local release_info
|
local release_info
|
||||||
release_info=$(get_latest_release)
|
release_info=$(get_latest_release)
|
||||||
@@ -828,60 +744,35 @@ main() {
|
|||||||
# Backup data directory
|
# Backup data directory
|
||||||
backup_data
|
backup_data
|
||||||
|
|
||||||
# Stop the application before updating (now running from /tmp/)
|
# Stop the application before updating
|
||||||
stop_application
|
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
|
# Download and extract release
|
||||||
local source_dir
|
local source_dir
|
||||||
source_dir=$(download_release "$release_info")
|
source_dir=$(download_release "$release_info")
|
||||||
log "Download completed, source_dir: $source_dir"
|
|
||||||
|
|
||||||
# Clear the original directory before updating
|
# Clear the original directory before updating
|
||||||
log "Clearing original directory..."
|
|
||||||
clear_original_directory
|
clear_original_directory
|
||||||
log "Original directory cleared successfully"
|
|
||||||
|
|
||||||
# Update files
|
# Update files
|
||||||
log "Starting file update process..."
|
|
||||||
if ! update_files "$source_dir"; then
|
if ! update_files "$source_dir"; then
|
||||||
log_error "File update failed, rolling back..."
|
log_error "File update failed, rolling back..."
|
||||||
rollback
|
rollback
|
||||||
fi
|
fi
|
||||||
log "File update completed successfully"
|
|
||||||
|
|
||||||
# Restore .env and data directory before building
|
# Restore .env and data directory before building
|
||||||
log "Restoring backup files..."
|
|
||||||
restore_backup_files
|
restore_backup_files
|
||||||
log "Backup files restored successfully"
|
|
||||||
|
|
||||||
# Install dependencies and build
|
# Install dependencies and build
|
||||||
log "Starting install and build process..."
|
|
||||||
if ! install_and_build; then
|
if ! install_and_build; then
|
||||||
log_error "Install and build failed, rolling back..."
|
log_error "Install and build failed, rolling back..."
|
||||||
rollback
|
rollback
|
||||||
fi
|
fi
|
||||||
log "Install and build completed successfully"
|
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
log "Cleaning up temporary files..."
|
|
||||||
rm -rf "$source_dir"
|
rm -rf "$source_dir"
|
||||||
rm -rf "/tmp/pve-update-$$"
|
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 the application
|
||||||
start_application
|
start_application
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user