Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55783c6bb3 | ||
|
|
7bf67091b7 | ||
|
|
c28e1d8dae | ||
|
|
e0a7a44470 | ||
|
|
430355aa4b | ||
|
|
baedcf8581 | ||
|
|
2735d7ecc9 | ||
|
|
e478a66c75 | ||
|
|
a9f23f2d28 | ||
|
|
3ec045de25 | ||
|
|
d9ceac03a2 | ||
|
|
fbc59d8964 | ||
|
|
8c27eacff7 | ||
|
|
86056c984d | ||
|
|
8af011a191 | ||
|
|
1dcf4159d6 | ||
|
|
84f664af96 | ||
|
|
5bb10145ad | ||
|
|
08fe6a7843 | ||
|
|
ed3b4c7b19 | ||
|
|
930254d4cb | ||
|
|
9e450ecbd1 | ||
|
|
cf3f9a5479 | ||
|
|
6ad18e185e | ||
|
|
42fbdc87da | ||
|
|
8e2286d847 | ||
|
|
73776ec6ac | ||
|
|
596887d19c | ||
|
|
0676772992 | ||
|
|
72a9ea52b0 | ||
|
|
826ee26cdf | ||
|
|
82d14f1fb1 | ||
|
|
220b610466 | ||
|
|
e460a05f91 | ||
|
|
21f723bff6 | ||
|
|
45ba67c827 | ||
|
|
61904f2936 | ||
|
|
237aee9c46 | ||
|
|
6bd402abea | ||
|
|
bd3ca74175 | ||
|
|
9fc61bb416 | ||
|
|
75d52c771f | ||
|
|
c868b008c5 | ||
|
|
7f1ef91db9 | ||
|
|
2ee5f73117 | ||
|
|
881456e74e | ||
|
|
650afd5872 | ||
|
|
f0dc393358 | ||
|
|
db72fea774 | ||
|
|
24896f9c0d | ||
|
|
bf434e3b39 | ||
|
|
7699858a35 | ||
|
|
962a3b9908 | ||
|
|
a1fe386efd | ||
|
|
aa76e17e25 |
@@ -52,6 +52,14 @@ const config = {
|
||||
}
|
||||
return config;
|
||||
},
|
||||
// Ignore ESLint errors during build (they can be fixed separately)
|
||||
eslint: {
|
||||
ignoreDuringBuilds: true,
|
||||
},
|
||||
// Ignore TypeScript errors during build (they can be fixed separately)
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
385
package-lock.json
generated
385
package-lock.json
generated
@@ -8,7 +8,7 @@
|
||||
"name": "pve-scripts-local",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.17.1",
|
||||
"@prisma/client": "^6.18.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
@@ -29,8 +29,8 @@
|
||||
"cron-validator": "^1.2.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"next": "^15.5.6",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^15.1.6",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -48,7 +48,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.15",
|
||||
"@tailwindcss/postcss": "^4.1.16",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -59,16 +59,16 @@
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-next": "^16.0.0",
|
||||
"eslint-config-next": "^15.1.6",
|
||||
"jsdom": "^27.0.1",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"prisma": "^6.18.0",
|
||||
"prisma": "^6.19.0",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
@@ -317,9 +317,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
|
||||
"integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -351,13 +351,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz",
|
||||
"integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
|
||||
"integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.28.4"
|
||||
"@babel/types": "^7.28.5"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -442,14 +442,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz",
|
||||
"integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==",
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
|
||||
"integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.27.1"
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1838,9 +1838,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.0.0.tgz",
|
||||
"integrity": "sha512-IB7RzmmtrPOrpAgEBR1PIQPD0yea5lggh5cq54m51jHjjljU80Ia+czfxJYMlSDl1DPvpzb8S9TalCc0VMo9Hw==",
|
||||
"version": "15.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-15.5.6.tgz",
|
||||
"integrity": "sha512-YxDvsT2fwy1j5gMqk3ppXlsgDopHnkM4BoxSVASbvvgh5zgsK8lvWerDzPip8k3WVzsTZ1O7A7si1KNfN4OZfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2042,9 +2042,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@prisma/client": {
|
||||
"version": "6.17.1",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.17.1.tgz",
|
||||
"integrity": "sha512-zL58jbLzYamjnNnmNA51IOZdbk5ci03KviXCuB0Tydc9btH2kDWsi1pQm2VecviRTM7jGia0OPPkgpGnT3nKvw==",
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.18.0.tgz",
|
||||
"integrity": "sha512-jnL2I9gDnPnw4A+4h5SuNn8Gc+1mL1Z79U/3I9eE2gbxJG1oSA+62ByPW4xkeDgwE0fqMzzpAZ7IHxYnLZ4iQA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@@ -2064,9 +2064,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/config": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.18.0.tgz",
|
||||
"integrity": "sha512-rgFzspCpwsE+q3OF/xkp0fI2SJ3PfNe9LLMmuSVbAZ4nN66WfBiKqJKo/hLz3ysxiPQZf8h1SMf2ilqPMeWATQ==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/config/-/config-6.19.0.tgz",
|
||||
"integrity": "sha512-zwCayme+NzI/WfrvFEtkFhhOaZb/hI+X8TTjzjJ252VbPxAl2hWHK5NMczmnG9sXck2lsXrxIZuK524E25UNmg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
@@ -2077,53 +2077,53 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/debug": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.18.0.tgz",
|
||||
"integrity": "sha512-PMVPMmxPj0ps1VY75DIrT430MoOyQx9hmm174k6cmLZpcI95rAPXOQ+pp8ANQkJtNyLVDxnxVJ0QLbrm/ViBcg==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-6.19.0.tgz",
|
||||
"integrity": "sha512-8hAdGG7JmxrzFcTzXZajlQCidX0XNkMJkpqtfbLV54wC6LSSX6Vni25W/G+nAANwLnZ2TmwkfIuWetA7jJxJFA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/engines": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.18.0.tgz",
|
||||
"integrity": "sha512-i5RzjGF/ex6AFgqEe2o1IW8iIxJGYVQJVRau13kHPYEL1Ck8Zvwuzamqed/1iIljs5C7L+Opiz5TzSsUebkriA==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-6.19.0.tgz",
|
||||
"integrity": "sha512-pMRJ+1S6NVdXoB8QJAPIGpKZevFjxhKt0paCkRDTZiczKb7F4yTgRP8M4JdVkpQwmaD4EoJf6qA+p61godDokw==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.18.0",
|
||||
"@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
|
||||
"@prisma/fetch-engine": "6.18.0",
|
||||
"@prisma/get-platform": "6.18.0"
|
||||
"@prisma/debug": "6.19.0",
|
||||
"@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773",
|
||||
"@prisma/fetch-engine": "6.19.0",
|
||||
"@prisma/get-platform": "6.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/engines-version": {
|
||||
"version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f.tgz",
|
||||
"integrity": "sha512-T7Af4QsJQnSgWN1zBbX+Cha5t4qjHRxoeoWpK4JugJzG/ipmmDMY5S+O0N1ET6sCBNVkf6lz+Y+ZNO9+wFU8pQ==",
|
||||
"version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773.tgz",
|
||||
"integrity": "sha512-gV7uOBQfAFlWDvPJdQxMT1aSRur3a0EkU/6cfbAC5isV67tKDWUrPauyaHNpB+wN1ebM4A9jn/f4gH+3iHSYSQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@prisma/fetch-engine": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.18.0.tgz",
|
||||
"integrity": "sha512-TdaBvTtBwP3IoqVYoGIYpD4mWlk0pJpjTJjir/xLeNWlwog7Sl3bD2J0jJ8+5+q/6RBg+acb9drsv5W6lqae7A==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-6.19.0.tgz",
|
||||
"integrity": "sha512-OOx2Lda0DGrZ1rodADT06ZGqHzr7HY7LNMaFE2Vp8dp146uJld58sRuasdX0OiwpHgl8SqDTUKHNUyzEq7pDdQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.18.0",
|
||||
"@prisma/engines-version": "6.18.0-8.34b5a692b7bd79939a9a2c3ef97d816e749cda2f",
|
||||
"@prisma/get-platform": "6.18.0"
|
||||
"@prisma/debug": "6.19.0",
|
||||
"@prisma/engines-version": "6.19.0-26.2ba551f319ab1df4bc874a89965d8b3641056773",
|
||||
"@prisma/get-platform": "6.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@prisma/get-platform": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.18.0.tgz",
|
||||
"integrity": "sha512-uXNJCJGhxTCXo2B25Ta91Rk1/Nmlqg9p7G9GKh8TPhxvAyXCvMNQoogj4JLEUy+3ku8g59cpyQIKFhqY2xO2bg==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-6.19.0.tgz",
|
||||
"integrity": "sha512-ym85WDO2yDhC3fIXHWYpG3kVMBA49cL1XD2GCsCF8xbwoy2OkDQY44gEbAt2X46IQ4Apq9H6g0Ex1iFfPqEkHA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/debug": "6.18.0"
|
||||
"@prisma/debug": "6.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
@@ -2660,9 +2660,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.38",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz",
|
||||
"integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==",
|
||||
"version": "1.0.0-beta.43",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz",
|
||||
"integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2981,6 +2981,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rushstack/eslint-patch": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.14.1.tgz",
|
||||
"integrity": "sha512-jGTk8UD/RdjsNZW8qq10r0RBvxL8OWtoT+kImlzPDFilmozzM+9QmIJsmze9UiSBrFU45ZxhTYBypn9q9z/VfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
|
||||
@@ -3053,56 +3060,49 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.15.tgz",
|
||||
"integrity": "sha512-HF4+7QxATZWY3Jr8OlZrBSXmwT3Watj0OogeDvdUY/ByXJHQ+LBtqA2brDb3sBxYslIFx6UP94BJ4X6a4L9Bmw==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
||||
"integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"enhanced-resolve": "^5.18.3",
|
||||
"jiti": "^2.6.0",
|
||||
"jiti": "^2.6.1",
|
||||
"lightningcss": "1.30.2",
|
||||
"magic-string": "^0.30.19",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.1.15"
|
||||
"tailwindcss": "4.1.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node/node_modules/tailwindcss": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz",
|
||||
"integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.15.tgz",
|
||||
"integrity": "sha512-krhX+UOOgnsUuks2SR7hFafXmLQrKxB4YyRTERuCE59JlYL+FawgaAlSkOYmDRJdf1Q+IFNDMl9iRnBW7QBDfQ==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz",
|
||||
"integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.15",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.15",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.15",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.15",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.15",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.15",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.15",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.15",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.15",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.15",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.15",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.15"
|
||||
"@tailwindcss/oxide-android-arm64": "4.1.16",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.1.16",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.1.16",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.1.16",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.16",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.16",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.16",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.16",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.16",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.16",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.15.tgz",
|
||||
"integrity": "sha512-TkUkUgAw8At4cBjCeVCRMc/guVLKOU1D+sBPrHt5uVcGhlbVKxrCaCW9OKUIBv1oWkjh4GbunD/u/Mf0ql6kEA==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz",
|
||||
"integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3117,9 +3117,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.15.tgz",
|
||||
"integrity": "sha512-xt5XEJpn2piMSfvd1UFN6jrWXyaKCwikP4Pidcf+yfHTSzSpYhG3dcMktjNkQO3JiLCp+0bG0HoWGvz97K162w==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz",
|
||||
"integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3134,9 +3134,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.15.tgz",
|
||||
"integrity": "sha512-TnWaxP6Bx2CojZEXAV2M01Yl13nYPpp0EtGpUrY+LMciKfIXiLL2r/SiSRpagE5Fp2gX+rflp/Os1VJDAyqymg==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz",
|
||||
"integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3151,9 +3151,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.15.tgz",
|
||||
"integrity": "sha512-quISQDWqiB6Cqhjc3iWptXVZHNVENsWoI77L1qgGEHNIdLDLFnw3/AfY7DidAiiCIkGX/MjIdB3bbBZR/G2aJg==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz",
|
||||
"integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3168,9 +3168,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.15.tgz",
|
||||
"integrity": "sha512-ObG76+vPlab65xzVUQbExmDU9FIeYLQ5k2LrQdR2Ud6hboR+ZobXpDoKEYXf/uOezOfIYmy2Ta3w0ejkTg9yxg==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz",
|
||||
"integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3185,9 +3185,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.15.tgz",
|
||||
"integrity": "sha512-4WbBacRmk43pkb8/xts3wnOZMDKsPFyEH/oisCm2q3aLZND25ufvJKcDUpAu0cS+CBOL05dYa8D4U5OWECuH/Q==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz",
|
||||
"integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3202,9 +3202,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.15.tgz",
|
||||
"integrity": "sha512-AbvmEiteEj1nf42nE8skdHv73NoR+EwXVSgPY6l39X12Ex8pzOwwfi3Kc8GAmjsnsaDEbk+aj9NyL3UeyHcTLg==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz",
|
||||
"integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3219,9 +3219,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.15.tgz",
|
||||
"integrity": "sha512-+rzMVlvVgrXtFiS+ES78yWgKqpThgV19ISKD58Ck+YO5pO5KjyxLt7AWKsWMbY0R9yBDC82w6QVGz837AKQcHg==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz",
|
||||
"integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3236,9 +3236,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.15.tgz",
|
||||
"integrity": "sha512-fPdEy7a8eQN9qOIK3Em9D3TO1z41JScJn8yxl/76mp4sAXFDfV4YXxsiptJcOwy6bGR+70ZSwFIZhTXzQeqwQg==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz",
|
||||
"integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3253,9 +3253,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.15.tgz",
|
||||
"integrity": "sha512-sJ4yd6iXXdlgIMfIBXuVGp/NvmviEoMVWMOAGxtxhzLPp9LOj5k0pMEMZdjeMCl4C6Up+RM8T3Zgk+BMQ0bGcQ==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz",
|
||||
"integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -3343,9 +3343,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.15.tgz",
|
||||
"integrity": "sha512-sJGE5faXnNQ1iXeqmRin7Ds/ru2fgCiaQZQQz3ZGIDtvbkeV85rAZ0QJFMDg0FrqsffZG96H1U9AQlNBRLsHVg==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz",
|
||||
"integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3360,9 +3360,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.15.tgz",
|
||||
"integrity": "sha512-NLeHE7jUV6HcFKS504bpOohyi01zPXi2PXmjFfkzTph8xRxDdxkRsXm/xDO5uV5K3brrE1cCwbUYmFUSHR3u1w==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz",
|
||||
"integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3377,26 +3377,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.15.tgz",
|
||||
"integrity": "sha512-IZh8IT76KujRz6d15wZw4eoeViT4TqmzVWNNfpuNCTKiaZUwgr5vtPqO4HjuYDyx3MgGR5qgPt1HMzTeLJyA3g==",
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.16.tgz",
|
||||
"integrity": "sha512-Qn3SFGPXYQMKR/UtqS+dqvPrzEeBZHrFA92maT4zijCVggdsXnDBMsPFJo1eArX3J+O+Gi+8pV4PkqjLCNBk3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@tailwindcss/node": "4.1.15",
|
||||
"@tailwindcss/oxide": "4.1.15",
|
||||
"@tailwindcss/node": "4.1.16",
|
||||
"@tailwindcss/oxide": "4.1.16",
|
||||
"postcss": "^8.4.41",
|
||||
"tailwindcss": "4.1.15"
|
||||
"tailwindcss": "4.1.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/postcss/node_modules/tailwindcss": {
|
||||
"version": "4.1.15",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.15.tgz",
|
||||
"integrity": "sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/typography": {
|
||||
"version": "0.5.19",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz",
|
||||
@@ -4384,18 +4377,18 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz",
|
||||
"integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==",
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz",
|
||||
"integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.4",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.38",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.43",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
"react-refresh": "^0.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
@@ -4882,13 +4875,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ast-v8-to-istanbul": {
|
||||
"version": "0.3.5",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.5.tgz",
|
||||
"integrity": "sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA==",
|
||||
"version": "0.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz",
|
||||
"integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/trace-mapping": "^0.3.30",
|
||||
"@jridgewell/trace-mapping": "^0.3.31",
|
||||
"estree-walker": "^3.0.3",
|
||||
"js-tokens": "^9.0.1"
|
||||
}
|
||||
@@ -6181,24 +6174,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next": {
|
||||
"version": "16.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.0.0.tgz",
|
||||
"integrity": "sha512-DWKT1YAO9ex2rK0/EeiPpKU++ghTiG59z6m08/ReLRECOYIaEv17maSCYT8zmFQLwIrY5lhJ+iaJPQdT4sJd4g==",
|
||||
"version": "15.5.6",
|
||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-15.5.6.tgz",
|
||||
"integrity": "sha512-cGr3VQlPsZBEv8rtYp4BpG1KNXDqGvPo9VC1iaCgIA11OfziC/vczng+TnAS3WpRIR3Q5ye/6yl+CRUuZ1fPGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/eslint-plugin-next": "16.0.0",
|
||||
"@next/eslint-plugin-next": "15.5.6",
|
||||
"@rushstack/eslint-patch": "^1.10.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"@typescript-eslint/parser": "^5.4.2 || ^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"eslint-import-resolver-node": "^0.3.6",
|
||||
"eslint-import-resolver-typescript": "^3.5.2",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||
"eslint-plugin-react": "^7.37.0",
|
||||
"eslint-plugin-react-hooks": "^7.0.0",
|
||||
"globals": "16.4.0",
|
||||
"typescript-eslint": "^8.46.0"
|
||||
"eslint-plugin-react-hooks": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": ">=9.0.0",
|
||||
"eslint": "^7.23.0 || ^8.0.0 || ^9.0.0",
|
||||
"typescript": ">=3.3.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
@@ -6207,19 +6201,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-config-next/node_modules/globals": {
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz",
|
||||
"integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-import-resolver-node": {
|
||||
"version": "0.3.9",
|
||||
"resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz",
|
||||
@@ -6423,20 +6404,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.0.tgz",
|
||||
"integrity": "sha512-fNXaOwvKwq2+pXiRpXc825Vd63+KM4DLL40Rtlycb8m7fYpp6efrTp1sa6ZbP/Ap58K2bEKFXRmhURE+CJAQWw==",
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz",
|
||||
"integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/parser": "^7.24.4",
|
||||
"hermes-parser": "^0.25.1",
|
||||
"zod": "^3.22.4 || ^4.0.0",
|
||||
"zod-validation-error": "^3.0.3 || ^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=10"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
|
||||
@@ -7289,23 +7263,6 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hermes-parser": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
|
||||
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
|
||||
@@ -8675,9 +8632,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.546.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.546.0.tgz",
|
||||
"integrity": "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==",
|
||||
"version": "0.553.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.553.0.tgz",
|
||||
"integrity": "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -9904,11 +9861,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nypm/node_modules/tinyexec": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz",
|
||||
"integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
@@ -10460,15 +10420,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prisma": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.18.0.tgz",
|
||||
"integrity": "sha512-bXWy3vTk8mnRmT+SLyZBQoC2vtV9Z8u7OHvEu+aULYxwiop/CPiFZ+F56KsNRNf35jw+8wcu8pmLsjxpBxAO9g==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/prisma/-/prisma-6.19.0.tgz",
|
||||
"integrity": "sha512-F3eX7K+tWpkbhl3l4+VkFtrwJlLXbAM+f9jolgoUZbFcm1DgHZ4cq9AgVEgUym2au5Ad/TDLN8lg83D+M10ycw==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@prisma/config": "6.18.0",
|
||||
"@prisma/engines": "6.18.0"
|
||||
"@prisma/config": "6.19.0",
|
||||
"@prisma/engines": "6.19.0"
|
||||
},
|
||||
"bin": {
|
||||
"prisma": "build/index.js"
|
||||
@@ -10645,9 +10605,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.17.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||
"integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
"integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -11601,9 +11561,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.9.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
|
||||
"integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -13252,19 +13212,6 @@
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-validation-error": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
||||
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zwitch": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
||||
|
||||
16
package.json
16
package.json
@@ -8,7 +8,7 @@
|
||||
"check": "next lint && tsc --noEmit",
|
||||
"dev": "next dev",
|
||||
"dev:server": "node server.js",
|
||||
"dev:next": "next dev --turbo",
|
||||
"dev:next": "next dev",
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
|
||||
"lint": "next lint",
|
||||
@@ -22,7 +22,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.17.1",
|
||||
"@prisma/client": "^6.18.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
@@ -43,8 +43,8 @@
|
||||
"cron-validator": "^1.2.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"next": "^15.5.6",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^15.1.6",
|
||||
"node-cron": "^3.0.3",
|
||||
"node-pty": "^1.0.0",
|
||||
"react": "^19.0.0",
|
||||
@@ -62,7 +62,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.15",
|
||||
"@tailwindcss/postcss": "^4.1.16",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -73,16 +73,16 @@
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.2.2",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"@vitejs/plugin-react": "^5.1.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-config-next": "^16.0.0",
|
||||
"eslint-config-next": "^15.1.6",
|
||||
"jsdom": "^27.0.1",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"prisma": "^6.18.0",
|
||||
"prisma": "^6.19.0",
|
||||
"tailwindcss": "^4.1.16",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
|
||||
132
scripts/core/api.func
Normal file
132
scripts/core/api.func
Normal file
@@ -0,0 +1,132 @@
|
||||
# Copyright (c) 2021-2025 community-scripts ORG
|
||||
# Author: michelroegl-brunner
|
||||
# License: MIT | https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/LICENSE
|
||||
|
||||
post_to_api() {
|
||||
|
||||
if ! command -v curl &>/dev/null; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$DIAGNOSTICS" = "no" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -z "$RANDOM_UUID" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local API_URL="http://api.community-scripts.org/upload"
|
||||
local pve_version="not found"
|
||||
pve_version=$(pveversion | awk -F'[/ ]' '{print $2}')
|
||||
|
||||
JSON_PAYLOAD=$(
|
||||
cat <<EOF
|
||||
{
|
||||
"ct_type": $CT_TYPE,
|
||||
"type":"lxc",
|
||||
"disk_size": $DISK_SIZE,
|
||||
"core_count": $CORE_COUNT,
|
||||
"ram_size": $RAM_SIZE,
|
||||
"os_type": "$var_os",
|
||||
"os_version": "$var_version",
|
||||
"disableip6": "",
|
||||
"nsapp": "$NSAPP",
|
||||
"method": "$METHOD(PVE-Local)",
|
||||
"pve_version": "$pve_version",
|
||||
"status": "installing",
|
||||
"random_id": "$RANDOM_UUID"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
if [[ "$DIAGNOSTICS" == "yes" ]]; then
|
||||
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_PAYLOAD") || true
|
||||
fi
|
||||
}
|
||||
|
||||
post_to_api_vm() {
|
||||
|
||||
if [[ ! -f /usr/local/community-scripts/diagnostics ]]; then
|
||||
return
|
||||
fi
|
||||
DIAGNOSTICS=$(grep -i "^DIAGNOSTICS=" /usr/local/community-scripts/diagnostics | awk -F'=' '{print $2}')
|
||||
if ! command -v curl &>/dev/null; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$DIAGNOSTICS" = "no" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -z "$RANDOM_UUID" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local API_URL="http://api.community-scripts.org/upload"
|
||||
local pve_version="not found"
|
||||
pve_version=$(pveversion | awk -F'[/ ]' '{print $2}')
|
||||
|
||||
DISK_SIZE_API=${DISK_SIZE%G}
|
||||
|
||||
JSON_PAYLOAD=$(
|
||||
cat <<EOF
|
||||
{
|
||||
"ct_type": 2,
|
||||
"type":"vm",
|
||||
"disk_size": $DISK_SIZE_API,
|
||||
"core_count": $CORE_COUNT,
|
||||
"ram_size": $RAM_SIZE,
|
||||
"os_type": "$var_os",
|
||||
"os_version": "$var_version",
|
||||
"disableip6": "",
|
||||
"nsapp": "$NSAPP",
|
||||
"method": "$METHOD(PVE-Local)",
|
||||
"pve_version": "$pve_version",
|
||||
"status": "installing",
|
||||
"random_id": "$RANDOM_UUID"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
if [[ "$DIAGNOSTICS" == "yes" ]]; then
|
||||
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_PAYLOAD") || true
|
||||
fi
|
||||
}
|
||||
|
||||
POST_UPDATE_DONE=false
|
||||
post_update_to_api() {
|
||||
|
||||
if ! command -v curl &>/dev/null; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ "$POST_UPDATE_DONE" = true ]; then
|
||||
return 0
|
||||
fi
|
||||
local API_URL="http://api.community-scripts.org/upload/updatestatus"
|
||||
local status="${1:-failed}"
|
||||
local error="${2:-No error message}"
|
||||
|
||||
JSON_PAYLOAD=$(
|
||||
cat <<EOF
|
||||
{
|
||||
"status": "$status",
|
||||
"error": "$error",
|
||||
"random_id": "$RANDOM_UUID"
|
||||
}
|
||||
EOF
|
||||
)
|
||||
|
||||
if [[ "$DIAGNOSTICS" == "yes" ]]; then
|
||||
RESPONSE=$(curl -s -w "%{http_code}" -L -X POST "$API_URL" --post301 --post302 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_PAYLOAD") || true
|
||||
fi
|
||||
|
||||
POST_UPDATE_DONE=true
|
||||
}
|
||||
@@ -17,9 +17,9 @@ variables() {
|
||||
|
||||
# Get absolute path to core directory
|
||||
CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$CORE_DIR/api.func"
|
||||
source "$CORE_DIR/core.func"
|
||||
|
||||
|
||||
load_functions
|
||||
# This function enables error handling in the script by setting options and defining a trap for the ERR signal.
|
||||
catch_errors() {
|
||||
set -Eeo pipefail
|
||||
@@ -33,15 +33,15 @@ error_handler() {
|
||||
local line_number="$1"
|
||||
local command="$2"
|
||||
local error_message="${RD}[ERROR]${CL} in line ${RD}$line_number${CL}: exit code ${RD}$exit_code${CL}: while executing command ${YW}$command${CL}"
|
||||
|
||||
post_update_to_api "failed" "${command}"
|
||||
echo -e "\n$error_message\n"
|
||||
}
|
||||
|
||||
# Check if the shell is using bash
|
||||
# Check if the current shell is using bash
|
||||
shell_check() {
|
||||
if [[ "$(basename "$SHELL")" != "bash" ]]; then
|
||||
if [[ "$(ps -p $$ -o comm=)" != "bash" ]]; then
|
||||
clear
|
||||
msg_error "Your default shell is currently not set to Bash. To use these scripts, please switch to the Bash shell."
|
||||
msg_error "Your default shell is not bash. Please report this to our github issues or discord."
|
||||
echo -e "\nExiting..."
|
||||
sleep 2
|
||||
exit
|
||||
@@ -439,14 +439,17 @@ advanced_settings() {
|
||||
exit_script
|
||||
fi
|
||||
done
|
||||
|
||||
if CT_ID=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Container ID" 8 58 "$NEXTID" --title "CONTAINER ID" 3>&1 1>&2 2>&3); then
|
||||
if [ -z "$CT_ID" ]; then
|
||||
CT_ID="$NEXTID"
|
||||
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
|
||||
else
|
||||
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
|
||||
fi
|
||||
else
|
||||
exit_script
|
||||
fi
|
||||
echo -e "${CONTAINERID}${BOLD}${DGN}Container ID: ${BGN}$CT_ID${CL}"
|
||||
|
||||
while true; do
|
||||
if CT_NAME=$(whiptail --backtitle "Proxmox VE Helper Scripts" --inputbox "Set Hostname" 8 58 "$NSAPP" --title "HOSTNAME" 3>&1 1>&2 2>&3); then
|
||||
@@ -911,6 +914,7 @@ install_script() {
|
||||
shell_check
|
||||
root_check
|
||||
arch_check
|
||||
#ssh_check
|
||||
maxkeys_check
|
||||
diagnostics_check
|
||||
|
||||
@@ -1045,7 +1049,6 @@ start() {
|
||||
source "$CORE_DIR/tools.func"
|
||||
if command -v pveversion >/dev/null 2>&1; then
|
||||
install_script
|
||||
|
||||
else
|
||||
CHOICE=$(whiptail --backtitle "Proxmox VE Helper Scripts" --title "${APP} LXC Update/Setting" --menu \
|
||||
"Support/Update functions for ${APP} LXC. Choose an option:" \
|
||||
@@ -1075,7 +1078,6 @@ start() {
|
||||
|
||||
# This function collects user settings and integrates all the collected information.
|
||||
build_container() {
|
||||
|
||||
# if [ "$VERBOSE" == "yes" ]; then set -x; fi
|
||||
|
||||
NET_STRING="-net0 name=eth0,bridge=$BRG$MAC,ip=$NET$GATE$VLAN$MTU"
|
||||
@@ -1099,18 +1101,12 @@ build_container() {
|
||||
fi
|
||||
|
||||
if [[ $DIAGNOSTICS == "yes" ]]; then
|
||||
echo "Diagnostics enabled (post_to_api function not available)"
|
||||
post_to_api
|
||||
fi
|
||||
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
pushd "$TEMP_DIR" >/dev/null
|
||||
# CORE_DIR is already defined at the top of the file
|
||||
|
||||
if [ "$var_os" == "alpine" ]; then
|
||||
export FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/alpine-install.func")"
|
||||
else
|
||||
export FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/install.func")"
|
||||
fi
|
||||
|
||||
export DIAGNOSTICS="$DIAGNOSTICS"
|
||||
export RANDOM_UUID="$RANDOM_UUID"
|
||||
@@ -1130,21 +1126,10 @@ build_container() {
|
||||
export PCT_OSTYPE="$var_os"
|
||||
export PCT_OSVERSION="$var_version"
|
||||
export PCT_DISK_SIZE="$DISK_SIZE"
|
||||
export PCT_OPTIONS="
|
||||
-features $FEATURES
|
||||
-hostname $HN
|
||||
-tags $TAGS
|
||||
$SD
|
||||
$NS
|
||||
$NET_STRING
|
||||
-onboot 1
|
||||
-cores $CORE_COUNT
|
||||
-memory $RAM_SIZE
|
||||
-unprivileged $CT_TYPE
|
||||
$PW
|
||||
"
|
||||
export PCT_OPTIONS="-features $FEATURES -hostname $HN -tags $TAGS $SD $NS $NET_STRING -onboot 1 -cores $CORE_COUNT -memory $RAM_SIZE -unprivileged $CT_TYPE $PW"
|
||||
# This executes create_lxc.sh and creates the container and .conf file
|
||||
bash "$CORE_DIR/create_lxc.sh" $?
|
||||
bash "$CORE_DIR/create_lxc.sh"
|
||||
|
||||
|
||||
LXC_CONFIG="/etc/pve/lxc/${CTID}.conf"
|
||||
|
||||
@@ -1337,8 +1322,64 @@ EOF'
|
||||
fi
|
||||
msg_ok "Customized LXC Container"
|
||||
|
||||
if [ "$var_os" == "alpine" ]; then
|
||||
FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/alpine-install.func")"
|
||||
else
|
||||
FUNCTIONS_FILE_PATH="$(cat "$CORE_DIR/core.func" && echo && cat "$CORE_DIR/tools.func" && echo && cat "$CORE_DIR/install.func")"
|
||||
fi
|
||||
|
||||
lxc-attach -n "$CTID" -- bash -c "$(cat "$(dirname "$CORE_DIR")/install/${var_install}.sh")"
|
||||
FUNCTIONS_FILE="/tmp/functions.sh"
|
||||
echo "$FUNCTIONS_FILE_PATH" | pct exec "$CTID" -- bash -c "cat > $FUNCTIONS_FILE"
|
||||
|
||||
pct exec "$CTID" -- test -f "$FUNCTIONS_FILE" || {
|
||||
msg_error "Failed to write functions file to container"
|
||||
exit 1
|
||||
}
|
||||
|
||||
INSTALL_SCRIPT_PATH="$(dirname "$CORE_DIR")/install/${var_install}.sh"
|
||||
INSTALL_SCRIPT_CONTENT=$(cat "$INSTALL_SCRIPT_PATH")
|
||||
|
||||
# Replace the old pattern: source /dev/stdin <<<"$FUNCTIONS_FILE_PATH"
|
||||
# with direct file source: source "/tmp/functions.sh"
|
||||
# Use sed with a simpler pattern that matches the exact line
|
||||
MODIFIED_INSTALL_SCRIPT=$(echo "$INSTALL_SCRIPT_CONTENT" | \
|
||||
sed "s|source /dev/stdin <<<\"\$FUNCTIONS_FILE_PATH\"|source \"$FUNCTIONS_FILE\"|")
|
||||
|
||||
# Verify replacement worked - if not, force add the source line
|
||||
if ! echo "$MODIFIED_INSTALL_SCRIPT" | grep -q "source \"$FUNCTIONS_FILE\""; then
|
||||
# If replacement didn't work, add source line after the last comment
|
||||
MODIFIED_INSTALL_SCRIPT=$(echo "$MODIFIED_INSTALL_SCRIPT" | \
|
||||
awk -v func_file="$FUNCTIONS_FILE" '
|
||||
BEGIN { replaced = 0 }
|
||||
{
|
||||
if (/source.*dev\/stdin/ || /source.*FUNCTIONS_FILE_PATH/) {
|
||||
print "source \"" func_file "\""
|
||||
replaced = 1
|
||||
next
|
||||
}
|
||||
if (!replaced && /^#/ && !/^#!/) {
|
||||
print
|
||||
next
|
||||
}
|
||||
if (!replaced && !/^#!/ && !/^#/) {
|
||||
print "source \"" func_file "\""
|
||||
replaced = 1
|
||||
}
|
||||
print
|
||||
}
|
||||
END {
|
||||
if (!replaced) {
|
||||
print "source \"" func_file "\""
|
||||
}
|
||||
}')
|
||||
fi
|
||||
|
||||
# Write the modified script to a file in the container and execute it
|
||||
INSTALL_SCRIPT_FILE="/tmp/install_script.sh"
|
||||
echo "$MODIFIED_INSTALL_SCRIPT" | pct exec "$CTID" -- bash -c "cat > $INSTALL_SCRIPT_FILE"
|
||||
pct exec "$CTID" -- chmod +x "$INSTALL_SCRIPT_FILE"
|
||||
|
||||
lxc-attach -n "$CTID" -- bash "$INSTALL_SCRIPT_FILE"
|
||||
}
|
||||
|
||||
# This function sets the description of the container.
|
||||
@@ -1383,6 +1424,37 @@ EOF
|
||||
if [[ -f /etc/systemd/system/ping-instances.service ]]; then
|
||||
systemctl start ping-instances.service
|
||||
fi
|
||||
|
||||
post_update_to_api "done" "none"
|
||||
}
|
||||
|
||||
api_exit_script() {
|
||||
exit_code=$? # Capture the exit status of the last executed command
|
||||
#200 exit codes indicate error in create_lxc.sh
|
||||
#100 exit codes indicate error in install.func
|
||||
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
case $exit_code in
|
||||
100) post_update_to_api "failed" "100: Unexpected error in create_lxc.sh" ;;
|
||||
101) post_update_to_api "failed" "101: No network connection detected in create_lxc.sh" ;;
|
||||
200) post_update_to_api "failed" "200: LXC creation failed in create_lxc.sh" ;;
|
||||
201) post_update_to_api "failed" "201: Invalid Storage class in create_lxc.sh" ;;
|
||||
202) post_update_to_api "failed" "202: User aborted menu in create_lxc.sh" ;;
|
||||
203) post_update_to_api "failed" "203: CTID not set in create_lxc.sh" ;;
|
||||
204) post_update_to_api "failed" "204: PCT_OSTYPE not set in create_lxc.sh" ;;
|
||||
205) post_update_to_api "failed" "205: CTID cannot be less than 100 in create_lxc.sh" ;;
|
||||
206) post_update_to_api "failed" "206: CTID already in use in create_lxc.sh" ;;
|
||||
207) post_update_to_api "failed" "207: Template not found in create_lxc.sh" ;;
|
||||
208) post_update_to_api "failed" "208: Error downloading template in create_lxc.sh" ;;
|
||||
209) post_update_to_api "failed" "209: Container creation failed, but template is intact in create_lxc.sh" ;;
|
||||
*) post_update_to_api "failed" "Unknown error, exit code: $exit_code in create_lxc.sh" ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v pveversion >/dev/null 2>&1; then
|
||||
trap 'api_exit_script' EXIT
|
||||
fi
|
||||
trap 'post_update_to_api "failed" "$BASH_COMMAND"' ERR
|
||||
trap 'post_update_to_api "failed" "INTERRUPTED"' SIGINT
|
||||
trap 'post_update_to_api "failed" "TERMINATED"' SIGTERM
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
username: string | null;
|
||||
isLoading: boolean;
|
||||
expirationTime: number | null;
|
||||
login: (username: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
@@ -21,8 +22,9 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [expirationTime, setExpirationTime] = useState<number | null>(null);
|
||||
|
||||
const checkAuth = async () => {
|
||||
const checkAuthInternal = async (retryCount = 0) => {
|
||||
try {
|
||||
// First check if setup is completed
|
||||
const setupResponse = await fetch('/api/settings/auth-credentials');
|
||||
@@ -33,30 +35,60 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
if (!setupData.setupCompleted || !setupData.enabled) {
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
setExpirationTime(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only verify authentication if setup is completed and auth is enabled
|
||||
const response = await fetch('/api/auth/verify');
|
||||
const response = await fetch('/api/auth/verify', {
|
||||
credentials: 'include', // Ensure cookies are sent
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { username: string };
|
||||
const data = await response.json() as {
|
||||
username: string;
|
||||
expirationTime?: number | null;
|
||||
timeUntilExpiration?: number | null;
|
||||
};
|
||||
setIsAuthenticated(true);
|
||||
setUsername(data.username);
|
||||
setExpirationTime(data.expirationTime ?? null);
|
||||
} else {
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
setExpirationTime(null);
|
||||
|
||||
// Retry logic for failed auth checks (max 2 retries)
|
||||
if (retryCount < 2) {
|
||||
setTimeout(() => {
|
||||
void checkAuthInternal(retryCount + 1);
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking auth:', error);
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
setExpirationTime(null);
|
||||
|
||||
// Retry logic for network errors (max 2 retries)
|
||||
if (retryCount < 2) {
|
||||
setTimeout(() => {
|
||||
void checkAuthInternal(retryCount + 1);
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkAuth = useCallback(() => {
|
||||
return checkAuthInternal(0);
|
||||
}, []);
|
||||
|
||||
const login = async (username: string, password: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
@@ -65,12 +97,21 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
credentials: 'include', // Ensure cookies are received
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { username: string };
|
||||
setIsAuthenticated(true);
|
||||
setUsername(data.username);
|
||||
|
||||
// Check auth again to get expiration time
|
||||
// Add a small delay to ensure the httpOnly cookie is available
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
void checkAuth().then(() => resolve());
|
||||
}, 150);
|
||||
});
|
||||
return true;
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
@@ -88,11 +129,12 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
document.cookie = 'auth-token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
setIsAuthenticated(false);
|
||||
setUsername(null);
|
||||
setExpirationTime(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void checkAuth();
|
||||
}, []);
|
||||
}, [checkAuth]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
@@ -100,6 +142,7 @@ export function AuthProvider({ children }: AuthProviderProps) {
|
||||
isAuthenticated,
|
||||
username,
|
||||
isLoading,
|
||||
expirationTime,
|
||||
login,
|
||||
logout,
|
||||
checkAuth,
|
||||
|
||||
@@ -41,6 +41,7 @@ export function FilterBar({
|
||||
}: FilterBarProps) {
|
||||
const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false);
|
||||
const [isSortDropdownOpen, setIsSortDropdownOpen] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
|
||||
const updateFilters = (updates: Partial<FilterState>) => {
|
||||
onFiltersChange({ ...filters, ...updates });
|
||||
@@ -98,10 +99,36 @@ export function FilterBar({
|
||||
{!isLoadingFilters && (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-foreground">Filter Scripts</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<ContextualHelpIcon section="available-scripts" tooltip="Help with filtering and searching" />
|
||||
<Button
|
||||
onClick={() => setIsMinimized(!isMinimized)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
title={isMinimized ? "Expand filters" : "Minimize filters"}
|
||||
>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isMinimized ? "" : "rotate-180"}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter Content - Conditionally rendered based on minimized state */}
|
||||
{!isMinimized && !isLoadingFilters && (
|
||||
<>
|
||||
{/* Search Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="relative max-w-md w-full">
|
||||
@@ -131,8 +158,7 @@ export function FilterBar({
|
||||
<Button
|
||||
onClick={() => updateFilters({ searchQuery: "" })}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute inset-y-0 right-0 pr-3 text-muted-foreground hover:text-foreground"
|
||||
className="absolute inset-y-0 right-0 flex items-center justify-center pr-3 h-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
@@ -431,6 +457,8 @@ export function FilterBar({
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Click outside to close dropdowns */}
|
||||
{(isTypeDropdownOpen || isSortDropdownOpen) && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ContextualHelpIcon } from './ContextualHelpIcon';
|
||||
import { useTheme } from './ThemeProvider';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useAuth } from './AuthProvider';
|
||||
|
||||
interface GeneralSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -17,7 +18,9 @@ interface GeneralSettingsModalProps {
|
||||
export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'general-settings-modal', allowEscape: true, onClose });
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { isAuthenticated, expirationTime, checkAuth } = useAuth();
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'github' | 'auth' | 'auto-sync'>('general');
|
||||
const [sessionExpirationDisplay, setSessionExpirationDisplay] = useState<string>('');
|
||||
const [githubToken, setGithubToken] = useState('');
|
||||
const [saveFilter, setSaveFilter] = useState(false);
|
||||
const [savedFilters, setSavedFilters] = useState<any>(null);
|
||||
@@ -34,6 +37,7 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
const [authHasCredentials, setAuthHasCredentials] = useState(false);
|
||||
const [authSetupCompleted, setAuthSetupCompleted] = useState(false);
|
||||
const [authLoading, setAuthLoading] = useState(false);
|
||||
const [sessionDurationDays, setSessionDurationDays] = useState(7);
|
||||
|
||||
// Auto-sync state
|
||||
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false);
|
||||
@@ -214,11 +218,12 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
try {
|
||||
const response = await fetch('/api/settings/auth-credentials');
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean };
|
||||
const data = await response.json() as { username: string; enabled: boolean; hasCredentials: boolean; setupCompleted: boolean; sessionDurationDays?: number };
|
||||
setAuthUsername(data.username ?? '');
|
||||
setAuthEnabled(data.enabled ?? false);
|
||||
setAuthHasCredentials(data.hasCredentials ?? false);
|
||||
setAuthSetupCompleted(data.setupCompleted ?? false);
|
||||
setSessionDurationDays(data.sessionDurationDays ?? 7);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading auth credentials:', error);
|
||||
@@ -227,6 +232,64 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
}
|
||||
};
|
||||
|
||||
// Format expiration time display
|
||||
const formatExpirationTime = (expTime: number | null): string => {
|
||||
if (!expTime) return 'No active session';
|
||||
|
||||
const now = Date.now();
|
||||
const timeUntilExpiration = expTime - now;
|
||||
|
||||
if (timeUntilExpiration <= 0) {
|
||||
return 'Session expired';
|
||||
}
|
||||
|
||||
const days = Math.floor(timeUntilExpiration / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((timeUntilExpiration % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((timeUntilExpiration % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
const parts: string[] = [];
|
||||
if (days > 0) {
|
||||
parts.push(`${days} ${days === 1 ? 'day' : 'days'}`);
|
||||
}
|
||||
if (hours > 0) {
|
||||
parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`);
|
||||
}
|
||||
if (minutes > 0 && days === 0) {
|
||||
parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return 'Less than a minute';
|
||||
}
|
||||
|
||||
return parts.join(', ');
|
||||
};
|
||||
|
||||
// Update expiration display periodically
|
||||
useEffect(() => {
|
||||
const updateExpirationDisplay = () => {
|
||||
if (expirationTime) {
|
||||
setSessionExpirationDisplay(formatExpirationTime(expirationTime));
|
||||
} else {
|
||||
setSessionExpirationDisplay('');
|
||||
}
|
||||
};
|
||||
|
||||
updateExpirationDisplay();
|
||||
|
||||
// Update every minute
|
||||
const interval = setInterval(updateExpirationDisplay, 60000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [expirationTime]);
|
||||
|
||||
// Refresh auth when tab changes to auth tab
|
||||
useEffect(() => {
|
||||
if (activeTab === 'auth' && isOpen) {
|
||||
void checkAuth();
|
||||
}
|
||||
}, [activeTab, isOpen, checkAuth]);
|
||||
|
||||
const saveAuthCredentials = async () => {
|
||||
if (authPassword !== authConfirmPassword) {
|
||||
setMessage({ type: 'error', text: 'Passwords do not match' });
|
||||
@@ -265,6 +328,41 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
}
|
||||
};
|
||||
|
||||
const saveSessionDuration = async (days: number) => {
|
||||
if (days < 1 || days > 365) {
|
||||
setMessage({ type: 'error', text: 'Session duration must be between 1 and 365 days' });
|
||||
return;
|
||||
}
|
||||
|
||||
setAuthLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/settings/auth-credentials', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ sessionDurationDays: days }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setMessage({ type: 'success', text: `Session duration updated to ${days} days` });
|
||||
setSessionDurationDays(days);
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
setMessage({ type: 'error', text: errorData.error ?? 'Failed to update session duration' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
}
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Failed to update session duration' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} finally {
|
||||
setAuthLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleAuthEnabled = async (enabled: boolean) => {
|
||||
setAuthLoading(true);
|
||||
setMessage(null);
|
||||
@@ -662,7 +760,10 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
{activeTab === 'auth' && (
|
||||
<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">Authentication Settings</h3>
|
||||
<div className="flex items-center gap-2 mb-3 sm:mb-4">
|
||||
<h3 className="text-base sm:text-lg font-medium text-foreground">Authentication Settings</h3>
|
||||
<ContextualHelpIcon section="auth-settings" tooltip="Help with Authentication Settings" />
|
||||
</div>
|
||||
<p className="text-sm sm:text-base text-muted-foreground mb-4">
|
||||
Configure authentication to secure access to your application.
|
||||
</p>
|
||||
@@ -699,6 +800,68 @@ export function GeneralSettingsModal({ isOpen, onClose }: GeneralSettingsModalPr
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isAuthenticated && expirationTime && (
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Session Information</h4>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Session expires in:</p>
|
||||
<p className="text-sm font-medium text-foreground">{sessionExpirationDisplay}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Expiration date:</p>
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{new Date(expirationTime).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Session Duration</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Configure how long user sessions should last before requiring re-authentication.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="session-duration" className="block text-sm font-medium text-foreground mb-1">
|
||||
Session Duration (days)
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
id="session-duration"
|
||||
type="number"
|
||||
min="1"
|
||||
max="365"
|
||||
placeholder="Enter days"
|
||||
value={sessionDurationDays}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
if (!isNaN(value)) {
|
||||
setSessionDurationDays(value);
|
||||
}
|
||||
}}
|
||||
disabled={authLoading || !authSetupCompleted}
|
||||
className="w-32"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">days (1-365)</span>
|
||||
<Button
|
||||
onClick={() => saveSessionDuration(sessionDurationDays)}
|
||||
disabled={authLoading || !authSetupCompleted}
|
||||
size="sm"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
Note: This setting applies to new logins. Current sessions will not be affected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Update Credentials</h4>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button } from './ui/button';
|
||||
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download } from 'lucide-react';
|
||||
import { HelpCircle, Server, Settings, RefreshCw, Clock, Package, HardDrive, FolderOpen, Search, Download, Lock } from 'lucide-react';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
|
||||
interface HelpModalProps {
|
||||
@@ -11,7 +11,7 @@ interface HelpModalProps {
|
||||
initialSection?: string;
|
||||
}
|
||||
|
||||
type HelpSection = 'server-settings' | 'general-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
|
||||
type HelpSection = 'server-settings' | 'general-settings' | 'auth-settings' | 'sync-button' | 'auto-sync' | 'available-scripts' | 'downloaded-scripts' | 'installed-scripts' | 'lxc-settings' | 'update-system';
|
||||
|
||||
export function HelpModal({ isOpen, onClose, initialSection = 'server-settings' }: HelpModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'help-modal', allowEscape: true, onClose });
|
||||
@@ -22,6 +22,7 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
const sections = [
|
||||
{ id: 'server-settings' as HelpSection, label: 'Server Settings', icon: Server },
|
||||
{ id: 'general-settings' as HelpSection, label: 'General Settings', icon: Settings },
|
||||
{ id: 'auth-settings' as HelpSection, label: 'Authentication Settings', icon: Lock },
|
||||
{ id: 'sync-button' as HelpSection, label: 'Sync Button', icon: RefreshCw },
|
||||
{ id: 'auto-sync' as HelpSection, label: 'Auto-Sync', icon: Clock },
|
||||
{ id: 'available-scripts' as HelpSection, label: 'Available Scripts', icon: Package },
|
||||
@@ -126,16 +127,113 @@ export function HelpModal({ isOpen, onClose, initialSection = 'server-settings'
|
||||
<li>• Token is stored securely and only used for API calls</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'auth-settings':
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-foreground mb-4">Authentication Settings</h3>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Secure your application with username and password authentication and configure session management.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Authentication</h4>
|
||||
<h4 className="font-medium text-foreground mb-2">Overview</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Secure your application with username and password authentication.
|
||||
Authentication settings allow you to secure access to your application with username and password protection.
|
||||
Sessions persist across page refreshes, so users don't need to log in repeatedly.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Set up username and password for app access</li>
|
||||
<li>• Enable/disable authentication as needed</li>
|
||||
<li>• Credentials are stored securely</li>
|
||||
<li>• Credentials are stored securely using bcrypt hashing</li>
|
||||
<li>• Sessions use secure httpOnly cookies</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Setting Up Authentication</h4>
|
||||
<ol className="text-sm text-muted-foreground space-y-2 list-decimal list-inside">
|
||||
<li>Navigate to General Settings → Authentication tab</li>
|
||||
<li>Enter a username (minimum 3 characters)</li>
|
||||
<li>Enter a password (minimum 6 characters)</li>
|
||||
<li>Confirm your password</li>
|
||||
<li>Click "Save Credentials" to save your authentication settings</li>
|
||||
<li>Toggle "Enable Authentication" to activate authentication</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Session Duration</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Configure how long user sessions should last before requiring re-authentication.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Configurable Duration:</strong> Set session duration from 1 to 365 days</li>
|
||||
<li>• <strong>Default Duration:</strong> Sessions default to 7 days if not configured</li>
|
||||
<li>• <strong>Session Persistence:</strong> Sessions persist across page refreshes and browser restarts</li>
|
||||
<li>• <strong>New Logins Only:</strong> Duration changes apply to new logins, not existing sessions</li>
|
||||
</ul>
|
||||
<div className="mt-3 p-3 bg-info/10 rounded-md">
|
||||
<h5 className="font-medium text-info-foreground mb-2">How to Configure:</h5>
|
||||
<ol className="text-xs text-info/80 space-y-1 list-decimal list-inside">
|
||||
<li>Go to General Settings → Authentication tab</li>
|
||||
<li>Find the "Session Duration" section</li>
|
||||
<li>Enter the number of days (1-365)</li>
|
||||
<li>Click "Save" to apply the setting</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Session Information</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
When authenticated, you can view your current session information in the Authentication tab.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• <strong>Time Until Expiration:</strong> See how much time remains before your session expires</li>
|
||||
<li>• <strong>Expiration Date:</strong> View the exact date and time your session will expire</li>
|
||||
<li>• <strong>Auto-Update:</strong> The expiration display updates every minute</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg">
|
||||
<h4 className="font-medium text-foreground mb-2">Updating Credentials</h4>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
You can change your username and password at any time from the Authentication tab.
|
||||
</p>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>• Update username without changing password (leave password fields empty)</li>
|
||||
<li>• Change password by entering a new password and confirmation</li>
|
||||
<li>• Both username and password can be updated together</li>
|
||||
<li>• Changes take effect immediately after saving</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-muted/50">
|
||||
<h4 className="font-medium text-foreground mb-2">Security Features</h4>
|
||||
<ul className="text-sm text-muted-foreground space-y-2">
|
||||
<li>• <strong>Password Hashing:</strong> Passwords are hashed using bcrypt before storage</li>
|
||||
<li>• <strong>Secure Cookies:</strong> Authentication tokens stored in httpOnly cookies</li>
|
||||
<li>• <strong>HTTPS in Production:</strong> Cookies are secure (HTTPS-only) in production mode</li>
|
||||
<li>• <strong>SameSite Protection:</strong> Cookies use strict SameSite policy to prevent CSRF attacks</li>
|
||||
<li>• <strong>JWT Tokens:</strong> Sessions use JSON Web Tokens with expiration</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg bg-warning/10 border-warning/20">
|
||||
<h4 className="font-medium text-warning-foreground mb-2">⚠️ Important Notes</h4>
|
||||
<ul className="text-sm text-warning/80 space-y-2">
|
||||
<li>• <strong>First-Time Setup:</strong> You must complete the initial setup before enabling authentication</li>
|
||||
<li>• <strong>Session Duration:</strong> Changes to session duration only affect new logins</li>
|
||||
<li>• <strong>Logout:</strong> You can log out manually, which immediately invalidates your session</li>
|
||||
<li>• <strong>Lost Credentials:</strong> If you forget your password, you'll need to reset it manually in the .env file</li>
|
||||
<li>• <strong>Disabling Auth:</strong> Disabling authentication clears all credentials and allows unrestricted access</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -935,6 +935,18 @@ export function InstalledScriptsTab() {
|
||||
>
|
||||
{showAutoDetectForm ? 'Cancel Auto-Detect' : '🔍 Auto-Detect LXC Containers (Must contain a tag with "community-script")'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
cleanupRunRef.current = false; // Allow cleanup to run again
|
||||
void cleanupMutation.mutate();
|
||||
}}
|
||||
disabled={cleanupMutation.isPending}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="border-warning/30 text-warning hover:bg-warning/10"
|
||||
>
|
||||
{cleanupMutation.isPending ? '🧹 Cleaning up...' : '🧹 Cleanup Orphaned Scripts'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
// Trigger status check by calling the mutation directly
|
||||
|
||||
@@ -7,6 +7,8 @@ import type { Script } from "~/types/script";
|
||||
import { DiffViewer } from "./DiffViewer";
|
||||
import { TextViewer } from "./TextViewer";
|
||||
import { ExecutionModeModal } from "./ExecutionModeModal";
|
||||
import { ConfirmationModal } from "./ConfirmationModal";
|
||||
import { ScriptVersionModal } from "./ScriptVersionModal";
|
||||
import { TypeBadge, UpdateableBadge, PrivilegedBadge, NoteBadge } from "./Badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
@@ -37,6 +39,10 @@ export function ScriptDetailModal({
|
||||
const [selectedDiffFile, setSelectedDiffFile] = useState<string | null>(null);
|
||||
const [textViewerOpen, setTextViewerOpen] = useState(false);
|
||||
const [executionModeOpen, setExecutionModeOpen] = useState(false);
|
||||
const [versionModalOpen, setVersionModalOpen] = useState(false);
|
||||
const [selectedVersionType, setSelectedVersionType] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
|
||||
// Check if script files exist locally
|
||||
const {
|
||||
@@ -83,6 +89,31 @@ export function ScriptDetailModal({
|
||||
},
|
||||
});
|
||||
|
||||
// Delete script mutation
|
||||
const deleteScriptMutation = api.scripts.deleteScript.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setIsDeleting(false);
|
||||
if (data.success) {
|
||||
const message =
|
||||
"message" in data ? data.message : "Script deleted successfully";
|
||||
setLoadMessage(`[SUCCESS] ${message}`);
|
||||
// Refetch script files status and comparison data to update the UI
|
||||
void refetchScriptFiles();
|
||||
void refetchComparison();
|
||||
} else {
|
||||
const error = "error" in data ? data.error : "Failed to delete script";
|
||||
setLoadMessage(`[ERROR] ${error}`);
|
||||
}
|
||||
// Clear message after 5 seconds
|
||||
setTimeout(() => setLoadMessage(null), 5000);
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsDeleting(false);
|
||||
setLoadMessage(`[ERROR] ${error.message}`);
|
||||
setTimeout(() => setLoadMessage(null), 5000);
|
||||
},
|
||||
});
|
||||
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
const handleImageError = () => {
|
||||
@@ -105,16 +136,43 @@ export function ScriptDetailModal({
|
||||
|
||||
const handleInstallScript = () => {
|
||||
if (!script) return;
|
||||
|
||||
// Check if script has multiple variants (default and alpine)
|
||||
const installMethods = script.install_methods || [];
|
||||
const hasMultipleVariants = installMethods.filter(method =>
|
||||
method.type === 'default' || method.type === 'alpine'
|
||||
).length > 1;
|
||||
|
||||
if (hasMultipleVariants) {
|
||||
// Show version selection modal first
|
||||
setVersionModalOpen(true);
|
||||
} else {
|
||||
// Only one variant, proceed directly to execution mode
|
||||
// Use the first available method or default to 'default' type
|
||||
const defaultMethod = installMethods.find(method => method.type === 'default');
|
||||
const firstMethod = installMethods[0];
|
||||
setSelectedVersionType(defaultMethod?.type || firstMethod?.type || 'default');
|
||||
setExecutionModeOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVersionSelect = (versionType: string) => {
|
||||
setSelectedVersionType(versionType);
|
||||
setVersionModalOpen(false);
|
||||
setExecutionModeOpen(true);
|
||||
};
|
||||
|
||||
const handleExecuteScript = (mode: "local" | "ssh", server?: any) => {
|
||||
if (!script || !onInstallScript) return;
|
||||
|
||||
// Find the script path (CT or tools)
|
||||
// Find the script path based on selected version type
|
||||
const versionType = selectedVersionType || 'default';
|
||||
const scriptMethod = script.install_methods?.find(
|
||||
(method) => method.type === versionType && method.script,
|
||||
) || script.install_methods?.find(
|
||||
(method) => method.script,
|
||||
);
|
||||
|
||||
if (scriptMethod?.script) {
|
||||
const scriptPath = `scripts/${scriptMethod.script}`;
|
||||
const scriptName = script.name;
|
||||
@@ -130,6 +188,19 @@ export function ScriptDetailModal({
|
||||
setTextViewerOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteScript = () => {
|
||||
if (!script) return;
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (!script) return;
|
||||
setDeleteConfirmOpen(false);
|
||||
setIsDeleting(true);
|
||||
setLoadMessage(null);
|
||||
deleteScriptMutation.mutate({ slug: script.slug });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm bg-black/50"
|
||||
@@ -165,6 +236,20 @@ export function ScriptDetailModal({
|
||||
{script.privileged && <PrivilegedBadge />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interface Port*/}
|
||||
{script.interface_port && (
|
||||
<div className="ml-3 sm:ml-4 flex-shrink-0">
|
||||
<div className="bg-primary/10 border border-primary/30 rounded-lg px-3 py-1.5 sm:px-4 sm:py-2">
|
||||
<span className="text-xs sm:text-sm font-medium text-muted-foreground mr-2">
|
||||
Port:
|
||||
</span>
|
||||
<span className="text-sm sm:text-base font-semibold text-foreground font-mono">
|
||||
{script.interface_port}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close Button */}
|
||||
@@ -359,6 +444,42 @@ export function ScriptDetailModal({
|
||||
);
|
||||
}
|
||||
})()}
|
||||
|
||||
{/* Delete Button - only show if script files exist */}
|
||||
{scriptFilesData?.success &&
|
||||
(scriptFilesData.ctExists || scriptFilesData.installExists) && (
|
||||
<Button
|
||||
onClick={handleDeleteScript}
|
||||
disabled={isDeleting}
|
||||
variant="destructive"
|
||||
size="default"
|
||||
className="w-full sm:w-auto flex items-center justify-center space-x-2"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-white"></div>
|
||||
<span>Deleting...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
<span>Delete Script</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -708,11 +829,22 @@ export function ScriptDetailModal({
|
||||
?.script?.split("/")
|
||||
.pop() ?? `${script.slug}.sh`
|
||||
}
|
||||
script={script}
|
||||
isOpen={textViewerOpen}
|
||||
onClose={() => setTextViewerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Version Selection Modal */}
|
||||
{script && (
|
||||
<ScriptVersionModal
|
||||
script={script}
|
||||
isOpen={versionModalOpen}
|
||||
onClose={() => setVersionModalOpen(false)}
|
||||
onSelectVersion={handleVersionSelect}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Execution Mode Modal */}
|
||||
{script && (
|
||||
<ExecutionModeModal
|
||||
@@ -722,6 +854,20 @@ export function ScriptDetailModal({
|
||||
onExecute={handleExecuteScript}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{script && (
|
||||
<ConfirmationModal
|
||||
isOpen={deleteConfirmOpen}
|
||||
onClose={() => setDeleteConfirmOpen(false)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Script"
|
||||
message={`Are you sure you want to delete all downloaded files for "${script.name}"? This action cannot be undone.`}
|
||||
variant="simple"
|
||||
confirmButtonText="Delete"
|
||||
cancelButtonText="Cancel"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
210
src/app/_components/ScriptVersionModal.tsx
Normal file
210
src/app/_components/ScriptVersionModal.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Script, ScriptInstallMethod } from '../../types/script';
|
||||
import { Button } from './ui/button';
|
||||
import { useRegisterModal } from './modal/ModalStackProvider';
|
||||
|
||||
interface ScriptVersionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelectVersion: (versionType: string) => void;
|
||||
script: Script | null;
|
||||
}
|
||||
|
||||
export function ScriptVersionModal({ isOpen, onClose, onSelectVersion, script }: ScriptVersionModalProps) {
|
||||
useRegisterModal(isOpen, { id: 'script-version-modal', allowEscape: true, onClose });
|
||||
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen || !script) return null;
|
||||
|
||||
// Get available install methods
|
||||
const installMethods = script.install_methods || [];
|
||||
const defaultMethod = installMethods.find(method => method.type === 'default');
|
||||
const alpineMethod = installMethods.find(method => method.type === 'alpine');
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedVersion) {
|
||||
onSelectVersion(selectedVersion);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleVersionSelect = (versionType: string) => {
|
||||
setSelectedVersion(versionType);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 backdrop-blur-sm bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-card rounded-lg shadow-xl max-w-2xl w-full border border-border">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-border">
|
||||
<h2 className="text-xl font-bold text-foreground">Select Version</h2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
||||
Choose a version for "{script.name}"
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select the version you want to install. Each version has different resource requirements.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Default Version */}
|
||||
{defaultMethod && (
|
||||
<div
|
||||
onClick={() => handleVersionSelect('default')}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
||||
selectedVersion === 'default'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border bg-card hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedVersion === 'default'
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
{selectedVersion === 'default' && (
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-foreground capitalize">
|
||||
{defaultMethod.type}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
|
||||
<div>
|
||||
<span className="text-muted-foreground">CPU: </span>
|
||||
<span className="text-foreground font-medium">{defaultMethod.resources.cpu} cores</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">RAM: </span>
|
||||
<span className="text-foreground font-medium">{defaultMethod.resources.ram} MB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">HDD: </span>
|
||||
<span className="text-foreground font-medium">{defaultMethod.resources.hdd} GB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">OS: </span>
|
||||
<span className="text-foreground font-medium">
|
||||
{defaultMethod.resources.os} {defaultMethod.resources.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alpine Version */}
|
||||
{alpineMethod && (
|
||||
<div
|
||||
onClick={() => handleVersionSelect('alpine')}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all ${
|
||||
selectedVersion === 'alpine'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border bg-card hover:border-primary/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div
|
||||
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center ${
|
||||
selectedVersion === 'alpine'
|
||||
? 'border-primary bg-primary'
|
||||
: 'border-border'
|
||||
}`}
|
||||
>
|
||||
{selectedVersion === 'alpine' && (
|
||||
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h4 className="text-base font-semibold text-foreground capitalize">
|
||||
{alpineMethod.type}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm ml-8">
|
||||
<div>
|
||||
<span className="text-muted-foreground">CPU: </span>
|
||||
<span className="text-foreground font-medium">{alpineMethod.resources.cpu} cores</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">RAM: </span>
|
||||
<span className="text-foreground font-medium">{alpineMethod.resources.ram} MB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">HDD: </span>
|
||||
<span className="text-foreground font-medium">{alpineMethod.resources.hdd} GB</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">OS: </span>
|
||||
<span className="text-foreground font-medium">
|
||||
{alpineMethod.resources.os} {alpineMethod.resources.version}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="outline"
|
||||
size="default"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedVersion}
|
||||
variant="default"
|
||||
size="default"
|
||||
className={!selectedVersion ? 'bg-muted-foreground cursor-not-allowed' : ''}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
});
|
||||
const [saveFiltersEnabled, setSaveFiltersEnabled] = useState(false);
|
||||
const [isLoadingFilters, setIsLoadingFilters] = useState(true);
|
||||
const [isNewestMinimized, setIsNewestMinimized] = useState(false);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: scriptCardsData, isLoading: githubLoading, error: githubError, refetch } = api.scripts.getScriptCardsWithCategories.useQuery();
|
||||
@@ -535,7 +536,30 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
};
|
||||
|
||||
const handleDownloadAllFiltered = () => {
|
||||
const slugsToDownload = filteredScripts.map(script => script.slug).filter(Boolean);
|
||||
|
||||
let scriptsToDownload: ScriptCardType[] = filteredScripts;
|
||||
|
||||
if (!hasActiveFilters) {
|
||||
|
||||
const scriptMap = new Map<string, ScriptCardType>();
|
||||
|
||||
filteredScripts.forEach(script => {
|
||||
if (script?.slug) {
|
||||
scriptMap.set(script.slug, script);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
newestScripts.forEach(script => {
|
||||
if (script?.slug && !scriptMap.has(script.slug)) {
|
||||
scriptMap.set(script.slug, script);
|
||||
}
|
||||
});
|
||||
|
||||
scriptsToDownload = Array.from(scriptMap.values());
|
||||
}
|
||||
|
||||
const slugsToDownload = scriptsToDownload.map(script => script.slug).filter(Boolean);
|
||||
if (slugsToDownload.length > 0) {
|
||||
void downloadScriptsIndividually(slugsToDownload);
|
||||
}
|
||||
@@ -666,8 +690,8 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
onViewModeChange={setViewMode}
|
||||
/>
|
||||
|
||||
{/* Newest Scripts Carousel - Always show when there are newest scripts */}
|
||||
{newestScripts.length > 0 && (
|
||||
{/* Newest Scripts Carousel - Only show when no search, filters, or category is active */}
|
||||
{newestScripts.length > 0 && !hasActiveFilters && !selectedCategory && (
|
||||
<div className="mb-8">
|
||||
<div className="bg-card border-l-4 border-l-primary border border-border rounded-lg p-6 shadow-lg">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -675,11 +699,35 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
<Clock className="h-6 w-6 text-primary" />
|
||||
Newest Scripts
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{newestScripts.length} recently added
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => setIsNewestMinimized(!isNewestMinimized)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-foreground"
|
||||
title={isNewestMinimized ? "Expand newest scripts" : "Minimize newest scripts"}
|
||||
>
|
||||
<svg
|
||||
className={`h-4 w-4 transition-transform ${isNewestMinimized ? "" : "rotate-180"}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 15l7-7 7 7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isNewestMinimized && (
|
||||
<div className="overflow-x-auto scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600 scrollbar-track-transparent">
|
||||
<div className="flex gap-4 pb-2" style={{ minWidth: 'max-content' }}>
|
||||
{newestScripts.map((script, index) => {
|
||||
@@ -708,6 +756,7 @@ export function ScriptsGrid({ onInstallScript }: ScriptsGridProps) {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -53,6 +53,50 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
void loadColorCodingSetting();
|
||||
}, []);
|
||||
|
||||
const validateServerAddress = (address: string): boolean => {
|
||||
const trimmed = address.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
// IPv4 validation
|
||||
const ipv4Regex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
if (ipv4Regex.test(trimmed)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// IPv6 validation (supports compressed format like ::1 and full format)
|
||||
// Matches: 2001:0db8:85a3:0000:0000:8a2e:0370:7334, ::1, 2001:db8::1, etc.
|
||||
// Also supports IPv4-mapped IPv6 addresses like ::ffff:192.168.1.1
|
||||
// Simplified validation: check for valid hex segments separated by colons
|
||||
const ipv6Pattern = /^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::1$|^::$|^(?:[0-9a-fA-F]{1,4}:)*::(?:[0-9a-fA-F]{1,4}:)*[0-9a-fA-F]{1,4}$|^(?:[0-9a-fA-F]{1,4}:)*::[0-9a-fA-F]{1,4}$|^::(?:[0-9a-fA-F]{1,4}:)+[0-9a-fA-F]{1,4}$|^::ffff:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$|^(?:[0-9a-fA-F]{1,4}:){1,4}:(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
if (ipv6Pattern.test(trimmed)) {
|
||||
// Additional validation: ensure only one :: compression exists
|
||||
const compressionCount = (trimmed.match(/::/g) || []).length;
|
||||
if (compressionCount <= 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// FQDN/hostname validation (RFC 1123 compliant)
|
||||
// Allows letters, numbers, hyphens, dots; must start and end with alphanumeric
|
||||
// Max length 253 characters, each label max 63 characters
|
||||
const hostnameRegex = /^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
|
||||
if (hostnameRegex.test(trimmed) && trimmed.length <= 253) {
|
||||
// Additional check: each label (between dots) must be max 63 chars
|
||||
const labels = trimmed.split('.');
|
||||
if (labels.every(label => label.length > 0 && label.length <= 63)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also allow simple hostnames without dots (like 'localhost')
|
||||
const simpleHostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?$/;
|
||||
if (simpleHostnameRegex.test(trimmed) && trimmed.length <= 63) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<Record<keyof CreateServerData, string>> = {};
|
||||
|
||||
@@ -61,12 +105,10 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
}
|
||||
|
||||
if (!formData.ip.trim()) {
|
||||
newErrors.ip = 'IP address is required';
|
||||
newErrors.ip = 'Server address is required';
|
||||
} else {
|
||||
// Basic IP validation
|
||||
const ipRegex = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
||||
if (!ipRegex.test(formData.ip)) {
|
||||
newErrors.ip = 'Please enter a valid IP address';
|
||||
if (!validateServerAddress(formData.ip)) {
|
||||
newErrors.ip = 'Please enter a valid IP address (IPv4/IPv6) or hostname';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +263,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
|
||||
<div>
|
||||
<label htmlFor="ip" className="block text-sm font-medium text-muted-foreground mb-1">
|
||||
IP Address *
|
||||
Host/IP Address *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -231,7 +273,7 @@ export function ServerForm({ onSubmit, initialData, isEditing = false, onCancel
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm bg-card text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring ${
|
||||
errors.ip ? 'border-destructive' : 'border-border'
|
||||
}`}
|
||||
placeholder="e.g., 192.168.1.100"
|
||||
placeholder="e.g., 192.168.1.100, server.example.com, or 2001:db8::1"
|
||||
/>
|
||||
{errors.ip && <p className="mt-1 text-sm text-destructive">{errors.ip}</p>}
|
||||
</div>
|
||||
|
||||
@@ -4,77 +4,156 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import { tomorrow } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import { Button } from './ui/button';
|
||||
import type { Script } from '../../types/script';
|
||||
|
||||
interface TextViewerProps {
|
||||
scriptName: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
script?: Script | null;
|
||||
}
|
||||
|
||||
interface ScriptContent {
|
||||
ctScript?: string;
|
||||
installScript?: string;
|
||||
alpineCtScript?: string;
|
||||
alpineInstallScript?: string;
|
||||
}
|
||||
|
||||
export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
export function TextViewer({ scriptName, isOpen, onClose, script }: TextViewerProps) {
|
||||
const [scriptContent, setScriptContent] = useState<ScriptContent>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'ct' | 'install'>('ct');
|
||||
const [selectedVersion, setSelectedVersion] = useState<'default' | 'alpine'>('default');
|
||||
|
||||
// Extract slug from script name (remove .sh extension)
|
||||
const slug = scriptName.replace(/\.sh$/, '');
|
||||
const slug = scriptName.replace(/\.sh$/, '').replace(/^alpine-/, '');
|
||||
|
||||
// Check if alpine variant exists
|
||||
const hasAlpineVariant = script?.install_methods?.some(
|
||||
method => method.type === 'alpine' && method.script?.startsWith('ct/')
|
||||
);
|
||||
|
||||
// Get script names for default and alpine versions
|
||||
const defaultScriptName = scriptName.replace(/^alpine-/, '');
|
||||
const alpineScriptName = scriptName.startsWith('alpine-') ? scriptName : `alpine-${scriptName}`;
|
||||
|
||||
const loadScriptContent = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Try to load from different possible locations
|
||||
const [ctResponse, toolsResponse, vmResponse, vwResponse, installResponse] = await Promise.allSettled([
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${scriptName}` } }))}`),
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${scriptName}` } }))}`),
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${scriptName}` } }))}`),
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${scriptName}` } }))}`),
|
||||
// Build fetch requests for default version
|
||||
const requests: Promise<Response>[] = [];
|
||||
|
||||
// Default CT script
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
|
||||
// Tools, VM, VW scripts
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `tools/pve/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vm/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `vw/${defaultScriptName}` } }))}`)
|
||||
);
|
||||
|
||||
// Default install script
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/${slug}-install.sh` } }))}`)
|
||||
]);
|
||||
);
|
||||
|
||||
// Alpine versions if variant exists
|
||||
if (hasAlpineVariant) {
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `ct/${alpineScriptName}` } }))}`)
|
||||
);
|
||||
requests.push(
|
||||
fetch(`/api/trpc/scripts.getScriptContent?input=${encodeURIComponent(JSON.stringify({ json: { path: `install/alpine-${slug}-install.sh` } }))}`)
|
||||
);
|
||||
}
|
||||
|
||||
const responses = await Promise.allSettled(requests);
|
||||
|
||||
const content: ScriptContent = {};
|
||||
let responseIndex = 0;
|
||||
|
||||
if (ctResponse.status === 'fulfilled' && ctResponse.value.ok) {
|
||||
// Default CT script
|
||||
const ctResponse = responses[responseIndex];
|
||||
if (ctResponse?.status === 'fulfilled' && ctResponse.value.ok) {
|
||||
const ctData = await ctResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (ctData.result?.data?.json?.success) {
|
||||
content.ctScript = ctData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolsResponse.status === 'fulfilled' && toolsResponse.value.ok) {
|
||||
responseIndex++;
|
||||
// Tools script
|
||||
const toolsResponse = responses[responseIndex];
|
||||
if (toolsResponse?.status === 'fulfilled' && toolsResponse.value.ok) {
|
||||
const toolsData = await toolsResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (toolsData.result?.data?.json?.success) {
|
||||
content.ctScript = toolsData.result.data.json.content; // Use ctScript field for tools scripts too
|
||||
}
|
||||
}
|
||||
|
||||
if (vmResponse.status === 'fulfilled' && vmResponse.value.ok) {
|
||||
responseIndex++;
|
||||
// VM script
|
||||
const vmResponse = responses[responseIndex];
|
||||
if (vmResponse?.status === 'fulfilled' && vmResponse.value.ok) {
|
||||
const vmData = await vmResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (vmData.result?.data?.json?.success) {
|
||||
content.ctScript = vmData.result.data.json.content; // Use ctScript field for VM scripts too
|
||||
}
|
||||
}
|
||||
|
||||
if (vwResponse.status === 'fulfilled' && vwResponse.value.ok) {
|
||||
responseIndex++;
|
||||
// VW script
|
||||
const vwResponse = responses[responseIndex];
|
||||
if (vwResponse?.status === 'fulfilled' && vwResponse.value.ok) {
|
||||
const vwData = await vwResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (vwData.result?.data?.json?.success) {
|
||||
content.ctScript = vwData.result.data.json.content; // Use ctScript field for VW scripts too
|
||||
}
|
||||
}
|
||||
|
||||
if (installResponse.status === 'fulfilled' && installResponse.value.ok) {
|
||||
responseIndex++;
|
||||
// Default install script
|
||||
const installResponse = responses[responseIndex];
|
||||
if (installResponse?.status === 'fulfilled' && installResponse.value.ok) {
|
||||
const installData = await installResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (installData.result?.data?.json?.success) {
|
||||
content.installScript = installData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
responseIndex++;
|
||||
// Alpine CT script
|
||||
if (hasAlpineVariant) {
|
||||
const alpineCtResponse = responses[responseIndex];
|
||||
if (alpineCtResponse?.status === 'fulfilled' && alpineCtResponse.value.ok) {
|
||||
const alpineCtData = await alpineCtResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (alpineCtData.result?.data?.json?.success) {
|
||||
content.alpineCtScript = alpineCtData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
responseIndex++;
|
||||
}
|
||||
|
||||
// Alpine install script
|
||||
if (hasAlpineVariant) {
|
||||
const alpineInstallResponse = responses[responseIndex];
|
||||
if (alpineInstallResponse?.status === 'fulfilled' && alpineInstallResponse.value.ok) {
|
||||
const alpineInstallData = await alpineInstallResponse.value.json() as { result?: { data?: { json?: { success?: boolean; content?: string } } } };
|
||||
if (alpineInstallData.result?.data?.json?.success) {
|
||||
content.alpineInstallScript = alpineInstallData.result.data.json.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setScriptContent(content);
|
||||
} catch (err) {
|
||||
@@ -82,7 +161,7 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [scriptName, slug]);
|
||||
}, [defaultScriptName, alpineScriptName, slug, hasAlpineVariant]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && scriptName) {
|
||||
@@ -106,11 +185,30 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
<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 */}
|
||||
<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 flex-1">
|
||||
<h2 className="text-2xl font-bold text-foreground">
|
||||
Script Viewer: {scriptName}
|
||||
Script Viewer: {defaultScriptName}
|
||||
</h2>
|
||||
{scriptContent.ctScript && scriptContent.installScript && (
|
||||
{hasAlpineVariant && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={selectedVersion === 'default' ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedVersion('default')}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Default
|
||||
</Button>
|
||||
<Button
|
||||
variant={selectedVersion === 'alpine' ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedVersion('alpine')}
|
||||
className="px-3 py-1 text-sm"
|
||||
>
|
||||
Alpine
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{((selectedVersion === 'default' && (scriptContent.ctScript || scriptContent.installScript)) ||
|
||||
(selectedVersion === 'alpine' && (scriptContent.alpineCtScript || scriptContent.alpineInstallScript))) && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant={activeTab === 'ct' ? 'outline' : 'ghost'}
|
||||
@@ -151,7 +249,8 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto">
|
||||
{activeTab === 'ct' && scriptContent.ctScript ? (
|
||||
{activeTab === 'ct' && (
|
||||
selectedVersion === 'default' && scriptContent.ctScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
@@ -167,7 +266,32 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
>
|
||||
{scriptContent.ctScript}
|
||||
</SyntaxHighlighter>
|
||||
) : activeTab === 'install' && scriptContent.installScript ? (
|
||||
) : selectedVersion === 'alpine' && scriptContent.alpineCtScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.alpineCtScript}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">
|
||||
{selectedVersion === 'default' ? 'Default CT script not found' : 'Alpine CT script not found'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{activeTab === 'install' && (
|
||||
selectedVersion === 'default' && scriptContent.installScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
@@ -183,12 +307,29 @@ export function TextViewer({ scriptName, isOpen, onClose }: TextViewerProps) {
|
||||
>
|
||||
{scriptContent.installScript}
|
||||
</SyntaxHighlighter>
|
||||
) : selectedVersion === 'alpine' && scriptContent.alpineInstallScript ? (
|
||||
<SyntaxHighlighter
|
||||
language="bash"
|
||||
style={tomorrow}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: '1rem',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.5',
|
||||
minHeight: '100%'
|
||||
}}
|
||||
showLineNumbers={true}
|
||||
wrapLines={true}
|
||||
>
|
||||
{scriptContent.alpineInstallScript}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-lg text-muted-foreground">
|
||||
{activeTab === 'ct' ? 'CT script not found' : 'Install script not found'}
|
||||
{selectedVersion === 'default' ? 'Default install script not found' : 'Alpine install script not found'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -38,7 +38,8 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const token = generateToken(username);
|
||||
const sessionDurationDays = authConfig.sessionDurationDays;
|
||||
const token = generateToken(username, sessionDurationDays);
|
||||
|
||||
const response = NextResponse.json({
|
||||
success: true,
|
||||
@@ -46,12 +47,15 @@ export async function POST(request: NextRequest) {
|
||||
username
|
||||
});
|
||||
|
||||
// Set httpOnly cookie
|
||||
// Determine if request is over HTTPS
|
||||
const isSecure = request.url.startsWith('https://');
|
||||
|
||||
// Set httpOnly cookie with configured duration
|
||||
response.cookies.set('auth-token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
secure: isSecure, // Only secure if actually over HTTPS
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
maxAge: sessionDurationDays * 24 * 60 * 60, // Use configured duration
|
||||
path: '/',
|
||||
});
|
||||
|
||||
|
||||
@@ -22,10 +22,17 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate expiration time in milliseconds
|
||||
const expirationTime = decoded.exp ? decoded.exp * 1000 : null;
|
||||
const currentTime = Date.now();
|
||||
const timeUntilExpiration = expirationTime ? expirationTime - currentTime : null;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
username: decoded.username,
|
||||
authenticated: true
|
||||
authenticated: true,
|
||||
expirationTime,
|
||||
timeUntilExpiration
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error verifying token:', error);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled } from '~/lib/auth';
|
||||
import { getAuthConfig, updateAuthCredentials, updateAuthEnabled, updateSessionDuration } from '~/lib/auth';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { withApiLogging } from '../../../../server/logging/withApiLogging';
|
||||
@@ -14,6 +14,7 @@ export const GET = withApiLogging(async function GET() {
|
||||
enabled: authConfig.enabled,
|
||||
hasCredentials: authConfig.hasCredentials,
|
||||
setupCompleted: authConfig.setupCompleted,
|
||||
sessionDurationDays: authConfig.sessionDurationDays,
|
||||
});
|
||||
} catch {
|
||||
// Error handled by withApiLogging
|
||||
@@ -66,7 +67,10 @@ export const POST = withApiLogging(async function POST(request: NextRequest) {
|
||||
|
||||
export const PATCH = withApiLogging(async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const { enabled } = await request.json() as { enabled: boolean };
|
||||
const body = await request.json() as { enabled?: boolean; sessionDurationDays?: number };
|
||||
|
||||
if (body.enabled !== undefined) {
|
||||
const { enabled } = body;
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
return NextResponse.json(
|
||||
@@ -108,6 +112,30 @@ export const PATCH = withApiLogging(async function PATCH(request: NextRequest) {
|
||||
success: true,
|
||||
message: `Authentication ${enabled ? 'enabled' : 'disabled'} successfully`
|
||||
});
|
||||
}
|
||||
|
||||
if (body.sessionDurationDays !== undefined) {
|
||||
const { sessionDurationDays } = body;
|
||||
|
||||
if (typeof sessionDurationDays !== 'number' || sessionDurationDays < 1 || sessionDurationDays > 365) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Session duration must be a number between 1 and 365 days' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
updateSessionDuration(sessionDurationDays);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Session duration updated to ${sessionDurationDays} days`
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'No valid field to update' },
|
||||
{ status: 400 }
|
||||
);
|
||||
} catch {
|
||||
// Error handled by withApiLogging
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -16,10 +16,12 @@ import { Button } from './_components/ui/button';
|
||||
import { ContextualHelpIcon } from './_components/ContextualHelpIcon';
|
||||
import { ReleaseNotesModal, getLastSeenVersion } from './_components/ReleaseNotesModal';
|
||||
import { Footer } from './_components/Footer';
|
||||
import { Package, HardDrive, FolderOpen } from 'lucide-react';
|
||||
import { Package, HardDrive, FolderOpen, LogOut } from 'lucide-react';
|
||||
import { api } from '~/trpc/react';
|
||||
import { useAuth } from './_components/AuthProvider';
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated, logout } = useAuth();
|
||||
const [runningScript, setRunningScript] = useState<{ path: string; name: string; mode?: 'local' | 'ssh'; server?: any } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'scripts' | 'downloaded' | 'installed'>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -152,7 +154,19 @@ export default function Home() {
|
||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold text-foreground flex items-center justify-center gap-2 sm:gap-3 flex-1">
|
||||
<span className="break-words">PVE Scripts Management</span>
|
||||
</h1>
|
||||
<div className="flex-1 flex justify-end">
|
||||
<div className="flex-1 flex justify-end items-center gap-2">
|
||||
{isAuthenticated && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={logout}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Logout"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
const JWT_EXPIRY = '7d'; // 7 days
|
||||
const DEFAULT_JWT_EXPIRY_DAYS = 7; // Default 7 days
|
||||
|
||||
// Cache for JWT secret to avoid multiple file reads
|
||||
let jwtSecretCache: string | null = null;
|
||||
@@ -66,18 +66,31 @@ export async function comparePassword(password: string, hash: string): Promise<b
|
||||
/**
|
||||
* Generate a JWT token
|
||||
*/
|
||||
export function generateToken(username: string): string {
|
||||
export function generateToken(username: string, durationDays?: number): string {
|
||||
const secret = getJwtSecret();
|
||||
return jwt.sign({ username }, secret, { expiresIn: JWT_EXPIRY });
|
||||
const days = durationDays ?? DEFAULT_JWT_EXPIRY_DAYS;
|
||||
return jwt.sign({ username }, secret, { expiresIn: `${days}d` });
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a JWT token without verification (for extracting expiration time)
|
||||
*/
|
||||
export function decodeToken(token: string): { username: string; exp?: number; iat?: number } | null {
|
||||
try {
|
||||
const decoded = jwt.decode(token) as { username: string; exp?: number; iat?: number } | null;
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT token
|
||||
*/
|
||||
export function verifyToken(token: string): { username: string } | null {
|
||||
export function verifyToken(token: string): { username: string; exp?: number; iat?: number } | null {
|
||||
try {
|
||||
const secret = getJwtSecret();
|
||||
const decoded = jwt.verify(token, secret) as { username: string };
|
||||
const decoded = jwt.verify(token, secret) as { username: string; exp?: number; iat?: number };
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
@@ -93,6 +106,7 @@ export function getAuthConfig(): {
|
||||
enabled: boolean;
|
||||
hasCredentials: boolean;
|
||||
setupCompleted: boolean;
|
||||
sessionDurationDays: number;
|
||||
} {
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
@@ -103,6 +117,7 @@ export function getAuthConfig(): {
|
||||
enabled: false,
|
||||
hasCredentials: false,
|
||||
setupCompleted: false,
|
||||
sessionDurationDays: DEFAULT_JWT_EXPIRY_DAYS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +143,13 @@ export function getAuthConfig(): {
|
||||
const setupCompletedMatch = setupCompletedRegex.exec(envContent);
|
||||
const setupCompleted = setupCompletedMatch ? setupCompletedMatch[1]?.trim().toLowerCase() === 'true' : false;
|
||||
|
||||
// Extract AUTH_SESSION_DURATION_DAYS
|
||||
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=(.*)$/m;
|
||||
const sessionDurationMatch = sessionDurationRegex.exec(envContent);
|
||||
const sessionDurationDays = sessionDurationMatch
|
||||
? parseInt(sessionDurationMatch[1]?.trim() || String(DEFAULT_JWT_EXPIRY_DAYS), 10) || DEFAULT_JWT_EXPIRY_DAYS
|
||||
: DEFAULT_JWT_EXPIRY_DAYS;
|
||||
|
||||
const hasCredentials = !!(username && passwordHash);
|
||||
|
||||
return {
|
||||
@@ -136,6 +158,7 @@ export function getAuthConfig(): {
|
||||
enabled,
|
||||
hasCredentials,
|
||||
setupCompleted,
|
||||
sessionDurationDays,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -238,3 +261,30 @@ export function updateAuthEnabled(enabled: boolean): void {
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update AUTH_SESSION_DURATION_DAYS in .env
|
||||
*/
|
||||
export function updateSessionDuration(days: number): void {
|
||||
// Validate: between 1 and 365 days
|
||||
const validDays = Math.max(1, Math.min(365, Math.floor(days)));
|
||||
|
||||
const envPath = path.join(process.cwd(), '.env');
|
||||
|
||||
// Read existing .env file
|
||||
let envContent = '';
|
||||
if (fs.existsSync(envPath)) {
|
||||
envContent = fs.readFileSync(envPath, 'utf8');
|
||||
}
|
||||
|
||||
// Update or add AUTH_SESSION_DURATION_DAYS
|
||||
const sessionDurationRegex = /^AUTH_SESSION_DURATION_DAYS=.*$/m;
|
||||
if (sessionDurationRegex.test(envContent)) {
|
||||
envContent = envContent.replace(sessionDurationRegex, `AUTH_SESSION_DURATION_DAYS=${validDays}`);
|
||||
} else {
|
||||
envContent += (envContent.endsWith('\n') ? '' : '\n') + `AUTH_SESSION_DURATION_DAYS=${validDays}\n`;
|
||||
}
|
||||
|
||||
// Write back to .env file
|
||||
fs.writeFileSync(envPath, envContent);
|
||||
}
|
||||
|
||||
|
||||
@@ -887,29 +887,96 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
);
|
||||
|
||||
|
||||
// Group scripts by server to batch check containers
|
||||
const scriptsByServer = new Map<number, any[]>();
|
||||
for (const script of scriptsToCheck) {
|
||||
try {
|
||||
const scriptData = script as any;
|
||||
const server = allServers.find((s: any) => s.id === scriptData.server_id);
|
||||
if (!scriptData.server_id) continue;
|
||||
|
||||
if (!scriptsByServer.has(scriptData.server_id)) {
|
||||
scriptsByServer.set(scriptData.server_id, []);
|
||||
}
|
||||
scriptsByServer.get(scriptData.server_id)!.push(scriptData);
|
||||
}
|
||||
|
||||
// Process each server
|
||||
for (const [serverId, serverScripts] of scriptsByServer.entries()) {
|
||||
try {
|
||||
const server = allServers.find((s: any) => s.id === serverId);
|
||||
if (!server) {
|
||||
// Server doesn't exist, delete all scripts for this server
|
||||
for (const scriptData of serverScripts) {
|
||||
await db.deleteInstalledScript(Number(scriptData.id));
|
||||
deletedScripts.push(String(scriptData.script_name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Test SSH connection
|
||||
|
||||
const connectionTest = await sshService.testSSHConnection(server as Server);
|
||||
if (!(connectionTest as any).success) {
|
||||
console.warn(`cleanupOrphanedScripts: SSH connection failed for server ${String((server as any).name)}, skipping ${serverScripts.length} scripts`);
|
||||
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"`;
|
||||
// Get all existing containers from pct list (more reliable than checking config files)
|
||||
const listCommand = 'pct list';
|
||||
let listOutput = '';
|
||||
|
||||
// Await full command completion to avoid early false negatives
|
||||
const containerExists = await new Promise<boolean>((resolve) => {
|
||||
const existingContainerIds = await new Promise<Set<string>>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
console.warn(`cleanupOrphanedScripts: timeout while getting container list from server ${String((server as any).name)}`);
|
||||
resolve(new Set()); // Treat timeout as no containers found
|
||||
}, 20000);
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
listCommand,
|
||||
(data: string) => {
|
||||
listOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
console.error(`cleanupOrphanedScripts: error getting container list from server ${String((server as any).name)}:`, error);
|
||||
clearTimeout(timeout);
|
||||
resolve(new Set()); // Treat error as no containers found
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Parse pct list output to extract container IDs
|
||||
const containerIds = new Set<string>();
|
||||
const lines = listOutput.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
// pct list format: CTID Status Name
|
||||
// Skip header line if present
|
||||
if (line.includes('CTID') || line.includes('VMID')) continue;
|
||||
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length > 0) {
|
||||
const containerId = parts[0]?.trim();
|
||||
if (containerId && /^\d{3,4}$/.test(containerId)) {
|
||||
containerIds.add(containerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolve(containerIds);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Check each script against the list of existing containers
|
||||
for (const scriptData of serverScripts) {
|
||||
try {
|
||||
const containerId = String(scriptData.container_id).trim();
|
||||
|
||||
// Check if container exists in pct list
|
||||
if (!existingContainerIds.has(containerId)) {
|
||||
// Also verify config file doesn't exist as a double-check
|
||||
const checkCommand = `test -f "/etc/pve/lxc/${containerId}.conf" && echo "exists" || echo "not_found"`;
|
||||
|
||||
const configExists = await new Promise<boolean>((resolve) => {
|
||||
let combinedOutput = '';
|
||||
let resolved = false;
|
||||
|
||||
@@ -917,22 +984,12 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
const out = combinedOutput.trim();
|
||||
if (out.includes('exists')) {
|
||||
resolve(true);
|
||||
} else if (out.includes('not_found')) {
|
||||
resolve(false);
|
||||
} else {
|
||||
// Unknown output; treat as not found but log for diagnostics
|
||||
console.warn(`cleanupOrphanedScripts: unexpected output for ${String(scriptData.script_name)} (${String(scriptData.container_id)}): ${out}`);
|
||||
resolve(false);
|
||||
}
|
||||
resolve(out.includes('exists'));
|
||||
};
|
||||
|
||||
// Add a guard timeout so we don't hang indefinitely
|
||||
const timer = setTimeout(() => {
|
||||
console.warn(`cleanupOrphanedScripts: timeout while checking ${String(scriptData.script_name)} on server ${String((server as any).name)}`);
|
||||
finish();
|
||||
}, 15000);
|
||||
}, 10000);
|
||||
|
||||
void sshExecutionService.executeCommand(
|
||||
server as Server,
|
||||
@@ -940,8 +997,8 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
(data: string) => {
|
||||
combinedOutput += data;
|
||||
},
|
||||
(error: string) => {
|
||||
combinedOutput += error;
|
||||
(_error: string) => {
|
||||
// Ignore errors, just check output
|
||||
},
|
||||
(_exitCode: number) => {
|
||||
clearTimeout(timer);
|
||||
@@ -950,14 +1007,22 @@ export const installedScriptsRouter = createTRPCRouter({
|
||||
);
|
||||
});
|
||||
|
||||
if (!containerExists) {
|
||||
// If container is not in pct list AND config file doesn't exist, it's orphaned
|
||||
if (!configExists) {
|
||||
console.log(`cleanupOrphanedScripts: Removing orphaned script ${String(scriptData.script_name)} (container ${containerId}) from server ${String((server as any).name)}`);
|
||||
await db.deleteInstalledScript(Number(scriptData.id));
|
||||
deletedScripts.push(String(scriptData.script_name));
|
||||
} else {
|
||||
// Config exists but not in pct list - might be in a transitional state, log but don't delete
|
||||
console.warn(`cleanupOrphanedScripts: Container ${containerId} (${String(scriptData.script_name)}) config exists but not in pct list - may be in transitional state`);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Error checking script ${(script as any).script_name}:`, error);
|
||||
console.error(`cleanupOrphanedScripts: Error checking script ${String((scriptData as any).script_name)}:`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`cleanupOrphanedScripts: Error processing server ${serverId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -363,6 +363,34 @@ export const scriptsRouter = createTRPCRouter({
|
||||
}
|
||||
}),
|
||||
|
||||
// Delete script files
|
||||
deleteScript: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
// Get the script details
|
||||
const script = await localScriptsService.getScriptBySlug(input.slug);
|
||||
if (!script) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Script not found',
|
||||
deletedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
// Delete the script files
|
||||
const result = await scriptDownloaderService.deleteScript(script);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error in deleteScript:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to delete script',
|
||||
deletedFiles: []
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
// Compare local and remote script content
|
||||
compareScriptContent: publicProcedure
|
||||
.input(z.object({ slug: z.string() }))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Real JavaScript implementation for script downloading
|
||||
import { join } from 'path';
|
||||
import { writeFile, mkdir, access } from 'fs/promises';
|
||||
import { writeFile, mkdir, access, readFile, unlink } from 'fs/promises';
|
||||
|
||||
export class ScriptDownloaderService {
|
||||
constructor() {
|
||||
@@ -112,16 +112,6 @@ export class ScriptDownloaderService {
|
||||
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
|
||||
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
} else if (scriptPath.startsWith('vw/')) {
|
||||
targetDir = 'vw';
|
||||
// Preserve subdirectory structure for VW scripts
|
||||
const subPath = scriptPath.replace('vw/', '');
|
||||
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
|
||||
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
|
||||
// Ensure the subdirectory exists
|
||||
await this.ensureDirectoryExists(join(this.scriptsDirectory, finalTargetDir));
|
||||
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
|
||||
await writeFile(filePath, content, 'utf-8');
|
||||
} else {
|
||||
// Handle other script types (fallback to ct directory)
|
||||
targetDir = 'ct';
|
||||
@@ -155,6 +145,35 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
// Download alpine install script if alpine variant exists (only for CT scripts)
|
||||
const hasAlpineCtVariant = script.install_methods?.some(
|
||||
method => method.type === 'alpine' && method.script?.startsWith('ct/')
|
||||
);
|
||||
console.log(`[${script.slug}] Checking for alpine variant:`, {
|
||||
hasAlpineCtVariant,
|
||||
installMethods: script.install_methods?.map(m => ({ type: m.type, script: m.script }))
|
||||
});
|
||||
|
||||
if (hasAlpineCtVariant) {
|
||||
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
|
||||
try {
|
||||
console.log(`[${script.slug}] Downloading alpine install script: install/${alpineInstallScriptName}`);
|
||||
const alpineInstallContent = await this.downloadFileFromGitHub(`install/${alpineInstallScriptName}`);
|
||||
const localAlpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
|
||||
await writeFile(localAlpineInstallPath, alpineInstallContent, 'utf-8');
|
||||
files.push(`install/${alpineInstallScriptName}`);
|
||||
console.log(`[${script.slug}] Successfully downloaded: install/${alpineInstallScriptName}`);
|
||||
} catch (error) {
|
||||
// Alpine install script might not exist, that's okay
|
||||
console.error(`[${script.slug}] Alpine install script not found or error: install/${alpineInstallScriptName}`, error);
|
||||
if (error instanceof Error) {
|
||||
console.error(`[${script.slug}] Error details:`, error.message, error.stack);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[${script.slug}] No alpine CT variant found, skipping alpine install script download`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully loaded ${files.length} script(s) for ${script.name}`,
|
||||
@@ -201,12 +220,6 @@ export class ScriptDownloaderService {
|
||||
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
|
||||
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
|
||||
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
|
||||
} else if (scriptPath.startsWith('vw/')) {
|
||||
targetDir = 'vw';
|
||||
const subPath = scriptPath.replace('vw/', '');
|
||||
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
|
||||
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
|
||||
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
|
||||
} else {
|
||||
targetDir = 'ct';
|
||||
finalTargetDir = targetDir;
|
||||
@@ -244,23 +257,39 @@ export class ScriptDownloaderService {
|
||||
|
||||
if (fileName) {
|
||||
let targetDir;
|
||||
let finalTargetDir;
|
||||
let filePath;
|
||||
|
||||
if (scriptPath.startsWith('ct/')) {
|
||||
targetDir = 'ct';
|
||||
finalTargetDir = targetDir;
|
||||
filePath = join(this.scriptsDirectory, targetDir, fileName);
|
||||
} else if (scriptPath.startsWith('tools/')) {
|
||||
targetDir = 'tools';
|
||||
// Preserve subdirectory structure for tools scripts
|
||||
const subPath = scriptPath.replace('tools/', '');
|
||||
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
|
||||
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
|
||||
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
|
||||
} else if (scriptPath.startsWith('vm/')) {
|
||||
targetDir = 'vm';
|
||||
// Preserve subdirectory structure for VM scripts
|
||||
const subPath = scriptPath.replace('vm/', '');
|
||||
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
|
||||
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
|
||||
filePath = join(this.scriptsDirectory, finalTargetDir, fileName);
|
||||
} else {
|
||||
targetDir = 'ct'; // Default fallback
|
||||
finalTargetDir = targetDir;
|
||||
filePath = join(this.scriptsDirectory, targetDir, fileName);
|
||||
}
|
||||
|
||||
const filePath = join(this.scriptsDirectory, targetDir, fileName);
|
||||
|
||||
try {
|
||||
await access(filePath);
|
||||
files.push(`${targetDir}/${fileName}`);
|
||||
files.push(`${finalTargetDir}/${fileName}`);
|
||||
|
||||
if (scriptPath.startsWith('ct/')) {
|
||||
// Set ctExists for all script types (CT, tools, vm) for UI consistency
|
||||
if (scriptPath.startsWith('ct/') || scriptPath.startsWith('tools/') || scriptPath.startsWith('vm/')) {
|
||||
ctExists = true;
|
||||
}
|
||||
} catch {
|
||||
@@ -286,12 +315,230 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
// Check alpine install script if alpine variant exists (only for CT scripts)
|
||||
const hasAlpineCtVariant = script.install_methods?.some(
|
||||
method => method.type === 'alpine' && method.script?.startsWith('ct/')
|
||||
);
|
||||
if (hasAlpineCtVariant) {
|
||||
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
|
||||
const alpineInstallPath = join(this.scriptsDirectory, 'install', alpineInstallScriptName);
|
||||
|
||||
try {
|
||||
await access(alpineInstallPath);
|
||||
files.push(`install/${alpineInstallScriptName}`);
|
||||
installExists = true; // Mark as exists if alpine install script exists
|
||||
} catch {
|
||||
// File doesn't exist
|
||||
}
|
||||
}
|
||||
|
||||
return { ctExists, installExists, files };
|
||||
} catch (error) {
|
||||
console.error('Error checking script existence:', error);
|
||||
return { ctExists: false, installExists: false, files: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async deleteScript(script) {
|
||||
this.initializeConfig();
|
||||
const deletedFiles = [];
|
||||
|
||||
try {
|
||||
// Get the list of files that exist for this script
|
||||
const fileCheck = await this.checkScriptExists(script);
|
||||
|
||||
if (fileCheck.files.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No script files found to delete',
|
||||
deletedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
// Delete all files
|
||||
for (const filePath of fileCheck.files) {
|
||||
try {
|
||||
const fullPath = join(this.scriptsDirectory, filePath);
|
||||
await unlink(fullPath);
|
||||
deletedFiles.push(filePath);
|
||||
} catch (error) {
|
||||
// Log error but continue deleting other files
|
||||
console.error(`Error deleting file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedFiles.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to delete any script files',
|
||||
deletedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully deleted ${deletedFiles.length} file(s) for ${script.name}`,
|
||||
deletedFiles
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error deleting script:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to delete script',
|
||||
deletedFiles
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async compareScriptContent(script) {
|
||||
this.initializeConfig();
|
||||
const differences = [];
|
||||
let hasDifferences = false;
|
||||
|
||||
try {
|
||||
// First check if any local files exist
|
||||
const localFilesExist = await this.checkScriptExists(script);
|
||||
if (!localFilesExist.ctExists && !localFilesExist.installExists) {
|
||||
// No local files exist, so no comparison needed
|
||||
return { hasDifferences: false, differences: [] };
|
||||
}
|
||||
|
||||
// If we have local files, proceed with comparison
|
||||
// Use Promise.all to run comparisons in parallel
|
||||
const comparisonPromises = [];
|
||||
|
||||
// Compare scripts only if they exist locally
|
||||
if (localFilesExist.ctExists && script.install_methods?.length) {
|
||||
for (const method of script.install_methods) {
|
||||
if (method.script) {
|
||||
const scriptPath = method.script;
|
||||
const fileName = scriptPath.split('/').pop();
|
||||
|
||||
if (fileName) {
|
||||
let targetDir;
|
||||
let finalTargetDir;
|
||||
|
||||
if (scriptPath.startsWith('ct/')) {
|
||||
targetDir = 'ct';
|
||||
finalTargetDir = targetDir;
|
||||
} else if (scriptPath.startsWith('tools/')) {
|
||||
targetDir = 'tools';
|
||||
// Preserve subdirectory structure for tools scripts
|
||||
const subPath = scriptPath.replace('tools/', '');
|
||||
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
|
||||
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
|
||||
} else if (scriptPath.startsWith('vm/')) {
|
||||
targetDir = 'vm';
|
||||
// Preserve subdirectory structure for VM scripts
|
||||
const subPath = scriptPath.replace('vm/', '');
|
||||
const subDir = subPath.includes('/') ? subPath.substring(0, subPath.lastIndexOf('/')) : '';
|
||||
finalTargetDir = subDir ? join(targetDir, subDir) : targetDir;
|
||||
} else {
|
||||
continue; // Skip unknown script types
|
||||
}
|
||||
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(scriptPath, `${finalTargetDir}/${fileName}`)
|
||||
.then(result => {
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
differences.push(result.filePath);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't add to differences if there's an error reading files
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compare install script only if it exists locally
|
||||
if (localFilesExist.installExists) {
|
||||
const installScriptName = `${script.slug}-install.sh`;
|
||||
const installScriptPath = `install/${installScriptName}`;
|
||||
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(installScriptPath, installScriptPath)
|
||||
.then(result => {
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
differences.push(result.filePath);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't add to differences if there's an error reading files
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Compare alpine install script if alpine variant exists (only for CT scripts)
|
||||
const hasAlpineCtVariant = script.install_methods?.some(
|
||||
method => method.type === 'alpine' && method.script?.startsWith('ct/')
|
||||
);
|
||||
if (hasAlpineCtVariant) {
|
||||
const alpineInstallScriptName = `alpine-${script.slug}-install.sh`;
|
||||
const alpineInstallScriptPath = `install/${alpineInstallScriptName}`;
|
||||
const localAlpineInstallPath = join(this.scriptsDirectory, alpineInstallScriptPath);
|
||||
|
||||
// Check if alpine install script exists locally
|
||||
try {
|
||||
await access(localAlpineInstallPath);
|
||||
comparisonPromises.push(
|
||||
this.compareSingleFile(alpineInstallScriptPath, alpineInstallScriptPath)
|
||||
.then(result => {
|
||||
if (result.hasDifferences) {
|
||||
hasDifferences = true;
|
||||
differences.push(result.filePath);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Don't add to differences if there's an error reading files
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// Alpine install script doesn't exist locally, skip comparison
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all comparisons to complete
|
||||
await Promise.all(comparisonPromises);
|
||||
|
||||
return { hasDifferences, differences };
|
||||
} catch (error) {
|
||||
console.error('Error comparing script content:', error);
|
||||
return { hasDifferences: false, differences: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async compareSingleFile(remotePath, filePath) {
|
||||
try {
|
||||
const localPath = join(this.scriptsDirectory, filePath);
|
||||
|
||||
// Read local content
|
||||
const localContent = await readFile(localPath, 'utf-8');
|
||||
|
||||
// Download remote content
|
||||
const remoteContent = await this.downloadFileFromGitHub(remotePath);
|
||||
|
||||
// Apply modification only for CT scripts, not for other script types
|
||||
let modifiedRemoteContent;
|
||||
if (remotePath.startsWith('ct/')) {
|
||||
modifiedRemoteContent = this.modifyScriptContent(remoteContent);
|
||||
} else {
|
||||
modifiedRemoteContent = remoteContent; // Don't modify tools or vm scripts
|
||||
}
|
||||
|
||||
// Compare content
|
||||
const hasDifferences = localContent !== modifiedRemoteContent;
|
||||
|
||||
return { hasDifferences, filePath };
|
||||
} catch (error) {
|
||||
console.error(`Error comparing file ${filePath}:`, error);
|
||||
return { hasDifferences: false, filePath };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const scriptDownloaderService = new ScriptDownloaderService();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFile, writeFile, mkdir } from 'fs/promises';
|
||||
import { readFile, writeFile, mkdir, unlink } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
import { env } from '~/env.js';
|
||||
import type { Script } from '~/types/script';
|
||||
@@ -461,6 +461,57 @@ export class ScriptDownloaderService {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteScript(script: Script): Promise<{ success: boolean; message: string; deletedFiles: string[] }> {
|
||||
this.initializeConfig();
|
||||
const deletedFiles: string[] = [];
|
||||
|
||||
try {
|
||||
// Get the list of files that exist for this script
|
||||
const fileCheck = await this.checkScriptExists(script);
|
||||
|
||||
if (fileCheck.files.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'No script files found to delete',
|
||||
deletedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
// Delete all files
|
||||
for (const filePath of fileCheck.files) {
|
||||
try {
|
||||
const fullPath = join(this.scriptsDirectory!, filePath);
|
||||
await unlink(fullPath);
|
||||
deletedFiles.push(filePath);
|
||||
} catch (error) {
|
||||
// Log error but continue deleting other files
|
||||
console.error(`Error deleting file ${filePath}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (deletedFiles.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to delete any script files',
|
||||
deletedFiles: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully deleted ${deletedFiles.length} file(s) for ${script.name}`,
|
||||
deletedFiles
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error deleting script:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'Failed to delete script',
|
||||
deletedFiles
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async compareScriptContent(script: Script): Promise<{ hasDifferences: boolean; differences: string[] }> {
|
||||
this.initializeConfig();
|
||||
const differences: string[] = [];
|
||||
|
||||
@@ -9,26 +9,35 @@
|
||||
"moduleDetection": "force",
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
|
||||
/* Strictness */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"checkJs": true,
|
||||
|
||||
/* Bundled projects */
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"ES2022"
|
||||
],
|
||||
"noEmit": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"jsx": "preserve",
|
||||
"plugins": [{ "name": "next" }],
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"incremental": true,
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./src/*"],
|
||||
"@/*": ["./src/*"]
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
],
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -37,7 +46,10 @@
|
||||
"**/*.tsx",
|
||||
"**/*.cjs",
|
||||
"**/*.js",
|
||||
".next/types/**/*.ts"
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user